Compare commits
No commits in common. "0aeb40ac56f70c8d7ef3674c76b5446bad35f7bd" and "e46b14ff07234db26652ec5b11327cea85ddf718" have entirely different histories.
0aeb40ac56
...
e46b14ff07
23 changed files with 57 additions and 659 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"section": "emergency",
|
"section": "emergency",
|
||||||
"emergencySchemaVersion": 1,
|
"emergencySchemaVersion": 1,
|
||||||
"contentVersion": 5,
|
"contentVersion": 4,
|
||||||
"updatedAt": "2026-08-31T00:00:00Z",
|
"updatedAt": "2026-08-31T00:00:00Z",
|
||||||
"services": {
|
"services": {
|
||||||
"emergencyNumber": "911",
|
"emergencyNumber": "911",
|
||||||
|
|
@ -22,19 +22,18 @@
|
||||||
"id": "weather",
|
"id": "weather",
|
||||||
"title": "Severe Weather",
|
"title": "Severe Weather",
|
||||||
"steps": [
|
"steps": [
|
||||||
|
"Call 911 if there is immediate danger",
|
||||||
"Seek appropriate shelter",
|
"Seek appropriate shelter",
|
||||||
"Follow event staff instructions",
|
"Follow event staff instructions"
|
||||||
"Call 911 only when event help isn't available."
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "medical",
|
"id": "medical",
|
||||||
"title": "Medical Emergency",
|
"title": "Medical Emergency",
|
||||||
"steps": [
|
"steps": [
|
||||||
|
"Call 911",
|
||||||
"Tell event staff or venue personnel",
|
"Tell event staff or venue personnel",
|
||||||
"Go to or call for first aid",
|
"Keep access clear for responders"
|
||||||
"Keep access clear for responders",
|
|
||||||
"If you can't reach event staff or first aid, call 911."
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ export default tseslint.config(
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"coverage/**",
|
"coverage/**",
|
||||||
"experiments/**",
|
"experiments/**",
|
||||||
"instance/**",
|
|
||||||
"scripts/serve-demo.mjs",
|
"scripts/serve-demo.mjs",
|
||||||
"public/sw.js",
|
"public/sw.js",
|
||||||
"share/**",
|
"share/**",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "lumen",
|
"name": "lumen",
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Lumen — offline-first festival PWA. Stage 1 foundation only; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1.",
|
"description": "Lumen — offline-first festival PWA. Stage 1 foundation only; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -18,9 +18,6 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"deploy": "npm run build && npm run bump-version patch",
|
|
||||||
"bump-version": "vite-node scripts/bump-version.ts",
|
|
||||||
"dev:deploy": "npm run build && npm run bump-version patch",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"package:staging": "vite-node pipeline/run.ts",
|
"package:staging": "vite-node pipeline/run.ts",
|
||||||
"package:smoke": "vite-node pipeline/run.ts --smoke-only",
|
"package:smoke": "vite-node pipeline/run.ts --smoke-only",
|
||||||
|
|
|
||||||
|
|
@ -63,24 +63,6 @@ export function createLayout(): {
|
||||||
}
|
}
|
||||||
header.append(themeBtn);
|
header.append(themeBtn);
|
||||||
|
|
||||||
const settingsLink = document.createElement("a");
|
|
||||||
settingsLink.href = "/settings";
|
|
||||||
settingsLink.setAttribute("data-route", "/settings");
|
|
||||||
settingsLink.className = "app-header__settings";
|
|
||||||
settingsLink.textContent = "⚙";
|
|
||||||
settingsLink.setAttribute("aria-label", "Settings");
|
|
||||||
settingsLink.style.minWidth = "36px";
|
|
||||||
settingsLink.style.minHeight = "36px";
|
|
||||||
settingsLink.style.padding = "0.25rem 0.5rem";
|
|
||||||
settingsLink.style.fontSize = "1.125rem";
|
|
||||||
settingsLink.style.textDecoration = "none";
|
|
||||||
settingsLink.style.display = "inline-flex";
|
|
||||||
settingsLink.style.alignItems = "center";
|
|
||||||
settingsLink.style.justifyContent = "center";
|
|
||||||
settingsLink.style.borderRadius = "999px";
|
|
||||||
settingsLink.style.cursor = "pointer";
|
|
||||||
header.append(settingsLink);
|
|
||||||
|
|
||||||
const statusChip = document.createElement("div");
|
const statusChip = document.createElement("div");
|
||||||
statusChip.className = "app-header__status";
|
statusChip.className = "app-header__status";
|
||||||
statusChip.setAttribute("role", "status");
|
statusChip.setAttribute("role", "status");
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ import { loadMapData, type MapHandle } from "./map.js";
|
||||||
import type { MapViewData } from "../domain/map/types.js";
|
import type { MapViewData } from "../domain/map/types.js";
|
||||||
import { createFestivalView } from "../ui/views/festival/festival.js";
|
import { createFestivalView } from "../ui/views/festival/festival.js";
|
||||||
import { createNotFoundView } from "../ui/views/not-found.js";
|
import { createNotFoundView } from "../ui/views/not-found.js";
|
||||||
import { renderSettings } from "../ui/views/settings/settings.js";
|
|
||||||
|
|
||||||
export interface RouteData {
|
export interface RouteData {
|
||||||
readonly emergency?: EmergencyViewModel | undefined;
|
readonly emergency?: EmergencyViewModel | undefined;
|
||||||
|
|
@ -76,8 +75,6 @@ function renderView(path: string, data?: RouteData): HTMLElement {
|
||||||
return createMapView(data?.map ?? null);
|
return createMapView(data?.map ?? null);
|
||||||
case "festival":
|
case "festival":
|
||||||
return createFestivalView();
|
return createFestivalView();
|
||||||
case "settings":
|
|
||||||
return renderSettings();
|
|
||||||
case "not-found":
|
case "not-found":
|
||||||
return createNotFoundView(path);
|
return createNotFoundView(path);
|
||||||
}
|
}
|
||||||
|
|
@ -222,12 +219,7 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
: "Activating…";
|
: "Activating…";
|
||||||
paint();
|
paint();
|
||||||
},
|
},
|
||||||
onProgress: (done, _total, label) => {
|
}).then((outcome) => {
|
||||||
syncMessage = `Downloading and verifying… (${String(done)} files · ${label})`;
|
|
||||||
paint();
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((outcome) => {
|
|
||||||
syncBusy = false;
|
syncBusy = false;
|
||||||
syncMessage =
|
syncMessage =
|
||||||
outcome.status === "ok"
|
outcome.status === "ok"
|
||||||
|
|
@ -244,16 +236,6 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
paint();
|
paint();
|
||||||
// Refresh the readiness chip behind the dialog either way.
|
// Refresh the readiness chip behind the dialog either way.
|
||||||
void refreshReadiness(false);
|
void refreshReadiness(false);
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
// Belt-and-braces: runSync is contracted to return outcomes, never
|
|
||||||
// throw — but a throw must never leave the button stuck on
|
|
||||||
// "Downloading…" forever with no message.
|
|
||||||
syncBusy = false;
|
|
||||||
syncMessage =
|
|
||||||
error instanceof Error ? `Sync failed. (${error.message})` : "Sync failed.";
|
|
||||||
paint();
|
|
||||||
void refreshReadiness(false);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
export type NotifyMode = "banner" | "browser" | "sound";
|
|
||||||
|
|
||||||
interface WindowWithLegacyAudio extends Window {
|
|
||||||
webkitAudioContext?: typeof AudioContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPrefs(): NotifyMode[] {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem("lumen-notify-modes");
|
|
||||||
return raw ? (JSON.parse(raw) as NotifyMode[]) : ["banner", "browser"];
|
|
||||||
} catch {
|
|
||||||
return ["banner", "browser"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPrefs(modes: NotifyMode[]): void {
|
|
||||||
localStorage.setItem("lumen-notify-modes", JSON.stringify(modes));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEnabledModes(): NotifyMode[] {
|
|
||||||
return getPrefs();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleMode(mode: NotifyMode): NotifyMode[] {
|
|
||||||
const current = getPrefs();
|
|
||||||
const next = current.includes(mode) ? current.filter((m) => m !== mode) : [...current, mode];
|
|
||||||
setPrefs(next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg: string): void {
|
|
||||||
const existing = document.getElementById("lumen-notification-banner");
|
|
||||||
if (existing) {
|
|
||||||
existing.textContent = msg;
|
|
||||||
existing.classList.add("notify-banner--visible");
|
|
||||||
window.setTimeout(() => {
|
|
||||||
existing.classList.remove("notify-banner--visible");
|
|
||||||
}, 4000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const toast = document.createElement("div");
|
|
||||||
toast.id = "lumen-notification-banner";
|
|
||||||
toast.textContent = msg;
|
|
||||||
toast.className = "notify-banner notify-banner--visible";
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
toast.remove();
|
|
||||||
}, 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function notify(msg: string, modes?: NotifyMode[]): void {
|
|
||||||
const active = modes ?? getPrefs();
|
|
||||||
if (active.includes("banner")) {
|
|
||||||
showToast(msg);
|
|
||||||
}
|
|
||||||
if (active.includes("browser") && typeof Notification !== "undefined") {
|
|
||||||
if (Notification.permission === "granted") {
|
|
||||||
new Notification(msg);
|
|
||||||
} else if (Notification.permission !== "denied") {
|
|
||||||
void Notification.requestPermission().then((p) => {
|
|
||||||
if (p === "granted") new Notification(msg);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (active.includes("sound")) {
|
|
||||||
try {
|
|
||||||
const Ctor = window.AudioContext ?? (window as WindowWithLegacyAudio).webkitAudioContext;
|
|
||||||
if (Ctor) {
|
|
||||||
const ctx = new Ctor();
|
|
||||||
if (ctx.state === "suspended") {
|
|
||||||
void ctx.resume();
|
|
||||||
}
|
|
||||||
const osc = ctx.createOscillator();
|
|
||||||
const gain = ctx.createGain();
|
|
||||||
osc.connect(gain);
|
|
||||||
gain.connect(ctx.destination);
|
|
||||||
osc.frequency.value = 1200;
|
|
||||||
gain.gain.value = 0.5;
|
|
||||||
osc.start();
|
|
||||||
osc.stop(ctx.currentTime + 0.3);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// audio is best-effort — a blocked AudioContext must not eat the notification
|
|
||||||
}
|
|
||||||
// Visual pulse fallback for mobile browsers that block Web Audio / vibration
|
|
||||||
const pulse = document.createElement("div");
|
|
||||||
pulse.className = "notify-pulse";
|
|
||||||
document.body.appendChild(pulse);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
pulse.remove();
|
|
||||||
}, 600);
|
|
||||||
if (typeof navigator.vibrate === "function") {
|
|
||||||
try {
|
|
||||||
navigator.vibrate([300, 100, 300, 100, 300]);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("vibrate blocked", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requestBrowserPermission(): void {
|
|
||||||
if (typeof Notification !== "undefined" && Notification.permission !== "granted") {
|
|
||||||
void Notification.requestPermission();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -962,149 +962,3 @@ html.dark .app-main__icon {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notification banner that appears at top of app */
|
|
||||||
.notify-banner {
|
|
||||||
position: fixed;
|
|
||||||
top: calc(var(--header-h, 56px) + 1rem);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%) translateY(-20px);
|
|
||||||
background: var(--accent, #c8473a);
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition:
|
|
||||||
opacity 0.25s ease,
|
|
||||||
transform 0.25s ease,
|
|
||||||
visibility 0.25s;
|
|
||||||
z-index: 1000;
|
|
||||||
pointer-events: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notify-banner--visible {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-view {
|
|
||||||
padding: 1rem;
|
|
||||||
max-width: 42rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section h2 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section p {
|
|
||||||
color: var(--fg-muted, #888);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-bottom: 1px solid var(--border, #ddd);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row__label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row__label strong {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row__label small {
|
|
||||||
color: var(--fg-muted, #888);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
min-width: 60px;
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: 1px solid var(--border, #ccc);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--surface, #f0f0f0);
|
|
||||||
color: var(--fg, #000);
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color 0.2s ease,
|
|
||||||
color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn--on {
|
|
||||||
background: var(--accent, #c8473a);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--accent, #c8473a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-test-btn {
|
|
||||||
display: block;
|
|
||||||
margin: 1.5rem auto 0;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--accent, #c8473a);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-test-btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notify-pulse {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(200, 71, 58, 0.25);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
animation: pulseFlash 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulseFlash {
|
|
||||||
0% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.emergency-share-btn {
|
|
||||||
display: block;
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.emergency-share-btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
*/
|
*/
|
||||||
import { HttpTransport } from "../sync/transport/http.js";
|
import { HttpTransport } from "../sync/transport/http.js";
|
||||||
import { TransportError } from "../sync/transport/types.js";
|
import { TransportError } from "../sync/transport/types.js";
|
||||||
import { pullCandidate, type PullProgress } from "../sync/pull.js";
|
import { pullCandidate } from "../sync/pull.js";
|
||||||
import { publicKeyFromDerBase64 } from "../sync/verifier/ed25519.js";
|
import { publicKeyFromDerBase64 } from "../sync/verifier/ed25519.js";
|
||||||
import type { TrustedKeySet } from "../sync/verifier/types.js";
|
import type { TrustedKeySet } from "../sync/verifier/types.js";
|
||||||
import { stageVerifiedPackage, activateStagedPackage } from "../sync/activation.js";
|
import { stageVerifiedPackage, activateStagedPackage } from "../sync/activation.js";
|
||||||
|
|
@ -44,7 +44,6 @@ export interface SyncRunDeps {
|
||||||
readonly appVersion?: string;
|
readonly appVersion?: string;
|
||||||
readonly supportedSchemaRange?: readonly number[];
|
readonly supportedSchemaRange?: readonly number[];
|
||||||
readonly onPhase?: (phase: "checking" | "downloading" | "activating") => void;
|
readonly onPhase?: (phase: "checking" | "downloading" | "activating") => void;
|
||||||
readonly onProgress?: (done: number, total: number | null, label: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSyncConfig(value: unknown): value is SyncConfig {
|
function isSyncConfig(value: unknown): value is SyncConfig {
|
||||||
|
|
@ -67,21 +66,14 @@ export function defaultConfigUrl(): string {
|
||||||
export async function loadSyncConfig(
|
export async function loadSyncConfig(
|
||||||
fetchImpl: typeof fetch,
|
fetchImpl: typeof fetch,
|
||||||
url: string = defaultConfigUrl(),
|
url: string = defaultConfigUrl(),
|
||||||
timeoutMs = 10_000,
|
|
||||||
): Promise<SyncConfig | null> {
|
): Promise<SyncConfig | null> {
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
controller.abort();
|
|
||||||
}, timeoutMs);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchImpl(url, { cache: "no-store", signal: controller.signal });
|
const res = await fetchImpl(url, { cache: "no-store" });
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const value: unknown = await res.json();
|
const value: unknown = await res.json();
|
||||||
return isSyncConfig(value) ? value : null;
|
return isSyncConfig(value) ? value : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,25 +124,8 @@ export async function runSync(deps: SyncRunDeps = {}): Promise<SyncOutcome> {
|
||||||
}
|
}
|
||||||
if (trusted.size === 0) return { status: "not-configured", detail: "no trusted keys pinned" };
|
if (trusted.size === 0) return { status: "not-configured", detail: "no trusted keys pinned" };
|
||||||
|
|
||||||
const transport = (() => {
|
const transport = new HttpTransport(config.origin, { fetchImpl });
|
||||||
try {
|
const user = await openUserDB();
|
||||||
return new HttpTransport(config.origin, { fetchImpl });
|
|
||||||
} catch (error) {
|
|
||||||
return { error: describeError(error) } as const;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
if ("error" in transport) {
|
|
||||||
return {
|
|
||||||
status: "error",
|
|
||||||
detail: `bad sync origin (${transport.error}) — the hub must serve HTTPS, e.g. node scripts/serve-demo.mjs`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let user;
|
|
||||||
try {
|
|
||||||
user = await openUserDB();
|
|
||||||
} catch (error) {
|
|
||||||
return { status: "error", detail: `local storage unavailable (${describeError(error)})` };
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
deps.onPhase?.("checking");
|
deps.onPhase?.("checking");
|
||||||
const outcome = await pullCandidate(
|
const outcome = await pullCandidate(
|
||||||
|
|
@ -164,13 +139,6 @@ export async function runSync(deps: SyncRunDeps = {}): Promise<SyncOutcome> {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isQuarantined: (packageVersion) => isQuarantined(user, config.edition, packageVersion),
|
isQuarantined: (packageVersion) => isQuarantined(user, config.edition, packageVersion),
|
||||||
...(deps.onProgress
|
|
||||||
? {
|
|
||||||
onProgress: (progress: PullProgress): void => {
|
|
||||||
deps.onProgress?.(progress.done, progress.total, progress.label);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* Current shell version (semver). Must match package.json — enforced by test.
|
* Current shell version (semver). Must match package.json — enforced by test.
|
||||||
* Published datasets declare appCompatibility against this.
|
* Published datasets declare appCompatibility against this.
|
||||||
*/
|
*/
|
||||||
export const APP_VERSION = "0.1.1";
|
export const APP_VERSION = "0.1.0";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema versions this shell can render. C-23: shells ship the range first
|
* Schema versions this shell can render. C-23: shells ship the range first
|
||||||
|
|
|
||||||
|
|
@ -7,50 +7,25 @@ import type { EmergencyFloor } from "./types.js";
|
||||||
|
|
||||||
export const EMERGENCY_FLOOR: EmergencyFloor = {
|
export const EMERGENCY_FLOOR: EmergencyFloor = {
|
||||||
floor: true,
|
floor: true,
|
||||||
floorVersion: "1.0.0+1",
|
floorVersion: "1.0.0+3",
|
||||||
emergencySchemaVersion: 1,
|
emergencySchemaVersion: 1,
|
||||||
generatedAt: "2026-10-05T16:10:42.201Z",
|
generatedAt: "2026-08-27T09:00:00Z",
|
||||||
sourceContentVersion: 5,
|
sourceContentVersion: 3,
|
||||||
services: {
|
services: {
|
||||||
emergencyNumber: "911",
|
emergencyNumber: "911",
|
||||||
security: {
|
security: { phone: "+1-555-0142", location: "Main Gate Kiosk" },
|
||||||
phone: "+1-555-0100",
|
firstAid: { location: "Behind Stage B", hours: "10:00–02:00" },
|
||||||
location: "Ask event staff or venue personnel",
|
|
||||||
},
|
|
||||||
firstAid: {
|
|
||||||
location: "Ask event staff or venue personnel",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
address: {
|
address: {
|
||||||
lines: ["Son's Blue River Camp", "2769 Sherrill Rd", "Kingsbury, TX 78638"],
|
lines: ["123 Festival Way", "Austin, TX 78701"],
|
||||||
coordinates: {
|
coordinates: { lat: 30.2672, lon: -97.7431 },
|
||||||
lat: 29.6488,
|
|
||||||
lon: -97.8247,
|
|
||||||
},
|
},
|
||||||
},
|
musterPoints: [{ name: "North Field" }],
|
||||||
musterPoints: [],
|
exits: [{ name: "East Gate" }],
|
||||||
exits: [],
|
aedSummary: "AEDs: 1 locations",
|
||||||
aedSummary: "AED on site",
|
|
||||||
procedures: [
|
procedures: [
|
||||||
{
|
{ id: "weather", title: "Severe Weather", steps: ["Seek shelter", "Follow staff"] },
|
||||||
id: "weather",
|
{ id: "medical", title: "Medical Emergency", steps: ["Call 911", "Locate first aid"] },
|
||||||
title: "Severe Weather",
|
|
||||||
steps: [
|
|
||||||
"Seek appropriate shelter",
|
|
||||||
"Follow event staff instructions",
|
|
||||||
"Call 911 only when event help isn't available.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "medical",
|
|
||||||
title: "Medical Emergency",
|
|
||||||
steps: [
|
|
||||||
"Tell event staff or venue personnel",
|
|
||||||
"Go to or call for first aid",
|
|
||||||
"Keep access clear for responders",
|
|
||||||
"If you can't reach event staff or first aid, call 911.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,7 @@ export async function registerSW(): Promise<SWRegistrationResult> {
|
||||||
return { status: "unsupported" };
|
return { status: "unsupported" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unregister all existing service workers first to ensure clean update
|
// Do not block app boot — register after load, catch errors.
|
||||||
await unregisterAllServiceWorkers();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||||
return { status: "registered", registration: reg };
|
return { status: "registered", registration: reg };
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { LatestPointer } from "../data/festival-package/types.js";
|
import type { LatestPointer } from "../data/festival-package/types.js";
|
||||||
import { verifyPackage } from "./verifier/package.js";
|
import { verifyPackage } from "./verifier/package.js";
|
||||||
import type { VerifyDependencies, VerifyResult } from "./verifier/types.js";
|
import type { VerifyDependencies, VerifyResult } from "./verifier/types.js";
|
||||||
import { TransportError, type Transport } from "./transport/types.js";
|
import type { Transport } from "./transport/types.js";
|
||||||
|
|
||||||
export interface CandidatePackage {
|
export interface CandidatePackage {
|
||||||
readonly pointer: LatestPointer;
|
readonly pointer: LatestPointer;
|
||||||
|
|
@ -16,29 +16,6 @@ export interface QuarantineSkipped {
|
||||||
|
|
||||||
export type PullOutcome = CandidatePackage | QuarantineSkipped | null;
|
export type PullOutcome = CandidatePackage | QuarantineSkipped | null;
|
||||||
|
|
||||||
export interface PullProgress {
|
|
||||||
/** Files fully fetched so far (manifest + signature + sections + assets). */
|
|
||||||
readonly done: number;
|
|
||||||
/** Total files when known (unknown until the manifest + assets inventory load). */
|
|
||||||
readonly total: number | null;
|
|
||||||
readonly label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRetryable(error: unknown): boolean {
|
|
||||||
if (!(error instanceof TransportError)) return false;
|
|
||||||
// 404/malformed/aborted mean "don't bother asking again". Timeouts,
|
|
||||||
// dropped connections and 5xx often clear on a festival box between waves.
|
|
||||||
return (
|
|
||||||
error.code === "timeout" || error.code === "network_unavailable" || error.code === "http_error"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function siblingUrl(manifestUrl: string, name: string): string {
|
function siblingUrl(manifestUrl: string, name: string): string {
|
||||||
if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString();
|
if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString();
|
||||||
return new URL(name, `https://transport.invalid${manifestUrl}`).pathname;
|
return new URL(name, `https://transport.invalid${manifestUrl}`).pathname;
|
||||||
|
|
@ -61,31 +38,8 @@ export async function pullCandidate(
|
||||||
readonly signal?: AbortSignal;
|
readonly signal?: AbortSignal;
|
||||||
readonly timeoutMs?: number;
|
readonly timeoutMs?: number;
|
||||||
readonly isQuarantined?: (packageVersion: number) => Promise<boolean>;
|
readonly isQuarantined?: (packageVersion: number) => Promise<boolean>;
|
||||||
/** Per-file retries on timeout/network/5xx (default 3). Total wait stays bounded. */
|
|
||||||
readonly retries?: number;
|
|
||||||
/** File-granular progress so the UI never looks stuck on a busy hub. */
|
|
||||||
readonly onProgress?: (progress: PullProgress) => void;
|
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<PullOutcome> {
|
): Promise<PullOutcome> {
|
||||||
const retries = options.retries ?? 3;
|
|
||||||
let done = 0;
|
|
||||||
const report = (total: number | null, label: string): void => {
|
|
||||||
options.onProgress?.({ done, total, label });
|
|
||||||
};
|
|
||||||
async function fetchWithRetry(path: string, label: string): Promise<Uint8Array> {
|
|
||||||
// oxlint-disable-next-line no-await-in-loop -- sequential retry with backoff is intentional
|
|
||||||
for (let attempt = 0; ; attempt++) {
|
|
||||||
try {
|
|
||||||
const bytes = await transport.fetchBytes(path, options);
|
|
||||||
done += 1;
|
|
||||||
report(null, label);
|
|
||||||
return bytes;
|
|
||||||
} catch (error) {
|
|
||||||
if (attempt >= retries || !isRetryable(error)) throw error;
|
|
||||||
await sleep(300 * (attempt + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!transport.isAvailable()) return null;
|
if (!transport.isAvailable()) return null;
|
||||||
const pointer = await transport.fetchPointer(edition, options);
|
const pointer = await transport.fetchPointer(edition, options);
|
||||||
if (!pointer) return null;
|
if (!pointer) return null;
|
||||||
|
|
@ -93,10 +47,10 @@ export async function pullCandidate(
|
||||||
return { skipped: "quarantined", pointer };
|
return { skipped: "quarantined", pointer };
|
||||||
}
|
}
|
||||||
const manifestUrl = pointer.manifestUrl;
|
const manifestUrl = pointer.manifestUrl;
|
||||||
const manifestBytes = await fetchWithRetry(manifestUrl, "manifest");
|
const manifestBytes = await transport.fetchBytes(manifestUrl, options);
|
||||||
const signatureBytes = await fetchWithRetry(
|
const signatureBytes = await transport.fetchBytes(
|
||||||
siblingUrl(manifestUrl, "signature.json"),
|
siblingUrl(manifestUrl, "signature.json"),
|
||||||
"signature",
|
options,
|
||||||
);
|
);
|
||||||
let signature: unknown;
|
let signature: unknown;
|
||||||
try {
|
try {
|
||||||
|
|
@ -109,7 +63,7 @@ export async function pullCandidate(
|
||||||
manifestBytes,
|
manifestBytes,
|
||||||
signature,
|
signature,
|
||||||
files: {
|
files: {
|
||||||
getFile: (path) => fetchWithRetry(siblingUrl(manifestUrl, path), path),
|
getFile: (path) => transport.fetchBytes(siblingUrl(manifestUrl, path), options),
|
||||||
},
|
},
|
||||||
// Pointer identity lets pre-manifest rejections (bad signature) still
|
// Pointer identity lets pre-manifest rejections (bad signature) still
|
||||||
// quarantine under the right edition/version — otherwise the no-loop
|
// quarantine under the right edition/version — otherwise the no-loop
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,7 @@ export class HttpTransport implements Transport {
|
||||||
constructor(baseUrl: string | URL, options: HttpTransportOptions = {}) {
|
constructor(baseUrl: string | URL, options: HttpTransportOptions = {}) {
|
||||||
this.baseUrl = new URL(baseUrl);
|
this.baseUrl = new URL(baseUrl);
|
||||||
if (this.baseUrl.protocol !== "https:")
|
if (this.baseUrl.protocol !== "https:")
|
||||||
throw new TransportError(
|
throw new TransportError("malformed_response", "HTTP transport requires HTTPS");
|
||||||
"malformed_response",
|
|
||||||
`HTTP transport requires HTTPS (got ${this.baseUrl.protocol}//${this.baseUrl.host}) — serve the hub with node scripts/serve-demo.mjs, not plain HTTP`,
|
|
||||||
);
|
|
||||||
const raw = options.fetchImpl ?? fetch;
|
const raw = options.fetchImpl ?? fetch;
|
||||||
// Native fetch rejects a non-Window receiver ("Illegal invocation") —
|
// Native fetch rejects a non-Window receiver ("Illegal invocation") —
|
||||||
// calling this.fetchImpl(url) would pass the transport as `this`. Bind it.
|
// calling this.fetchImpl(url) would pass the transport as `this`. Bind it.
|
||||||
|
|
@ -60,11 +57,8 @@ export class HttpTransport implements Transport {
|
||||||
}
|
}
|
||||||
|
|
||||||
isAvailable(): boolean {
|
isAvailable(): boolean {
|
||||||
// Deliberately NOT gated on navigator.onLine: an isolated festival LAN
|
if (typeof navigator === "undefined") return true;
|
||||||
// box has no upstream internet, so browsers report "offline" while the
|
return navigator.onLine;
|
||||||
// origin is fully reachable. A truly dead network fails fast at fetch
|
|
||||||
// time (network_unavailable) instead of hanging the UI.
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchPointer(
|
async fetchPointer(
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@
|
||||||
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207
|
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type RouteId =
|
export type RouteId = "home" | "emergency" | "schedule" | "map" | "festival" | "not-found";
|
||||||
"home" | "emergency" | "schedule" | "map" | "festival" | "settings" | "not-found";
|
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
id: RouteId;
|
id: RouteId;
|
||||||
|
|
@ -22,7 +21,6 @@ export const ROUTES: readonly Route[] = [
|
||||||
{ id: "emergency", path: "/emergency", label: "Emergency", title: "Emergency" },
|
{ id: "emergency", path: "/emergency", label: "Emergency", title: "Emergency" },
|
||||||
{ id: "schedule", path: "/schedule", label: "Schedule", title: "Schedule" },
|
{ id: "schedule", path: "/schedule", label: "Schedule", title: "Schedule" },
|
||||||
{ id: "map", path: "/map", label: "Map", title: "Map" },
|
{ id: "map", path: "/map", label: "Map", title: "Map" },
|
||||||
{ id: "settings", path: "/settings", label: "Settings", title: "Settings" },
|
|
||||||
{ id: "festival", path: "/festival", label: "Information", title: "Information" },
|
{ id: "festival", path: "/festival", label: "Information", title: "Information" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,25 +136,6 @@ export function createEmergencyView(
|
||||||
urgent.append(text("p", "Tap to call. The number is also selectable for copying."));
|
urgent.append(text("p", "Tap to call. The number is also selectable for copying."));
|
||||||
section.append(urgent);
|
section.append(urgent);
|
||||||
|
|
||||||
const shareBtn = document.createElement("button");
|
|
||||||
shareBtn.className = "emergency-share-btn";
|
|
||||||
shareBtn.textContent = "Share emergency info";
|
|
||||||
shareBtn.setAttribute("aria-label", "Share emergency information with others");
|
|
||||||
shareBtn.onclick = () => {
|
|
||||||
const textToShare = `Emergency: ${services.emergencyNumber}\nAddress: ${address.lines.join(", ")}\nFirst Aid: ${services.firstAid.location || "See venue"}`;
|
|
||||||
if (navigator.share) {
|
|
||||||
navigator.share({ title: "Emergency info — Lumen", text: textToShare }).catch(() => {
|
|
||||||
/* user dismissed the share sheet */
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
navigator.clipboard?.writeText(textToShare).catch(() => {
|
|
||||||
/* clipboard blocked — the alert below still shows the text */
|
|
||||||
});
|
|
||||||
alert("Copied to clipboard: " + textToShare.slice(0, 80) + "...");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
section.append(shareBtn);
|
|
||||||
|
|
||||||
const contacts = document.createElement("section");
|
const contacts = document.createElement("section");
|
||||||
contacts.setAttribute("aria-labelledby", "emergency-services-heading");
|
contacts.setAttribute("aria-labelledby", "emergency-services-heading");
|
||||||
contacts.append(heading("emergency-services-heading", "Contacts"));
|
contacts.append(heading("emergency-services-heading", "Contacts"));
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ export function createHomeView(): HTMLElement {
|
||||||
grid.className = "home-grid";
|
grid.className = "home-grid";
|
||||||
|
|
||||||
for (const r of ROUTES) {
|
for (const r of ROUTES) {
|
||||||
if (r.id === "settings") continue;
|
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.className = `home-tile${r.id === "emergency" ? " home-tile--emergency" : ""}`;
|
a.className = `home-tile${r.id === "emergency" ? " home-tile--emergency" : ""}`;
|
||||||
a.href = r.path;
|
a.href = r.path;
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import {
|
|
||||||
getEnabledModes,
|
|
||||||
toggleMode,
|
|
||||||
requestBrowserPermission,
|
|
||||||
notify,
|
|
||||||
type NotifyMode,
|
|
||||||
} from "../../../app/notifications.js";
|
|
||||||
|
|
||||||
export function renderSettings(): HTMLElement {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "settings-view";
|
|
||||||
el.setAttribute("aria-labelledby", "settings-heading");
|
|
||||||
|
|
||||||
const h1 = document.createElement("h1");
|
|
||||||
h1.id = "settings-heading";
|
|
||||||
h1.textContent = "Settings";
|
|
||||||
el.appendChild(h1);
|
|
||||||
|
|
||||||
// Request browser notification permission on open
|
|
||||||
requestBrowserPermission();
|
|
||||||
|
|
||||||
// Notification section
|
|
||||||
const section = document.createElement("section");
|
|
||||||
section.className = "settings-section";
|
|
||||||
const sectionTitle = document.createElement("h2");
|
|
||||||
sectionTitle.textContent = "Notifications";
|
|
||||||
section.appendChild(sectionTitle);
|
|
||||||
|
|
||||||
const desc = document.createElement("p");
|
|
||||||
desc.textContent = "Choose how you want to be notified when things change.";
|
|
||||||
section.appendChild(desc);
|
|
||||||
|
|
||||||
const modes: { id: NotifyMode; label: string; hint: string }[] = [
|
|
||||||
{ id: "banner", label: "On-screen banner", hint: "Shows a message inside the app" },
|
|
||||||
{
|
|
||||||
id: "browser",
|
|
||||||
label: "Browser notification",
|
|
||||||
hint: "Pops up even if the tab is in the background",
|
|
||||||
},
|
|
||||||
{ id: "sound", label: "Sound", hint: "Plays a short beep" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const enabled = getEnabledModes();
|
|
||||||
|
|
||||||
for (const m of modes) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "settings-row";
|
|
||||||
|
|
||||||
const label = document.createElement("div");
|
|
||||||
label.className = "settings-row__label";
|
|
||||||
const strong = document.createElement("strong");
|
|
||||||
strong.textContent = m.label;
|
|
||||||
const hint = document.createElement("small");
|
|
||||||
hint.textContent = m.hint;
|
|
||||||
label.append(strong, hint);
|
|
||||||
row.appendChild(label);
|
|
||||||
|
|
||||||
const toggle = document.createElement("button");
|
|
||||||
toggle.className = "toggle-btn" + (enabled.includes(m.id) ? " toggle-btn--on" : "");
|
|
||||||
toggle.setAttribute("role", "switch");
|
|
||||||
toggle.setAttribute("aria-checked", String(enabled.includes(m.id)));
|
|
||||||
toggle.textContent = enabled.includes(m.id) ? "ON" : "OFF";
|
|
||||||
|
|
||||||
toggle.addEventListener("click", () => {
|
|
||||||
const next = toggleMode(m.id);
|
|
||||||
const isOn = next.includes(m.id);
|
|
||||||
toggle.classList.toggle("toggle-btn--on", isOn);
|
|
||||||
toggle.setAttribute("aria-checked", String(isOn));
|
|
||||||
toggle.textContent = isOn ? "ON" : "OFF";
|
|
||||||
notify("Toggled " + m.label + " " + (isOn ? "ON" : "OFF"), [m.id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
row.appendChild(toggle);
|
|
||||||
section.appendChild(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
el.appendChild(section);
|
|
||||||
|
|
||||||
// Test notification button
|
|
||||||
const testBtn = document.createElement("button");
|
|
||||||
testBtn.className = "settings-test-btn";
|
|
||||||
testBtn.textContent = "Send a test notification";
|
|
||||||
testBtn.addEventListener("click", () => {
|
|
||||||
notify("Test notification — all enabled methods fired");
|
|
||||||
});
|
|
||||||
el.appendChild(testBtn);
|
|
||||||
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
@ -170,10 +170,6 @@ export function createStatusView(
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "READY":
|
case "READY":
|
||||||
// READY must still be able to pull a newer published package;
|
|
||||||
// onGetData runs the sync flow ("You already have the latest"
|
|
||||||
// when there is nothing new).
|
|
||||||
buttons.append(actionButton("Check for updates", actions.onGetData));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
buttons.append(actionButton("Check my data", actions.onCheckData));
|
buttons.append(actionButton("Check my data", actions.onCheckData));
|
||||||
|
|
|
||||||
|
|
@ -236,16 +236,4 @@ describe("app sync coordinator", () => {
|
||||||
});
|
});
|
||||||
expect(result.status).toBe("not-configured");
|
expect(result.status).toBe("not-configured");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an error outcome (never throws/hangs) for a plain-HTTP origin", async () => {
|
|
||||||
const origin = buildOrigin();
|
|
||||||
const httpConfig = { ...config(origin), origin: "http://10.144.0.221:8080/origin" };
|
|
||||||
const result = await runSync({
|
|
||||||
fetchConfig: async () => httpConfig,
|
|
||||||
fetchImpl: fakeFetch(origin),
|
|
||||||
appVersion: "1.0.0",
|
|
||||||
});
|
|
||||||
expect(result.status).toBe("error");
|
|
||||||
expect(result.status === "error" && result.detail).toContain("HTTPS");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -72,17 +72,6 @@ describe("Status view", () => {
|
||||||
expect(view.querySelectorAll(".status-check--ok")).toHaveLength(2);
|
expect(view.querySelectorAll(".status-check--ok")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("READY offers Check for updates wired to the sync flow", () => {
|
|
||||||
const a = actions();
|
|
||||||
const view = createStatusView(report(), a);
|
|
||||||
const btn = [...view.querySelectorAll("button")].find(
|
|
||||||
(b) => b.textContent === "Check for updates",
|
|
||||||
);
|
|
||||||
expect(btn).toBeTruthy();
|
|
||||||
btn?.dispatchEvent(new MouseEvent("click"));
|
|
||||||
expect(a.onGetData).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("PARTIAL enumerates missing and offers Continue setup", () => {
|
it("PARTIAL enumerates missing and offers Continue setup", () => {
|
||||||
const a = actions();
|
const a = actions();
|
||||||
const view = createStatusView(
|
const view = createStatusView(
|
||||||
|
|
|
||||||
|
|
@ -119,20 +119,12 @@ describe("SW registration — page bridge", () => {
|
||||||
it("registers /sw.js with scope / when supported", async () => {
|
it("registers /sw.js with scope / when supported", async () => {
|
||||||
const mockReg = { scope: "/", waiting: null } as unknown as ServiceWorkerRegistration;
|
const mockReg = { scope: "/", waiting: null } as unknown as ServiceWorkerRegistration;
|
||||||
const mockRegister = vi.fn().mockResolvedValue(mockReg);
|
const mockRegister = vi.fn().mockResolvedValue(mockReg);
|
||||||
const mockUnregister = vi.fn().mockResolvedValue(true);
|
|
||||||
Object.defineProperty(globalThis, "navigator", {
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
value: {
|
value: { serviceWorker: { register: mockRegister } },
|
||||||
serviceWorker: {
|
|
||||||
register: mockRegister,
|
|
||||||
getRegistrations: vi.fn().mockResolvedValue([{ unregister: mockUnregister }]),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
writable: true,
|
writable: true,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
const res = await registerSW();
|
const res = await registerSW();
|
||||||
// clean-update: any pre-existing worker is unregistered before re-registering
|
|
||||||
expect(mockUnregister).toHaveBeenCalled();
|
|
||||||
expect(mockRegister).toHaveBeenCalledWith("/sw.js", { scope: "/" });
|
expect(mockRegister).toHaveBeenCalledWith("/sw.js", { scope: "/" });
|
||||||
expect(res.status).toBe("registered");
|
expect(res.status).toBe("registered");
|
||||||
});
|
});
|
||||||
|
|
@ -140,12 +132,7 @@ describe("SW registration — page bridge", () => {
|
||||||
it("returns failed when register throws (offline or HTTPS issue)", async () => {
|
it("returns failed when register throws (offline or HTTPS issue)", async () => {
|
||||||
const mockRegister = vi.fn().mockRejectedValue(new Error("HTTPS required"));
|
const mockRegister = vi.fn().mockRejectedValue(new Error("HTTPS required"));
|
||||||
Object.defineProperty(globalThis, "navigator", {
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
value: {
|
value: { serviceWorker: { register: mockRegister } },
|
||||||
serviceWorker: {
|
|
||||||
register: mockRegister,
|
|
||||||
getRegistrations: vi.fn().mockResolvedValue([]),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
writable: true,
|
writable: true,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,10 @@ describe("Stage 9 HttpTransport", () => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports available even when the browser thinks it is offline (isolated LAN hub)", () => {
|
it("reports availability from the browser online hint without making a request", () => {
|
||||||
expect(transport.isAvailable()).toBe(true);
|
expect(transport.isAvailable()).toBe(true);
|
||||||
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
|
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
|
||||||
// A festival box has no upstream internet: onLine is false while the
|
expect(transport.isAvailable()).toBe(false);
|
||||||
// origin is reachable. Availability must not gate the attempt — a truly
|
|
||||||
// dead network fails fast at fetch time instead.
|
|
||||||
expect(transport.isAvailable()).toBe(true);
|
|
||||||
expect(fetchImpl).not.toHaveBeenCalled();
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -167,49 +164,6 @@ describe("Stage 9 pull and verifier boundary", () => {
|
||||||
).toBeGreaterThan(2);
|
).toBeGreaterThan(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries a transient network failure mid-download and reports progress", async () => {
|
|
||||||
const keys = generateTestKeyPair();
|
|
||||||
const built = buildPackage(makeValidInput(), { signWith: keys });
|
|
||||||
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
|
||||||
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
|
|
||||||
const files = new Map<string, Uint8Array>();
|
|
||||||
files.set("/editions/lumen-2026/packages/1/manifest.json", manifestBytes);
|
|
||||||
files.set(
|
|
||||||
"/editions/lumen-2026/packages/1/signature.json",
|
|
||||||
new TextEncoder().encode(JSON.stringify(built.pkg.signature)),
|
|
||||||
);
|
|
||||||
for (const [name, file] of built.pkg.files)
|
|
||||||
files.set(`/editions/lumen-2026/packages/1/${name}`, file.canonicalBytes);
|
|
||||||
for (const asset of built.pkg.assets)
|
|
||||||
files.set(`/editions/lumen-2026/packages/1/${asset.file}`, asset.bytesContent);
|
|
||||||
let emergencyFailures = 1;
|
|
||||||
const seen: string[] = [];
|
|
||||||
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
||||||
const url = inputUrl(input);
|
|
||||||
if (url.endsWith("/latest.json")) return Promise.resolve(response(pointer()));
|
|
||||||
if (url.endsWith("/emergency.json") && emergencyFailures > 0) {
|
|
||||||
emergencyFailures -= 1;
|
|
||||||
return Promise.reject(new TypeError("wifi hiccup"));
|
|
||||||
}
|
|
||||||
const body = files.get(new URL(url).pathname);
|
|
||||||
return Promise.resolve(body ? response(body) : response("missing", 404));
|
|
||||||
});
|
|
||||||
const result = await pullCandidate(
|
|
||||||
new HttpTransport("https://festival.example/", { fetchImpl }),
|
|
||||||
"lumen-2026",
|
|
||||||
{
|
|
||||||
trustedKeys: new Map([[keys.fingerprint, publicKeyFromDerBase64(keys.publicKeyDerBase64)]]),
|
|
||||||
appVersion: "1.0.0",
|
|
||||||
supportedSchemaRange: [1],
|
|
||||||
},
|
|
||||||
{ onProgress: (progress) => seen.push(progress.label) },
|
|
||||||
);
|
|
||||||
if (!result || "skipped" in result) throw new Error("expected candidate");
|
|
||||||
expect(result.result.ok).toBe(true);
|
|
||||||
expect(emergencyFailures).toBe(0);
|
|
||||||
expect(seen).toContain("emergency.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not retrieve protected files when the signature gate fails", async () => {
|
it("does not retrieve protected files when the signature gate fails", async () => {
|
||||||
const keys = generateTestKeyPair();
|
const keys = generateTestKeyPair();
|
||||||
const built = buildPackage(makeValidInput(), { signWith: keys });
|
const built = buildPackage(makeValidInput(), { signWith: keys });
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,10 @@ function lumenShellPlugin(): Plugin {
|
||||||
const version = `${pkg.version}-${gitHash}`;
|
const version = `${pkg.version}-${gitHash}`;
|
||||||
const cacheName = `lumen-shell-${version}`;
|
const cacheName = `lumen-shell-${version}`;
|
||||||
|
|
||||||
// Collect hashed assets produced by this build — sourcemaps excluded:
|
// Collect hashed assets produced by this build
|
||||||
// they bloat the install (~580KB) and the SW would cache-first them.
|
|
||||||
const assetsDir = join(dist, "assets");
|
const assetsDir = join(dist, "assets");
|
||||||
const assetFiles = existsSync(assetsDir) ? readdirSync(assetsDir) : [];
|
const assetFiles = existsSync(assetsDir) ? readdirSync(assetsDir) : [];
|
||||||
const hashedAssets = assetFiles.filter((f) => !f.endsWith(".map")).map((f) => `/assets/${f}`);
|
const hashedAssets = assetFiles.map((f) => `/assets/${f}`);
|
||||||
|
|
||||||
// Core shell URLs that must be precached (deterministic list per build)
|
// Core shell URLs that must be precached (deterministic list per build)
|
||||||
const core = [
|
const core = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue