mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Stage 325: PR #1891 — feat: add browser offline recovery and PWA cache hardening by @ai-ag2026
This commit is contained in:
+1
-1
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Public paths (no auth required) ─────────────────────────────────────────
|
||||
PUBLIC_PATHS = frozenset({
|
||||
'/login', '/health', '/favicon.ico',
|
||||
'/login', '/health', '/favicon.ico', '/sw.js',
|
||||
'/api/auth/login', '/api/auth/status',
|
||||
'/manifest.json', '/manifest.webmanifest',
|
||||
})
|
||||
|
||||
+10
-3
@@ -1130,10 +1130,17 @@ function _normalizeAppearance(theme,skin){
|
||||
// the meta tag.
|
||||
function _syncThemeColorMeta(){
|
||||
try{
|
||||
const meta=document.getElementById('hermes-theme-color');
|
||||
if(!meta) return;
|
||||
const bg=getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
|
||||
if(bg) meta.setAttribute('content',bg);
|
||||
if(!bg) return;
|
||||
const known=document.getElementById('hermes-theme-color');
|
||||
if(known){
|
||||
known.setAttribute('content',bg);
|
||||
known.removeAttribute('media');
|
||||
}
|
||||
document.querySelectorAll('meta[name="theme-color"]').forEach(meta=>{
|
||||
meta.setAttribute('content',bg);
|
||||
meta.removeAttribute('media');
|
||||
});
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
|
||||
const LOCALES = {
|
||||
en: {
|
||||
offline_title: 'Connection lost',
|
||||
offline_browser_detail: 'Your browser reports that this device is offline.',
|
||||
offline_network_detail: 'Hermes is unreachable from this browser right now.',
|
||||
offline_autorefresh: 'I will refresh this page automatically when Hermes is reachable again.',
|
||||
offline_check_now: 'Check now',
|
||||
offline_checking: 'Checking…',
|
||||
offline_stream_waiting: 'Connection lost. Waiting to refresh…',
|
||||
_lang: 'en',
|
||||
_label: 'English',
|
||||
_speech: 'en-US',
|
||||
@@ -1026,6 +1033,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
ja: {
|
||||
offline_title: '接続が切断されました',
|
||||
offline_browser_detail: 'ブラウザはこのデバイスがオフラインだと報告しています。',
|
||||
offline_network_detail: '現在、このブラウザからHermesに到達できません。',
|
||||
offline_autorefresh: 'Hermesに再接続できたら、このページを自動的に更新します。',
|
||||
offline_check_now: '今すぐ確認',
|
||||
offline_checking: '確認中…',
|
||||
offline_stream_waiting: '接続が切断されました。更新を待っています…',
|
||||
_lang: 'ja',
|
||||
_label: '日本語',
|
||||
_speech: 'ja-JP',
|
||||
@@ -2047,6 +2061,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
ru: {
|
||||
offline_title: 'Соединение потеряно',
|
||||
offline_browser_detail: 'Браузер сообщает, что это устройство офлайн.',
|
||||
offline_network_detail: 'Hermes сейчас недоступен из этого браузера.',
|
||||
offline_autorefresh: 'Я автоматически обновлю страницу, когда Hermes снова станет доступен.',
|
||||
offline_check_now: 'Проверить сейчас',
|
||||
offline_checking: 'Проверка…',
|
||||
offline_stream_waiting: 'Соединение потеряно. Ожидаю обновления…',
|
||||
_lang: 'ru',
|
||||
_label: 'Русский',
|
||||
_speech: 'ru-RU',
|
||||
@@ -3006,6 +3027,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
es: {
|
||||
offline_title: 'Conexión perdida',
|
||||
offline_browser_detail: 'Tu navegador indica que este dispositivo está sin conexión.',
|
||||
offline_network_detail: 'Hermes no está disponible desde este navegador ahora mismo.',
|
||||
offline_autorefresh: 'Actualizaré esta página automáticamente cuando Hermes vuelva a estar disponible.',
|
||||
offline_check_now: 'Comprobar ahora',
|
||||
offline_checking: 'Comprobando…',
|
||||
offline_stream_waiting: 'Conexión perdida. Esperando para actualizar…',
|
||||
_lang: 'es',
|
||||
_label: 'Español',
|
||||
_speech: 'es-ES',
|
||||
@@ -3959,6 +3987,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
de: {
|
||||
offline_title: 'Verbindung verloren',
|
||||
offline_browser_detail: 'Dein Browser meldet, dass dieses Gerät offline ist.',
|
||||
offline_network_detail: 'Hermes ist von diesem Browser aus gerade nicht erreichbar.',
|
||||
offline_autorefresh: 'Ich aktualisiere diese Seite automatisch, sobald Hermes wieder erreichbar ist.',
|
||||
offline_check_now: 'Jetzt prüfen',
|
||||
offline_checking: 'Prüfe…',
|
||||
offline_stream_waiting: 'Verbindung verloren. Warte auf Aktualisierung…',
|
||||
_lang: 'de',
|
||||
_label: 'Deutsch',
|
||||
_speech: 'de-DE',
|
||||
@@ -4916,6 +4951,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
zh: {
|
||||
offline_title: '连接已断开',
|
||||
offline_browser_detail: '浏览器报告此设备当前离线。',
|
||||
offline_network_detail: '此浏览器当前无法连接到 Hermes。',
|
||||
offline_autorefresh: '当 Hermes 可访问时,我会自动刷新此页面。',
|
||||
offline_check_now: '立即检查',
|
||||
offline_checking: '正在检查…',
|
||||
offline_stream_waiting: '连接已断开。正在等待刷新…',
|
||||
_lang: 'zh',
|
||||
_label: '\u7b80\u4f53\u4e2d\u6587',
|
||||
_speech: 'zh-CN',
|
||||
@@ -6857,6 +6899,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
pt: {
|
||||
offline_title: 'Conexão perdida',
|
||||
offline_browser_detail: 'O navegador informa que este dispositivo está offline.',
|
||||
offline_network_detail: 'O Hermes está inacessível neste navegador agora.',
|
||||
offline_autorefresh: 'Vou atualizar esta página automaticamente quando o Hermes voltar a responder.',
|
||||
offline_check_now: 'Verificar agora',
|
||||
offline_checking: 'Verificando…',
|
||||
offline_stream_waiting: 'Conexão perdida. Aguardando para atualizar…',
|
||||
_lang: 'pt',
|
||||
_label: 'Português',
|
||||
_speech: 'pt-BR',
|
||||
@@ -7706,6 +7755,13 @@ const LOCALES = {
|
||||
disable_auth_confirm_title: 'Desativar proteção por senha',
|
||||
},
|
||||
ko: {
|
||||
offline_title: '연결이 끊겼습니다',
|
||||
offline_browser_detail: '브라우저가 이 장치가 오프라인이라고 보고합니다.',
|
||||
offline_network_detail: '현재 이 브라우저에서 Hermes에 연결할 수 없습니다.',
|
||||
offline_autorefresh: 'Hermes에 다시 연결되면 이 페이지를 자동으로 새로고침합니다.',
|
||||
offline_check_now: '지금 확인',
|
||||
offline_checking: '확인 중…',
|
||||
offline_stream_waiting: '연결이 끊겼습니다. 새로고침을 기다리는 중…',
|
||||
_lang: 'ko',
|
||||
_label: '한국어',
|
||||
_speech: 'ko-KR',
|
||||
|
||||
+9
-1
@@ -21,7 +21,7 @@
|
||||
<meta name="theme-color" content="#FEFCF7" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0D0D1A" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" id="hermes-theme-color" content="#0D0D1A">
|
||||
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#0D0D1A':'#FEFCF7';var m=document.getElementById('hermes-theme-color');if(m)m.setAttribute('content',c);}catch(e){}})()</script>
|
||||
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#0D0D1A':'#FEFCF7';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
|
||||
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
||||
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" integrity="sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh" crossorigin="anonymous">
|
||||
@@ -347,6 +347,14 @@
|
||||
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="offline-banner" id="offlineBanner" role="status" aria-live="assertive" hidden>
|
||||
<div class="offline-copy">
|
||||
<strong id="offlineTitle" data-i18n="offline_title">Connection lost</strong>
|
||||
<span id="offlineDetails" data-i18n="offline_browser_detail">Your browser reports that this device is offline.</span>
|
||||
<span id="offlineAutorefresh" data-i18n="offline_autorefresh">I will refresh this page automatically when Hermes is reachable again.</span>
|
||||
</div>
|
||||
<button class="offline-action" id="offlineCheckNow" type="button" onclick="checkOfflineRecoveryNow()" data-i18n="offline_check_now">Check now</button>
|
||||
</div>
|
||||
<div class="agent-health-banner" id="agentHealthBanner" role="alert" aria-live="assertive" hidden>
|
||||
<div class="agent-health-copy">
|
||||
<strong id="agentHealthTitle">Hermes agent is not responding</strong>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"description": "Hermes AI Agent Web UI",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#1a1a1a",
|
||||
"background_color": "#0D0D1A",
|
||||
"theme_color": "#0D0D1A",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
|
||||
+19
-1
@@ -32,6 +32,19 @@ function _markActiveSessionViewedOnReturn() {
|
||||
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
|
||||
}
|
||||
|
||||
function _deferStreamErrorIfOffline(){
|
||||
if(typeof isOfflineBannerVisible==='function' && isOfflineBannerVisible()){
|
||||
setComposerStatus(t('offline_stream_waiting'));
|
||||
return true;
|
||||
}
|
||||
if(typeof showOfflineBanner==='function' && navigator.onLine===false){
|
||||
showOfflineBanner('browser');
|
||||
setComposerStatus(t('offline_stream_waiting'));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', _markActiveSessionViewedOnReturn);
|
||||
window.addEventListener('focus', _markActiveSessionViewedOnReturn);
|
||||
// TTS: pause speech synthesis when user focuses the composer (#499)
|
||||
@@ -1207,6 +1220,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('error',async e=>{
|
||||
source.close();
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
if(_terminalStateReached || _streamFinalized){
|
||||
_closeSource();
|
||||
return;
|
||||
@@ -1223,13 +1237,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true}));
|
||||
return;
|
||||
}
|
||||
}catch(_){}
|
||||
}catch(_){
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
}
|
||||
if(await _restoreSettledSession()) return;
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
_handleStreamError();
|
||||
},1500);
|
||||
return;
|
||||
}
|
||||
if(await _restoreSettledSession()) return;
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
_handleStreamError();
|
||||
});
|
||||
|
||||
|
||||
@@ -536,6 +536,14 @@
|
||||
.reconnect-banner.visible{display:flex;}
|
||||
.reconnect-btn{padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;background:var(--accent-bg-strong);border:1px solid var(--accent-bg-strong);color:var(--accent-text);cursor:pointer;}
|
||||
.reconnect-btn:hover{background:var(--accent-bg-strong);}
|
||||
.offline-banner{position:fixed;left:0;right:0;top:0;z-index:1200;display:none;align-items:center;justify-content:space-between;gap:14px;padding:12px 18px;border-bottom:1px solid color-mix(in srgb,var(--warning,#f6c343) 55%,var(--surface));background:color-mix(in srgb,var(--bg-1,#0d0d1a) 88%,var(--warning,#f6c343));color:var(--text);box-shadow:0 12px 40px rgba(0,0,0,.22);backdrop-filter:blur(10px);}
|
||||
.offline-banner.visible{display:flex;}
|
||||
.offline-copy{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:13px;line-height:1.35;}
|
||||
.offline-copy strong{color:var(--warning,#f6c343);font-size:13px;letter-spacing:.08em;text-transform:uppercase;}
|
||||
.offline-copy span{color:var(--muted);}
|
||||
.offline-action{flex-shrink:0;padding:7px 13px;border-radius:9px;border:1px solid color-mix(in srgb,var(--warning,#f6c343) 48%,var(--surface));background:color-mix(in srgb,var(--warning,#f6c343) 12%,var(--surface));color:var(--warning,#f6c343);font-size:12px;font-weight:700;cursor:pointer;}
|
||||
.offline-action:hover{background:color-mix(in srgb,var(--warning,#f6c343) 20%,var(--surface));}
|
||||
.offline-action[disabled]{cursor:wait;opacity:.65;}
|
||||
.agent-health-banner{position:sticky;bottom:0;z-index:4;display:none;align-items:center;justify-content:space-between;gap:12px;margin:10px auto 0;max-width:var(--msg-max);width:calc(100% - 40px);padding:12px 16px;border:1px solid color-mix(in srgb,var(--error) 55%,var(--surface));border-radius:12px;background:color-mix(in srgb,var(--error) 14%,var(--surface));color:var(--text);box-shadow:0 10px 32px rgba(0,0,0,.16);}
|
||||
.agent-health-banner.visible{display:flex;}
|
||||
.agent-health-copy{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:13px;line-height:1.35;}
|
||||
|
||||
+18
-17
@@ -68,7 +68,7 @@ self.addEventListener('activate', (event) => {
|
||||
// - API calls (/api/*, /stream) → always network (never cache)
|
||||
// - Login assets → always network (never cache stale auth code)
|
||||
// - Page navigations → network-first so auth redirects/cookies are honored
|
||||
// - Shell assets → cache-first with network fallback
|
||||
// - Shell assets → network-first with cache fallback
|
||||
// - Everything else → network-only
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
@@ -131,7 +131,7 @@ self.addEventListener('fetch', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only explicit shell assets use cache-first. Everything else should hit the
|
||||
// Only explicit shell assets are cached. Everything else should hit the
|
||||
// network so stale one-off files (especially auth/login scripts) do not get
|
||||
// trapped in CacheStorage until a manual cache clear.
|
||||
const scopePath = new URL(self.registration.scope).pathname;
|
||||
@@ -141,21 +141,22 @@ self.addEventListener('fetch', (event) => {
|
||||
const shellPath = './' + relPath.replace(/^\/+/, '') + url.search;
|
||||
if (!SHELL_ASSETS.includes(shellPath)) return;
|
||||
|
||||
// Shell assets: cache-first
|
||||
// Shell assets: network-first with cache fallback. This keeps offline support
|
||||
// but avoids executing stale JS/CSS after a local hotfix when WEBUI_VERSION
|
||||
// has not changed yet (e.g. before a guarded restart updates the ?v token).
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then((response) => {
|
||||
// Cache successful GET responses for shell assets
|
||||
if (
|
||||
event.request.method === 'GET' &&
|
||||
response.status === 200
|
||||
) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
fetch(event.request).then((response) => {
|
||||
if (
|
||||
event.request.method === 'GET' &&
|
||||
response.status === 200
|
||||
) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => caches.match(event.request).then((cached) => cached || new Response('Offline', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
})))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,105 @@ const MAX_UPLOAD_MB=Math.round(MAX_UPLOAD_BYTES/1024/1024);
|
||||
// single-threaded so only one done event fires at a time in practice.
|
||||
let _queueDrainSid=null;
|
||||
const $=id=>document.getElementById(id);
|
||||
const OFFLINE_RECHECK_MS=2500;
|
||||
let _offlineVisible=false;
|
||||
let _offlineReason='browser';
|
||||
let _offlineProbeTimer=null;
|
||||
let _offlineChecking=false;
|
||||
let _offlineProbePromise=null;
|
||||
let _offlineHealthProbePromise=null;
|
||||
let _offlineRawFetch=null;
|
||||
let _offlineFetchPatched=false;
|
||||
function _browserReportsOnline(){return !('onLine' in navigator)||navigator.onLine!==false;}
|
||||
function _offlineHealthUrl(){const url=new URL('health',document.baseURI||location.href);url.searchParams.set('offline_probe',String(Date.now()));return url.href;}
|
||||
function _setOfflineChecking(checking){
|
||||
_offlineChecking=!!checking;
|
||||
const btn=$('offlineCheckNow');
|
||||
if(btn){btn.disabled=_offlineChecking;btn.textContent=_offlineChecking?t('offline_checking'):t('offline_check_now');}
|
||||
}
|
||||
function _renderOfflineBanner(){
|
||||
const banner=$('offlineBanner');
|
||||
if(!banner)return;
|
||||
const detail=$('offlineDetails');
|
||||
if(detail)detail.textContent=t(_offlineReason==='browser'?'offline_browser_detail':'offline_network_detail');
|
||||
const title=$('offlineTitle');
|
||||
if(title)title.textContent=t('offline_title');
|
||||
const auto=$('offlineAutorefresh');
|
||||
if(auto)auto.textContent=t('offline_autorefresh');
|
||||
_setOfflineChecking(_offlineChecking);
|
||||
banner.hidden=false;
|
||||
banner.classList.add('visible');
|
||||
}
|
||||
function _startOfflineProbeTimer(){
|
||||
if(_offlineProbeTimer)return;
|
||||
_offlineProbeTimer=setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS);
|
||||
}
|
||||
function _stopOfflineProbeTimer(){
|
||||
if(_offlineProbeTimer){clearInterval(_offlineProbeTimer);_offlineProbeTimer=null;}
|
||||
}
|
||||
function showOfflineBanner(reason){
|
||||
_offlineVisible=true;
|
||||
_offlineReason=reason||(_browserReportsOnline()?'network':'browser');
|
||||
_renderOfflineBanner();
|
||||
_startOfflineProbeTimer();
|
||||
}
|
||||
function isOfflineBannerVisible(){return _offlineVisible;}
|
||||
function _hideOfflineBanner(){
|
||||
_offlineVisible=false;
|
||||
_stopOfflineProbeTimer();
|
||||
_setOfflineChecking(false);
|
||||
const banner=$('offlineBanner');
|
||||
if(banner){banner.classList.remove('visible');banner.hidden=true;}
|
||||
}
|
||||
async function _probeOfflineRecovery(){
|
||||
if(_offlineHealthProbePromise)return _offlineHealthProbePromise;
|
||||
_offlineHealthProbePromise=(async()=>{
|
||||
const fetcher=_offlineRawFetch||window.fetch.bind(window);
|
||||
try{
|
||||
const res=await fetcher(_offlineHealthUrl(),{cache:'no-store',credentials:'include'});
|
||||
return !!(res&&res.ok);
|
||||
}catch(_){return false;}
|
||||
})();
|
||||
try{return await _offlineHealthProbePromise;}
|
||||
finally{_offlineHealthProbePromise=null;}
|
||||
}
|
||||
async function checkOfflineRecoveryNow(){
|
||||
if(_offlineProbePromise)return _offlineProbePromise;
|
||||
_offlineProbePromise=(async()=>{
|
||||
if(!_offlineVisible)return false;
|
||||
if(!_browserReportsOnline()){showOfflineBanner('browser');return false;}
|
||||
_setOfflineChecking(true);
|
||||
const ok=await _probeOfflineRecovery();
|
||||
_setOfflineChecking(false);
|
||||
if(ok){_stopOfflineProbeTimer();window.location.reload();return true;}
|
||||
showOfflineBanner('network');
|
||||
return false;
|
||||
})();
|
||||
try{return await _offlineProbePromise;}
|
||||
finally{_offlineProbePromise=null;}
|
||||
}
|
||||
function _isAbortError(e){return !!(e&&(e.name==='AbortError'||e.code===20));}
|
||||
function _patchOfflineFetch(){
|
||||
if(_offlineFetchPatched||typeof window.fetch!=='function')return;
|
||||
_offlineFetchPatched=true;
|
||||
_offlineRawFetch=window.fetch.bind(window);
|
||||
window.fetch=async function(...args){
|
||||
try{return await _offlineRawFetch(...args);}
|
||||
catch(e){
|
||||
if(!_browserReportsOnline())showOfflineBanner('browser');
|
||||
else if(e instanceof TypeError&&!_isAbortError(e))void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');});
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
function initOfflineMonitor(){
|
||||
_patchOfflineFetch();
|
||||
window.addEventListener('offline',()=>showOfflineBanner('browser'));
|
||||
window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();});
|
||||
if(!_browserReportsOnline())showOfflineBanner('browser');
|
||||
}
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',initOfflineMonitor,{once:true});
|
||||
else initOfflineMonitor();
|
||||
// Redirect to login when the server responds with 401 (auth session expired).
|
||||
// Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from
|
||||
// escaping to the personal site root /login.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Regression coverage for the browser-offline banner and auto-refresh loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_offline_banner_markup_styles_and_copy_exist():
|
||||
assert 'id="offlineBanner"' in INDEX_HTML
|
||||
assert 'role="status"' in INDEX_HTML
|
||||
assert 'aria-live="assertive"' in INDEX_HTML
|
||||
assert 'onclick="checkOfflineRecoveryNow()"' in INDEX_HTML
|
||||
assert ".offline-banner" in STYLE_CSS
|
||||
assert ".offline-banner.visible" in STYLE_CSS
|
||||
assert ".offline-action[disabled]" in STYLE_CSS
|
||||
for key in (
|
||||
"offline_title",
|
||||
"offline_browser_detail",
|
||||
"offline_network_detail",
|
||||
"offline_autorefresh",
|
||||
"offline_check_now",
|
||||
"offline_checking",
|
||||
"offline_stream_waiting",
|
||||
):
|
||||
assert key in I18N_JS
|
||||
|
||||
|
||||
def test_offline_monitor_patches_fetch_and_auto_reloads_after_health_probe():
|
||||
assert "const OFFLINE_RECHECK_MS=2500" in UI_JS
|
||||
assert "window.fetch=async function(...args)" in UI_JS
|
||||
assert "window.addEventListener('offline',()=>showOfflineBanner('browser'))" in UI_JS
|
||||
assert "window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();})" in UI_JS
|
||||
assert "setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS)" in UI_JS
|
||||
assert "new URL('health',document.baseURI||location.href)" in UI_JS
|
||||
assert "window.location.reload()" in UI_JS
|
||||
|
||||
|
||||
def test_offline_recovery_probe_is_serialized_and_stops_timer_before_reload():
|
||||
assert "let _offlineProbePromise=null" in UI_JS
|
||||
assert "let _offlineHealthProbePromise=null" in UI_JS
|
||||
assert "if(!_offlineVisible)return false;" in UI_JS
|
||||
assert "if(!_offlineVisible&&!_offlineFetchPatched)return false;" not in UI_JS
|
||||
assert "finally{_offlineProbePromise=null;}" in UI_JS
|
||||
assert "finally{_offlineHealthProbePromise=null;}" in UI_JS
|
||||
reload_idx = UI_JS.find("window.location.reload()")
|
||||
assert reload_idx != -1
|
||||
assert UI_JS.rfind("_stopOfflineProbeTimer();", 0, reload_idx) != -1
|
||||
|
||||
|
||||
def test_fetch_typeerror_is_gated_by_health_probe_not_blind_banner():
|
||||
fetch_patch = UI_JS.split("window.fetch=async function(...args){", 1)[1].split("function initOfflineMonitor", 1)[0]
|
||||
assert "function _isAbortError(e)" in UI_JS
|
||||
assert "e instanceof TypeError&&!_isAbortError(e)" in fetch_patch
|
||||
assert "void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');})" in fetch_patch
|
||||
assert "if(!_browserReportsOnline())showOfflineBanner('browser');" in fetch_patch
|
||||
assert "e instanceof TypeError||!_browserReportsOnline()" not in fetch_patch
|
||||
|
||||
|
||||
def test_sse_network_error_defers_to_offline_banner_instead_of_inline_error():
|
||||
assert "function _deferStreamErrorIfOffline()" in MESSAGES_JS
|
||||
assert "t('offline_stream_waiting')" in MESSAGES_JS
|
||||
assert "if(_deferStreamErrorIfOffline()) return;" in MESSAGES_JS
|
||||
error_handler = MESSAGES_JS.split("source.addEventListener('error',async e=>{", 1)[1].split("source.addEventListener('cancel'", 1)[0]
|
||||
assert error_handler.find("_deferStreamErrorIfOffline()") < error_handler.rfind("_handleStreamError()")
|
||||
@@ -18,6 +18,7 @@ MANIFEST = ROOT / "static" / "manifest.json"
|
||||
SW = ROOT / "static" / "sw.js"
|
||||
INDEX = ROOT / "static" / "index.html"
|
||||
ROUTES = ROOT / "api" / "routes.py"
|
||||
AUTH = ROOT / "api" / "auth.py"
|
||||
|
||||
|
||||
class TestManifest:
|
||||
@@ -107,6 +108,22 @@ class TestServiceWorker:
|
||||
"sw.js must await/then the caches.match() result before applying the fallback"
|
||||
)
|
||||
|
||||
def test_sw_shell_assets_are_network_first_with_cache_fallback(self):
|
||||
"""Local hotfixes can change JS/CSS while WEBUI_VERSION stays unchanged.
|
||||
|
||||
If shell assets are cache-first, the browser can keep executing stale
|
||||
sessions.js even though the server/curl already returns patched source.
|
||||
Network-first preserves offline fallback without hiding local fixes.
|
||||
"""
|
||||
src = SW.read_text(encoding="utf-8")
|
||||
assert "Shell assets: network-first with cache fallback" in src
|
||||
assert "fetch(event.request).then((response)" in src
|
||||
assert "caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))" in src
|
||||
assert ".catch(() => caches.match(event.request)" in src
|
||||
assert "if (cached) return cached;" not in src, (
|
||||
"shell assets must not be cache-first; stale JS can survive hard refresh"
|
||||
)
|
||||
|
||||
def test_sw_never_caches_api_responses(self):
|
||||
"""Defensive: the SW must not cache responses from /api/* paths.
|
||||
Currently enforced by early-return before the shell-asset cache block."""
|
||||
@@ -162,6 +179,15 @@ class TestPWARoutes:
|
||||
"the expected scope"
|
||||
)
|
||||
|
||||
def test_sw_is_public_auth_path(self):
|
||||
src = AUTH.read_text(encoding="utf-8")
|
||||
public_idx = src.find("PUBLIC_PATHS")
|
||||
assert public_idx != -1, "auth.py must define PUBLIC_PATHS"
|
||||
block = src[public_idx:public_idx + 400]
|
||||
assert "'/sw.js'" in block, (
|
||||
"/sw.js must be public so service-worker updates never return login HTML"
|
||||
)
|
||||
|
||||
|
||||
class TestIndexHtmlIntegration:
|
||||
def test_index_links_manifest(self):
|
||||
|
||||
Reference in New Issue
Block a user