Open installed dashboard offline with a versioned app shell #227

Merged
timmy merged 1 commits from timmy/226-offline-app-shell into main 2026-08-07 20:57:33 +00:00
6 changed files with 252 additions and 5 deletions

View File

@ -68,6 +68,20 @@ API process is running and does not contact Gitea. GET `/readyz` is the
readiness check: it validates the configured Gitea credentials and returns readiness check: it validates the configured Gitea credentials and returns
HTTP 503 with an error when Gitea is unavailable or authentication fails. HTTP 503 with an error when Gitea is unavailable or authentication fails.
## Offline mobile shell
After one successful online load, the installed dashboard precaches a versioned,
subpath-scoped application shell. During a network outage, navigation falls back
to that shell so local issue and review drafts remain reachable. A visible,
accessible offline notice distinguishes this mode from live Gitea data, and the
dashboard refreshes its live snapshot when connectivity returns.
The offline guarantee covers only the HTML/JavaScript shell, manifest, icons, and
browser-local drafts. API responses and mutations are never cached or queued;
submissions still require connectivity. Service-worker upgrades are atomic and
remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on
the same origin.
Run the test suite with: Run the test suite with:
```bash ```bash

View File

@ -55,6 +55,8 @@ textarea { resize: vertical; min-height: 120px; }
.small { font-size: 12px; color: #94a3b8; } .small { font-size: 12px; color: #94a3b8; }
.status { display:inline-flex; gap:6px; align-items:center; } .status { display:inline-flex; gap:6px; align-items:center; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; } .dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
.offline-status[hidden] { display:none; }
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); } .widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; } .widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; } .event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
@ -273,6 +275,9 @@ textarea { resize: vertical; min-height: 120px; }
<span class="small muted" id="clock"></span> <span class="small muted" id="clock"></span>
</div> </div>
</header> </header>
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
</div>
<main> <main>
<section class="panel my-work" id="my-work"> <section class="panel my-work" id="my-work">
@ -2989,6 +2994,20 @@ textarea { resize: vertical; min-height: 120px; }
}); });
function load() { return contextPoller.refresh(); } function load() { return contextPoller.refresh(); }
const offlineStatus = qs('#offline-status');
function showOfflineStatus() {
offlineStatus.hidden = false;
setStatus('Offline');
}
function reconnectLiveData() {
offlineStatus.hidden = true;
setStatus('Reconnecting…');
contextPoller.refresh();
}
if (!navigator.onLine) showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData);
qs('#refresh').addEventListener('click', load); qs('#refresh').addEventListener('click', load);
qs('#start-work-session').addEventListener('click', () => { qs('#start-work-session').addEventListener('click', () => {
if (!filterMyWork(lastMyWork, selectedWorkFilter).length) { if (!filterMyWork(lastMyWork, selectedWorkFilter).length) {

View File

@ -1,9 +1,24 @@
const CACHE = 'stackchain-shell-v1'; const CACHE = 'stackchain-dashboard-shell-v2';
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
const SHELL = [ const SHELL = [
BASE,
BASE + 'manifest.webmanifest', BASE + 'manifest.webmanifest',
BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png', BASE + 'static/icons/stackchain-512.png',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/my-work.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/pull-sheet.js',
BASE + 'static/review-sheet.js',
BASE + 'static/work-route.js',
BASE + 'static/context-poller.js',
]; ];
self.addEventListener('install', event => { self.addEventListener('install', event => {
@ -12,7 +27,8 @@ self.addEventListener('install', event => {
self.addEventListener('activate', event => { self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all( event.waitUntil(caches.keys().then(keys => Promise.all(
keys.filter(key => key !== CACHE).map(key => caches.delete(key)) keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
)).then(() => self.clients.claim())); )).then(() => self.clients.claim()));
}); });
@ -20,8 +36,20 @@ self.addEventListener('fetch', event => {
const request = event.request; const request = event.request;
if (request.method !== 'GET' || request.url.includes('/api/')) return; if (request.method !== 'GET' || request.url.includes('/api/')) return;
const url = new URL(request.url); const url = new URL(request.url);
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
if (request.mode === 'navigate') { if (request.mode === 'navigate') {
event.respondWith(fetch(request)); event.respondWith(
fetch(request).then(async response => {
if (response.ok) {
const cache = await caches.open(CACHE);
await cache.put(BASE, response.clone());
}
return response;
}).catch(async () => {
const cache = await caches.open(CACHE);
return cache.match(BASE);
})
);
return; return;
} }
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) { if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {

View File

@ -9,7 +9,14 @@ MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js" SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
@router.get("/", response_class=HTMLResponse) class RevalidatingHTMLResponse(HTMLResponse):
def __init__(self, content, status_code=200, headers=None, media_type=None, background=None):
response_headers = dict(headers or {})
response_headers["Cache-Control"] = "no-cache"
super().__init__(content, status_code, response_headers, media_type, background)
@router.get("/", response_class=RevalidatingHTMLResponse)
async def dashboard() -> str: async def dashboard() -> str:
return DASHBOARD_FILE.read_text() return DASHBOARD_FILE.read_text()

View File

@ -0,0 +1,154 @@
import json
import subprocess
from pathlib import Path
WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js"
def run_worker_scenario(scenario: str) -> dict:
harness = f"""
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
}};
const context = {{
URL, Request, Response,
console,
self: {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{ claim: async () => {{ state.claimed = true; }} }},
}},
caches: {{
open: async () => cache,
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
delete: async key => {{ state.deleted.push(key); return true; }},
match: async request => cache.match(request),
}},
fetch: async request => {{
state.fetches.push(String(request.url || request));
if (state.failFetch) throw new Error('offline');
return new Response('network', {{ status: state.fetchStatus }});
}},
}};
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context);
async function dispatch(name, request) {{
let pending;
let response;
listeners[name]({{
request,
waitUntil: promise => {{ pending = promise; }},
respondWith: promise => {{ response = promise; }},
}});
if (pending) await pending;
return response ? await response : null;
}}
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(
["node", "-e", harness], capture_output=True, check=True, text=True
)
return json.loads(completed.stdout)
def test_install_precaches_complete_subpath_scoped_app_shell():
result = run_worker_scenario(
"""
await dispatch('install');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["skipped"] is True
assert result["added"][0] == "/dashboard/"
assert set(result["added"]) == {
"/dashboard/",
"/dashboard/manifest.webmanifest",
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/my-work.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js",
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/pull-sheet.js",
"/dashboard/static/review-sheet.js",
"/dashboard/static/work-route.js",
"/dashboard/static/context-poller.js",
}
def test_activate_deletes_only_stale_stackchain_caches():
result = run_worker_scenario(
"""
await dispatch('activate');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["claimed"] is True
assert result["deleted"] == ["stackchain-dashboard-old"]
def test_offline_navigation_returns_cached_shell_for_share_target_url():
result = run_worker_scenario(
"""
state.failFetch = true;
state.cachedBody = 'cached dashboard';
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate',
url: 'https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com',
});
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
"""
)
assert result["body"] == "cached dashboard"
assert result["state"]["fetches"] == [
"https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com"
]
def test_cross_origin_navigation_is_not_intercepted_or_cached():
result = run_worker_scenario(
"""
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://outside.example/page',
});
process.stdout.write(JSON.stringify({ intercepted: response !== null, state }));
"""
)
assert result["intercepted"] is False
assert result["state"]["fetches"] == []
assert result["state"]["puts"] == []
def test_failed_online_navigation_does_not_replace_working_cached_shell():
result = run_worker_scenario(
"""
state.fetchStatus = 503;
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({ status: response.status, state }));
"""
)
assert result["status"] == 503
assert result["state"]["puts"] == []

View File

@ -85,4 +85,29 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
assert worker.headers["service-worker-allowed"] == "/" assert worker.headers["service-worker-allowed"] == "/"
assert "request.url.includes('/api/')" in worker.text assert "request.url.includes('/api/')" in worker.text
assert "request.method !== 'GET'" in worker.text assert "request.method !== 'GET'" in worker.text
assert "new URL('./', self.location.href).pathname" in worker.text assert "new URL('./', self.location.href).pathname" in worker.text
@pytest.mark.anyio
async def test_dashboard_announces_offline_mode_and_refreshes_after_reconnect():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/")
assert response.status_code == 200
assert 'id="offline-status"' in response.text
assert 'role="status"' in response.text
assert 'aria-live="polite"' in response.text
assert "window.addEventListener('offline'" in response.text
assert "window.addEventListener('online'" in response.text
assert "contextPoller.refresh()" in response.text
@pytest.mark.anyio
async def test_dashboard_shell_revalidates_instead_of_being_stored_as_fresh():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-cache"