feat(offline+sensors): Service Worker, bússola, barômetro, pré-cache de mapa
Implementa requisitos pra uso em áreas remotas: OFFLINE REAL (Service Worker em server/public/sw.js) - Pré-cache de shell (HTML, manifest, icon, Leaflet, fontes Google) - Cache-first pra map tiles OSM (offline em alto-mar com tiles já visitados) - Network-first pra Windy/Open-Meteo (com fallback ao cache) - /api/* passa direto (não interferir em sync, heartbeat, auth) - Skip-waiting + claim pra ativar imediatamente após install SENSORES (sensor widget flutuante canto superior direito) - Bússola via DeviceOrientationEvent (suporta iOS webkitCompassHeading + Android alpha) - iOS: pede permission via gesture do usuário (botão 'Ativar bússola') - Barômetro via Generic Sensor API (Android com sensor real, fallback gracioso) - Tendência de pressão (subindo/caindo/estável) baseada em janela móvel - Indicador de online/offline sempre visível PRÉ-CACHE DE MAPA - Botão 'Pré-cachear mapa' baixa tiles ~50km de raio (zooms 8-13, ~200 tiles) - Comunicação page→SW via MessageChannel - Limit 6 conexões paralelas (respeitando OSM tile policy) DOCUMENTAÇÃO TERMÔMETRO: API web não tem termômetro de ambiente. Solução: usar dado da Windy (já implementado) + cache offline via SW. Sincronizado em app/ e server/public/ — single-file HTML preservado. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f83ae389bc
commit
d1a2401048
3 changed files with 524 additions and 0 deletions
|
|
@ -1821,6 +1821,8 @@ async function updateStorageInfo(){
|
|||
loadTrackingState();
|
||||
loadAnchorState();
|
||||
initBattery();
|
||||
initServiceWorker();
|
||||
initSensorWidget();
|
||||
// tenta auto-fetch do tempo após pequeno delay
|
||||
setTimeout(maybeAutoFetchWeather,3000);
|
||||
})();
|
||||
|
|
@ -2951,6 +2953,173 @@ async function testWindyKey(){
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Service Worker (offline real) =====
|
||||
function initServiceWorker(){
|
||||
if(!('serviceWorker' in navigator))return;
|
||||
navigator.serviceWorker.register('/sw.js').then(reg=>{
|
||||
console.log('[SW] registered:',reg.scope);
|
||||
}).catch(e=>console.warn('[SW] failed:',e.message));
|
||||
}
|
||||
|
||||
// ===== Sensores: Bússola + Barômetro + Status Offline =====
|
||||
const sensors={heading:null,pressure:null,pressureTrend:null,_pressHistory:[],compassActive:false,barometerActive:false};
|
||||
|
||||
function initSensorWidget(){
|
||||
// Cria widget flutuante (canto superior direito, abaixo do header)
|
||||
const w=document.createElement('div');
|
||||
w.id='sensors-widget';
|
||||
w.style.cssText='position:fixed;top:64px;right:12px;background:rgba(14,42,61,.92);color:#efe5cd;padding:8px 12px;border-radius:8px;font-family:var(--f-mono),monospace;font-size:11px;z-index:998;box-shadow:0 2px 8px rgba(0,0,0,.3);min-width:140px;backdrop-filter:blur(4px)';
|
||||
w.innerHTML='<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer" id="sw-toggle"><span id="sw-online" style="color:#3f7768">●</span><span id="sw-compass">🧭 ---°</span><span id="sw-pressure" style="opacity:.7">🌡 ---</span><span style="opacity:.5;font-size:10px">▼</span></div><div id="sw-extra" style="display:none;margin-top:8px;padding-top:8px;border-top:1px solid rgba(160,120,50,.3);font-size:10px;line-height:1.5"></div>';
|
||||
document.body.appendChild(w);
|
||||
document.getElementById('sw-toggle').addEventListener('click',toggleSensorPanel);
|
||||
// Online/offline status
|
||||
updateOnlineStatus();
|
||||
window.addEventListener('online',updateOnlineStatus);
|
||||
window.addEventListener('offline',updateOnlineStatus);
|
||||
// Tenta iniciar bússola e barômetro automaticamente (sem permission no Android; iOS espera tap)
|
||||
tryStartCompass();
|
||||
tryStartBarometer();
|
||||
}
|
||||
|
||||
function toggleSensorPanel(){
|
||||
const ex=document.getElementById('sw-extra');
|
||||
const open=ex.style.display==='none';
|
||||
ex.style.display=open?'block':'none';
|
||||
if(open)renderSensorPanel();
|
||||
}
|
||||
|
||||
function renderSensorPanel(){
|
||||
const ex=document.getElementById('sw-extra');
|
||||
const online=navigator.onLine;
|
||||
const cardinal=sensors.heading!==null?headingToCardinal(sensors.heading):'—';
|
||||
const trend=sensors.pressureTrend===null?'—':(sensors.pressureTrend>0?'↑ subindo':sensors.pressureTrend<0?'↓ caindo':'→ estável');
|
||||
ex.innerHTML=`
|
||||
<div>Conexão: <strong>${online?'online':'offline'}</strong></div>
|
||||
<div>Bússola: <strong>${sensors.heading!==null?Math.round(sensors.heading)+'° '+cardinal:'sem dados'}</strong></div>
|
||||
<div>Pressão: <strong>${sensors.pressure!==null?sensors.pressure.toFixed(1)+' hPa':'sem sensor'}</strong></div>
|
||||
<div>Tendência: <strong>${trend}</strong></div>
|
||||
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
${sensors.heading===null?'<button onclick="requestCompassPermission()" style="background:#a07832;color:#0e2a3d;border:none;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Ativar bússola</button>':''}
|
||||
<button onclick="precacheCurrentMapArea()" style="background:#3f7768;color:#efe5cd;border:none;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Pré-cachear mapa</button>
|
||||
<button onclick="showCacheSizes()" style="background:#0e2a3d;color:#efe5cd;border:1px solid #a07832;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Cache</button>
|
||||
</div>
|
||||
<div id="sw-cache-info" style="margin-top:6px;font-size:9px;opacity:.7"></div>`;
|
||||
}
|
||||
|
||||
function updateOnlineStatus(){
|
||||
const dot=document.getElementById('sw-online');
|
||||
if(!dot)return;
|
||||
dot.style.color=navigator.onLine?'#3f7768':'#8c3434';
|
||||
dot.title=navigator.onLine?'Online':'Offline — usando cache';
|
||||
}
|
||||
|
||||
function headingToCardinal(deg){
|
||||
const dirs=['N','NE','L','SE','S','SO','O','NO'];
|
||||
return dirs[Math.round(deg/45)%8];
|
||||
}
|
||||
|
||||
function tryStartCompass(){
|
||||
// iOS Safari requer permission via gesture do usuário (DeviceOrientationEvent.requestPermission)
|
||||
// Android Chrome: funciona direto sem permission
|
||||
if(typeof DeviceOrientationEvent==='undefined')return;
|
||||
if(typeof DeviceOrientationEvent.requestPermission==='function'){
|
||||
// iOS — aguarda usuário clicar "Ativar bússola"
|
||||
return;
|
||||
}
|
||||
// Android: pode iniciar direto
|
||||
attachCompassListener();
|
||||
}
|
||||
|
||||
function requestCompassPermission(){
|
||||
if(typeof DeviceOrientationEvent==='undefined'){toast('Bússola não suportada');return}
|
||||
if(typeof DeviceOrientationEvent.requestPermission==='function'){
|
||||
DeviceOrientationEvent.requestPermission().then(state=>{
|
||||
if(state==='granted')attachCompassListener();
|
||||
else toast('Permissão de bússola negada');
|
||||
}).catch(()=>toast('Falha ao pedir permissão'));
|
||||
}else{
|
||||
attachCompassListener();
|
||||
}
|
||||
}
|
||||
|
||||
function attachCompassListener(){
|
||||
if(sensors.compassActive)return;
|
||||
sensors.compassActive=true;
|
||||
window.addEventListener('deviceorientationabsolute',onCompass);
|
||||
window.addEventListener('deviceorientation',onCompass);
|
||||
}
|
||||
|
||||
function onCompass(e){
|
||||
// iOS: e.webkitCompassHeading (0-360 azimuth real)
|
||||
// Android: e.alpha (0-360, mas relativo — pra absoluto, use deviceorientationabsolute)
|
||||
let h=null;
|
||||
if(typeof e.webkitCompassHeading==='number')h=e.webkitCompassHeading;
|
||||
else if(typeof e.alpha==='number')h=360-e.alpha;
|
||||
if(h===null||isNaN(h))return;
|
||||
sensors.heading=h;
|
||||
const c=document.getElementById('sw-compass');
|
||||
if(c)c.textContent='🧭 '+Math.round(h)+'° '+headingToCardinal(h);
|
||||
}
|
||||
|
||||
function tryStartBarometer(){
|
||||
if(typeof Barometer==='undefined')return;
|
||||
try{
|
||||
const bar=new Barometer({frequency:1});
|
||||
bar.addEventListener('reading',()=>{
|
||||
sensors.pressure=bar.pressure;
|
||||
sensors._pressHistory.push({ts:Date.now(),p:bar.pressure});
|
||||
if(sensors._pressHistory.length>30)sensors._pressHistory.shift();
|
||||
// tendência: diferença entre média recente e antiga
|
||||
if(sensors._pressHistory.length>=10){
|
||||
const half=Math.floor(sensors._pressHistory.length/2);
|
||||
const old=sensors._pressHistory.slice(0,half).reduce((s,x)=>s+x.p,0)/half;
|
||||
const recent=sensors._pressHistory.slice(half).reduce((s,x)=>s+x.p,0)/(sensors._pressHistory.length-half);
|
||||
sensors.pressureTrend=recent-old;
|
||||
}
|
||||
const el=document.getElementById('sw-pressure');
|
||||
if(el){el.textContent='🌡 '+bar.pressure.toFixed(1);el.style.opacity='1'}
|
||||
});
|
||||
bar.addEventListener('error',e=>console.warn('[barometer]',e.error));
|
||||
bar.start();
|
||||
sensors.barometerActive=true;
|
||||
}catch(e){console.warn('[barometer] no permission/sensor:',e.message)}
|
||||
}
|
||||
|
||||
async function precacheCurrentMapArea(){
|
||||
if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo ainda — recarregue');return}
|
||||
// Pede ao usuário um centro/raio se não há mapa visível
|
||||
if(!confirm('Pré-cachear mapa de uma área de ~50km no entorno da posição atual?\nVai baixar ~200 tiles (uns 5-10 MB).'))return;
|
||||
if(!navigator.geolocation){toast('Sem GPS pra centro do cache');return}
|
||||
navigator.geolocation.getCurrentPosition(pos=>{
|
||||
const lat=pos.coords.latitude,lng=pos.coords.longitude;
|
||||
const d=0.5; // ~50km de lat/lng buffer
|
||||
const bounds={north:lat+d,south:lat-d,east:lng+d,west:lng-d};
|
||||
toast('Baixando tiles…');
|
||||
const channel=new MessageChannel();
|
||||
channel.port1.onmessage=ev=>{
|
||||
if(ev.data.type==='PRECACHE_DONE')toast(`Mapa cacheado: ${ev.data.done}/${ev.data.total}`);
|
||||
if(ev.data.type==='PRECACHE_ERROR')toast('Erro: '+ev.data.error);
|
||||
};
|
||||
navigator.serviceWorker.controller.postMessage({type:'PRECACHE_TILES',bounds,minZoom:8,maxZoom:13},[channel.port2]);
|
||||
},err=>toast('GPS erro: '+err.message),{timeout:10000});
|
||||
}
|
||||
|
||||
async function showCacheSizes(){
|
||||
if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo');return}
|
||||
const channel=new MessageChannel();
|
||||
channel.port1.onmessage=ev=>{
|
||||
if(ev.data.type==='CACHE_SIZE_REPORT'){
|
||||
const s=ev.data.sizes;
|
||||
const info=document.getElementById('sw-cache-info');
|
||||
if(info){
|
||||
const total=Object.values(s).reduce((a,b)=>a+b,0);
|
||||
info.innerHTML=`Cache: <strong>${total}</strong> itens (shell ${s['shivao-shell-shivao-v1']||0}, tiles ${s['shivao-tiles-v1']||0}, windy ${s['shivao-windy-shivao-v1']||0})`;
|
||||
}
|
||||
}
|
||||
};
|
||||
navigator.serviceWorker.controller.postMessage({type:'CACHE_SIZE'},[channel.port2]);
|
||||
}
|
||||
|
||||
const battery={level:1,charging:true,saverMode:'auto',forced:'',handler:null};
|
||||
|
||||
async function initBattery(){
|
||||
|
|
|
|||
|
|
@ -1821,6 +1821,8 @@ async function updateStorageInfo(){
|
|||
loadTrackingState();
|
||||
loadAnchorState();
|
||||
initBattery();
|
||||
initServiceWorker();
|
||||
initSensorWidget();
|
||||
// tenta auto-fetch do tempo após pequeno delay
|
||||
setTimeout(maybeAutoFetchWeather,3000);
|
||||
})();
|
||||
|
|
@ -2951,6 +2953,163 @@ async function testWindyKey(){
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Service Worker (offline real) =====
|
||||
function initServiceWorker(){
|
||||
if(!('serviceWorker' in navigator))return;
|
||||
navigator.serviceWorker.register('/sw.js').then(reg=>{
|
||||
console.log('[SW] registered:',reg.scope);
|
||||
}).catch(e=>console.warn('[SW] failed:',e.message));
|
||||
}
|
||||
|
||||
// ===== Sensores: Bússola + Barômetro + Status Offline =====
|
||||
const sensors={heading:null,pressure:null,pressureTrend:null,_pressHistory:[],compassActive:false,barometerActive:false};
|
||||
|
||||
function initSensorWidget(){
|
||||
// Cria widget flutuante (canto superior direito, abaixo do header)
|
||||
const w=document.createElement('div');
|
||||
w.id='sensors-widget';
|
||||
w.style.cssText='position:fixed;top:64px;right:12px;background:rgba(14,42,61,.92);color:#efe5cd;padding:8px 12px;border-radius:8px;font-family:var(--f-mono),monospace;font-size:11px;z-index:998;box-shadow:0 2px 8px rgba(0,0,0,.3);min-width:140px;backdrop-filter:blur(4px)';
|
||||
w.innerHTML='<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer" id="sw-toggle"><span id="sw-online" style="color:#3f7768">●</span><span id="sw-compass">🧭 ---°</span><span id="sw-pressure" style="opacity:.7">🌡 ---</span><span style="opacity:.5;font-size:10px">▼</span></div><div id="sw-extra" style="display:none;margin-top:8px;padding-top:8px;border-top:1px solid rgba(160,120,50,.3);font-size:10px;line-height:1.5"></div>';
|
||||
document.body.appendChild(w);
|
||||
document.getElementById('sw-toggle').addEventListener('click',toggleSensorPanel);
|
||||
// Online/offline status
|
||||
updateOnlineStatus();
|
||||
window.addEventListener('online',updateOnlineStatus);
|
||||
window.addEventListener('offline',updateOnlineStatus);
|
||||
// Tenta iniciar bússola e barômetro automaticamente (sem permission no Android; iOS espera tap)
|
||||
tryStartCompass();
|
||||
tryStartBarometer();
|
||||
}
|
||||
|
||||
function toggleSensorPanel(){
|
||||
const ex=document.getElementById('sw-extra');
|
||||
const open=ex.style.display==='none';
|
||||
ex.style.display=open?'block':'none';
|
||||
if(open)renderSensorPanel();
|
||||
}
|
||||
|
||||
function renderSensorPanel(){
|
||||
const ex=document.getElementById('sw-extra');
|
||||
const online=navigator.onLine;
|
||||
const cardinal=sensors.heading!==null?headingToCardinal(sensors.heading):'—';
|
||||
const trend=sensors.pressureTrend===null?'—':(sensors.pressureTrend>0?'↑ subindo':sensors.pressureTrend<0?'↓ caindo':'→ estável');
|
||||
ex.innerHTML=`
|
||||
<div>Conexão: <strong>${online?'online':'offline'}</strong></div>
|
||||
<div>Bússola: <strong>${sensors.heading!==null?Math.round(sensors.heading)+'° '+cardinal:'sem dados'}</strong></div>
|
||||
<div>Pressão: <strong>${sensors.pressure!==null?sensors.pressure.toFixed(1)+' hPa':'sem sensor'}</strong></div>
|
||||
<div>Tendência: <strong>${trend}</strong></div>
|
||||
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
${sensors.heading===null?'<button onclick="requestCompassPermission()" style="background:#a07832;color:#0e2a3d;border:none;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Ativar bússola</button>':''}
|
||||
<button onclick="precacheCurrentMapArea()" style="background:#3f7768;color:#efe5cd;border:none;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Pré-cachear mapa</button>
|
||||
<button onclick="showCacheSizes()" style="background:#0e2a3d;color:#efe5cd;border:1px solid #a07832;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Cache</button>
|
||||
</div>
|
||||
<div id="sw-cache-info" style="margin-top:6px;font-size:9px;opacity:.7"></div>`;
|
||||
}
|
||||
|
||||
function updateOnlineStatus(){
|
||||
const dot=document.getElementById('sw-online');
|
||||
if(!dot)return;
|
||||
dot.style.color=navigator.onLine?'#3f7768':'#8c3434';
|
||||
dot.title=navigator.onLine?'Online':'Offline — usando cache';
|
||||
}
|
||||
|
||||
function headingToCardinal(deg){
|
||||
const dirs=['N','NE','L','SE','S','SO','O','NO'];
|
||||
return dirs[Math.round(deg/45)%8];
|
||||
}
|
||||
|
||||
function tryStartCompass(){
|
||||
if(typeof DeviceOrientationEvent==='undefined')return;
|
||||
if(typeof DeviceOrientationEvent.requestPermission==='function')return; // iOS espera tap
|
||||
attachCompassListener();
|
||||
}
|
||||
|
||||
function requestCompassPermission(){
|
||||
if(typeof DeviceOrientationEvent==='undefined'){toast('Bússola não suportada');return}
|
||||
if(typeof DeviceOrientationEvent.requestPermission==='function'){
|
||||
DeviceOrientationEvent.requestPermission().then(state=>{
|
||||
if(state==='granted')attachCompassListener();
|
||||
else toast('Permissão de bússola negada');
|
||||
}).catch(()=>toast('Falha ao pedir permissão'));
|
||||
}else{
|
||||
attachCompassListener();
|
||||
}
|
||||
}
|
||||
|
||||
function attachCompassListener(){
|
||||
if(sensors.compassActive)return;
|
||||
sensors.compassActive=true;
|
||||
window.addEventListener('deviceorientationabsolute',onCompass);
|
||||
window.addEventListener('deviceorientation',onCompass);
|
||||
}
|
||||
|
||||
function onCompass(e){
|
||||
let h=null;
|
||||
if(typeof e.webkitCompassHeading==='number')h=e.webkitCompassHeading;
|
||||
else if(typeof e.alpha==='number')h=360-e.alpha;
|
||||
if(h===null||isNaN(h))return;
|
||||
sensors.heading=h;
|
||||
const c=document.getElementById('sw-compass');
|
||||
if(c)c.textContent='🧭 '+Math.round(h)+'° '+headingToCardinal(h);
|
||||
}
|
||||
|
||||
function tryStartBarometer(){
|
||||
if(typeof Barometer==='undefined')return;
|
||||
try{
|
||||
const bar=new Barometer({frequency:1});
|
||||
bar.addEventListener('reading',()=>{
|
||||
sensors.pressure=bar.pressure;
|
||||
sensors._pressHistory.push({ts:Date.now(),p:bar.pressure});
|
||||
if(sensors._pressHistory.length>30)sensors._pressHistory.shift();
|
||||
if(sensors._pressHistory.length>=10){
|
||||
const half=Math.floor(sensors._pressHistory.length/2);
|
||||
const old=sensors._pressHistory.slice(0,half).reduce((s,x)=>s+x.p,0)/half;
|
||||
const recent=sensors._pressHistory.slice(half).reduce((s,x)=>s+x.p,0)/(sensors._pressHistory.length-half);
|
||||
sensors.pressureTrend=recent-old;
|
||||
}
|
||||
const el=document.getElementById('sw-pressure');
|
||||
if(el){el.textContent='🌡 '+bar.pressure.toFixed(1);el.style.opacity='1'}
|
||||
});
|
||||
bar.addEventListener('error',e=>console.warn('[barometer]',e.error));
|
||||
bar.start();
|
||||
sensors.barometerActive=true;
|
||||
}catch(e){console.warn('[barometer] no permission/sensor:',e.message)}
|
||||
}
|
||||
|
||||
async function precacheCurrentMapArea(){
|
||||
if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo ainda — recarregue');return}
|
||||
if(!confirm('Pré-cachear mapa de uma área de ~50km no entorno da posição atual?\nVai baixar ~200 tiles (uns 5-10 MB).'))return;
|
||||
if(!navigator.geolocation){toast('Sem GPS pra centro do cache');return}
|
||||
navigator.geolocation.getCurrentPosition(pos=>{
|
||||
const lat=pos.coords.latitude,lng=pos.coords.longitude;
|
||||
const d=0.5;
|
||||
const bounds={north:lat+d,south:lat-d,east:lng+d,west:lng-d};
|
||||
toast('Baixando tiles…');
|
||||
const channel=new MessageChannel();
|
||||
channel.port1.onmessage=ev=>{
|
||||
if(ev.data.type==='PRECACHE_DONE')toast(`Mapa cacheado: ${ev.data.done}/${ev.data.total}`);
|
||||
if(ev.data.type==='PRECACHE_ERROR')toast('Erro: '+ev.data.error);
|
||||
};
|
||||
navigator.serviceWorker.controller.postMessage({type:'PRECACHE_TILES',bounds,minZoom:8,maxZoom:13},[channel.port2]);
|
||||
},err=>toast('GPS erro: '+err.message),{timeout:10000});
|
||||
}
|
||||
|
||||
async function showCacheSizes(){
|
||||
if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo');return}
|
||||
const channel=new MessageChannel();
|
||||
channel.port1.onmessage=ev=>{
|
||||
if(ev.data.type==='CACHE_SIZE_REPORT'){
|
||||
const s=ev.data.sizes;
|
||||
const info=document.getElementById('sw-cache-info');
|
||||
if(info){
|
||||
const total=Object.values(s).reduce((a,b)=>a+b,0);
|
||||
info.innerHTML=`Cache: <strong>${total}</strong> itens (shell ${s['shivao-shell-shivao-v1']||0}, tiles ${s['shivao-tiles-v1']||0}, windy ${s['shivao-windy-shivao-v1']||0})`;
|
||||
}
|
||||
}
|
||||
};
|
||||
navigator.serviceWorker.controller.postMessage({type:'CACHE_SIZE'},[channel.port2]);
|
||||
}
|
||||
|
||||
const battery={level:1,charging:true,saverMode:'auto',forced:'',handler:null};
|
||||
|
||||
async function initBattery(){
|
||||
|
|
|
|||
196
server/public/sw.js
Normal file
196
server/public/sw.js
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// Shivao Service Worker — offline real
|
||||
// Estratégia: shell precachado, tiles cache-first, windy network-first, /api passa direto.
|
||||
// Versão usada nos cache names — bumpa essa string pra invalidar caches antigos em deploys.
|
||||
const VERSION = 'shivao-v1';
|
||||
const SHELL_CACHE = `shivao-shell-${VERSION}`;
|
||||
const TILES_CACHE = 'shivao-tiles-v1'; // separado pra não invalidar tiles em update do shell
|
||||
const WINDY_CACHE = `shivao-windy-${VERSION}`;
|
||||
|
||||
// Recursos do "shell" (UI essencial). Pré-cacheados no install.
|
||||
const SHELL_URLS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/icon.svg',
|
||||
// Leaflet (cdnjs)
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(SHELL_CACHE).then(async (cache) => {
|
||||
// Tenta cachear cada um individualmente — falha de 1 não derruba install
|
||||
await Promise.all(SHELL_URLS.map(url =>
|
||||
cache.add(url).catch(e => console.warn('[SW] precache miss:', url, e.message))
|
||||
));
|
||||
}).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys => Promise.all(
|
||||
// Remove caches antigos do shell e windy (não tiles — que tem própria versão)
|
||||
keys.filter(k => k.startsWith('shivao-shell-') && k !== SHELL_CACHE)
|
||||
.concat(keys.filter(k => k.startsWith('shivao-windy-') && k !== WINDY_CACHE))
|
||||
.map(k => caches.delete(k))
|
||||
)).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
if (event.request.method !== 'GET') return; // POST/PUT/DELETE passam direto
|
||||
|
||||
// /api do Shivão: passa direto, não interferir em sync/heartbeat/auth
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// Map tiles (OSM): cache-first, com network fallback
|
||||
if (url.host === 'tile.openstreetmap.org' || url.pathname.match(/\.(png|jpg|jpeg)$/) && url.pathname.match(/\/\d+\/\d+\/\d+\./)) {
|
||||
event.respondWith(cacheFirst(event.request, TILES_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Windy API: network-first, fallback cache (mostra última previsão quando offline)
|
||||
if (url.host === 'api.windy.com') {
|
||||
event.respondWith(networkFirst(event.request, WINDY_CACHE, 3600 * 1000)); // TTL 1h
|
||||
return;
|
||||
}
|
||||
|
||||
// Open-Meteo (fallback meteo): mesmo padrão
|
||||
if (url.host === 'api.open-meteo.com' || url.host === 'marine-api.open-meteo.com') {
|
||||
event.respondWith(networkFirst(event.request, WINDY_CACHE, 3600 * 1000));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fonts Google (fontes carregam no startup): cache-first
|
||||
if (url.host === 'fonts.googleapis.com' || url.host === 'fonts.gstatic.com') {
|
||||
event.respondWith(cacheFirst(event.request, SHELL_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// CDN Leaflet e similares
|
||||
if (url.host === 'cdnjs.cloudflare.com') {
|
||||
event.respondWith(cacheFirst(event.request, SHELL_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Mesma origem (HTML/JS/CSS do app): stale-while-revalidate
|
||||
if (url.origin === self.location.origin) {
|
||||
event.respondWith(staleWhileRevalidate(event.request, SHELL_CACHE));
|
||||
return;
|
||||
}
|
||||
// Resto: deixa rede tratar
|
||||
});
|
||||
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
if (fresh.ok) cache.put(request, fresh.clone()).catch(() => {});
|
||||
return fresh;
|
||||
} catch (e) {
|
||||
return new Response('offline-no-cache', { status: 503, statusText: 'Offline' });
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(request, cacheName, ttlMs) {
|
||||
const cache = await caches.open(cacheName);
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
if (fresh.ok) cache.put(request, fresh.clone()).catch(() => {});
|
||||
return fresh;
|
||||
} catch (e) {
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
const fetchPromise = fetch(request).then(res => {
|
||||
if (res.ok) cache.put(request, res.clone()).catch(() => {});
|
||||
return res;
|
||||
}).catch(() => cached || new Response('offline', { status: 503 }));
|
||||
return cached || fetchPromise;
|
||||
}
|
||||
|
||||
// Mensagens da página: pré-cachear tiles de uma área
|
||||
self.addEventListener('message', (event) => {
|
||||
if (!event.data || !event.data.type) return;
|
||||
|
||||
if (event.data.type === 'PRECACHE_TILES') {
|
||||
const { bounds, minZoom, maxZoom } = event.data;
|
||||
precacheTiles(bounds, minZoom, maxZoom).then(stats => {
|
||||
event.source && event.source.postMessage({ type: 'PRECACHE_DONE', ...stats });
|
||||
}).catch(err => {
|
||||
event.source && event.source.postMessage({ type: 'PRECACHE_ERROR', error: err.message });
|
||||
});
|
||||
}
|
||||
|
||||
if (event.data.type === 'CACHE_SIZE') {
|
||||
cacheSizes().then(sizes => {
|
||||
event.source && event.source.postMessage({ type: 'CACHE_SIZE_REPORT', sizes });
|
||||
});
|
||||
}
|
||||
|
||||
if (event.data.type === 'CLEAR_TILES') {
|
||||
caches.delete(TILES_CACHE).then(ok => {
|
||||
event.source && event.source.postMessage({ type: 'TILES_CLEARED', ok });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Calcula tiles necessários e baixa em paralelo (max 6 simultâneos)
|
||||
async function precacheTiles(bounds, minZoom, maxZoom) {
|
||||
const cache = await caches.open(TILES_CACHE);
|
||||
const tiles = [];
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
const min = lngLatToTile(bounds.west, bounds.north, z);
|
||||
const max = lngLatToTile(bounds.east, bounds.south, z);
|
||||
for (let x = min.x; x <= max.x; x++) {
|
||||
for (let y = min.y; y <= max.y; y++) {
|
||||
tiles.push({ z, x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let done = 0, failed = 0;
|
||||
const total = tiles.length;
|
||||
// Limita a 6 conexões paralelas pra respeitar OSM tile policy
|
||||
const queue = tiles.slice();
|
||||
await Promise.all([...Array(6)].map(async () => {
|
||||
while (queue.length) {
|
||||
const t = queue.shift();
|
||||
const url = `https://tile.openstreetmap.org/${t.z}/${t.x}/${t.y}.png`;
|
||||
try {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'Shivao-PWA' } });
|
||||
if (res.ok) await cache.put(url, res);
|
||||
else failed++;
|
||||
} catch { failed++; }
|
||||
done++;
|
||||
}
|
||||
}));
|
||||
|
||||
return { total, done, failed };
|
||||
}
|
||||
|
||||
function lngLatToTile(lng, lat, z) {
|
||||
const x = Math.floor((lng + 180) / 360 * Math.pow(2, z));
|
||||
const y = Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, z));
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
async function cacheSizes() {
|
||||
const result = {};
|
||||
for (const name of [SHELL_CACHE, TILES_CACHE, WINDY_CACHE]) {
|
||||
const cache = await caches.open(name);
|
||||
const keys = await cache.keys();
|
||||
result[name] = keys.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Loading…
Reference in a new issue