feat(sync): WebSocket realtime + auto-push/pull entre PC e celular v1.5.0
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Backend (server/src/realtime.js): - WebSocket server em /ws via lib `ws` - Auth por JWT ou BOAT_TOKEN (mesmo middleware do REST) - Broadcast de notificações state:changed por user (skip device origem) - Heartbeat ping/pong + cleanup de conexões mortas - Presença: avisa todos os devices do user quantos estão online - POST /api/data agora dispara broadcast pra outros devices em tempo real Frontend (app/diario-bordo.html): - Cliente WS com reconnect exponencial (1s→2s→5s→15s→30s→60s) - deviceId persistente em localStorage (gerado no primeiro boot) - Heartbeat 25s pra manter NAT/proxy abertos - Auto-push debounced 2.5s no saveState (acumula edições rápidas) - Auto-pull debounced 300ms no recebimento de state:changed - Reconnect ao voltar pro foreground + ao recuperar conexão - Indicador visual no header: 🟢 online · 🟡 syncing · 🔴 offline · ⚫ disabled · ⚠️ erro Echo prevention em 3 camadas: 1) Server skip por originDeviceId (header X-Device-Id) 2) Cliente ignora notif do próprio device 3) Guard temporal: pull rejeita se updated_at < lastPushAt Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5833efcc48
commit
21b91b3522
8 changed files with 581 additions and 16 deletions
|
|
@ -528,6 +528,15 @@ header{
|
|||
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
|
||||
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)}
|
||||
|
||||
/* === Sync Indicator === */
|
||||
.sync-indicator{
|
||||
display:inline-block;font-size:10px;
|
||||
margin-left:6px;cursor:help;
|
||||
vertical-align:1px;
|
||||
}
|
||||
.sync-indicator[data-status="syncing"]{animation:syncPulse 1.2s infinite}
|
||||
@keyframes syncPulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
|
||||
/* === Boat Photo === */
|
||||
.boat-photo-row{display:flex;gap:14px;align-items:flex-start}
|
||||
.boat-photo-preview{
|
||||
|
|
@ -1368,7 +1377,7 @@ header{
|
|||
</svg>
|
||||
<div class="boat-header-avatar" id="boat-header-avatar" onclick="openFleetManager()" style="cursor:pointer" title="Gerenciar frota">⛵</div>
|
||||
<div class="boat-info">
|
||||
<div class="boat-tagline">Diário de Bordo · Logbook</div>
|
||||
<div class="boat-tagline">Diário de Bordo · Logbook <span class="sync-indicator" id="sync-indicator" data-status="disabled" title="Sync na nuvem desligado">⚫</span></div>
|
||||
<button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota">
|
||||
<span id="boat-name-display">Shivao</span>
|
||||
<span class="boat-chevron" aria-hidden="true">▾</span>
|
||||
|
|
@ -2216,7 +2225,11 @@ function loadState(){try{const raw=localStorage.getItem(STORAGE_KEY);if(raw){con
|
|||
saveState();
|
||||
}
|
||||
}
|
||||
function saveState(){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){toast('Erro ao salvar')}}
|
||||
function saveState(){
|
||||
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){toast('Erro ao salvar')}
|
||||
// Auto-push pra nuvem (debounced) — só se cloud configurada
|
||||
if(typeof scheduleCloudPush==='function')scheduleCloudPush();
|
||||
}
|
||||
|
||||
function uid(){return Date.now().toString(36)+Math.random().toString(36).slice(2,7)}
|
||||
const MESES=['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ'];
|
||||
|
|
@ -2981,10 +2994,22 @@ async function updateStorageInfo(){
|
|||
initBattery();
|
||||
initServiceWorker();
|
||||
initSensorWidget();
|
||||
// Realtime sync: conecta WebSocket se cloud configurada
|
||||
setSyncStatus(cloudConfigured()?'syncing':'disabled');
|
||||
if(cloudConfigured())rtConnect();
|
||||
// tenta auto-fetch do tempo após pequeno delay
|
||||
setTimeout(maybeAutoFetchWeather,3000);
|
||||
})();
|
||||
document.addEventListener('visibilitychange',async()=>{if(document.visibilityState==='visible'){if(tracking.active&&!tracking.wakeLock)await requestWakeLock();if(anchorWatch.active&&!anchorWatch.wakeLock)await requestAnchorWakeLock()}});
|
||||
document.addEventListener('visibilitychange',async()=>{
|
||||
if(document.visibilityState==='visible'){
|
||||
if(tracking.active&&!tracking.wakeLock)await requestWakeLock();
|
||||
if(anchorWatch.active&&!anchorWatch.wakeLock)await requestAnchorWakeLock();
|
||||
// Reconecta WS ao voltar ao foreground
|
||||
if(cloudConfigured()&&(!_wsConn||_wsConn.readyState!==WebSocket.OPEN))rtConnect();
|
||||
}
|
||||
});
|
||||
window.addEventListener('online',()=>{if(cloudConfigured())rtConnect()});
|
||||
window.addEventListener('offline',()=>setSyncStatus('offline'));
|
||||
|
||||
// ============ ANCHOR WATCH ============
|
||||
const anchorWatch={active:false,watchId:null,startedAt:null,anchorPos:null,swingPos:null,radius:50,currentDist:0,maxDist:0,wakeLock:null,alarmFired:false,alarmCount:0,autoRecenter:false,recentPositions:[]};
|
||||
|
|
@ -3629,6 +3654,185 @@ async function cloudFetch(path,opts={}){
|
|||
return r;
|
||||
}
|
||||
|
||||
// ============ REALTIME SYNC (WebSocket + auto push/pull) ============
|
||||
// Conecta ao /ws do servidor, escuta notificações de mudança de outros devices
|
||||
// e dispara pull/push automáticos. Reconnect com backoff exponencial.
|
||||
|
||||
const SYNC_DEBOUNCE_MS=2500;
|
||||
const SYNC_PULL_DEBOUNCE_MS=300;
|
||||
let _wsConn=null;
|
||||
let _wsReconnectTimer=null;
|
||||
let _wsReconnectAttempt=0;
|
||||
let _wsHeartbeatTimer=null;
|
||||
let _wsPushTimer=null;
|
||||
let _wsPullTimer=null;
|
||||
let _wsLastPushAt=0;
|
||||
let _wsSyncInFlight=false;
|
||||
|
||||
function getDeviceId(){
|
||||
let id=localStorage.getItem('shivao_device_id');
|
||||
if(!id){id='dev-'+Math.random().toString(36).slice(2,12);localStorage.setItem('shivao_device_id',id)}
|
||||
return id;
|
||||
}
|
||||
|
||||
function setSyncStatus(status,detail){
|
||||
// status: 'online' | 'syncing' | 'offline' | 'disabled' | 'error'
|
||||
const el=document.getElementById('sync-indicator');
|
||||
if(!el)return;
|
||||
el.dataset.status=status;
|
||||
const labels={online:'🟢',syncing:'🟡',offline:'🔴',disabled:'⚫',error:'⚠️'};
|
||||
const titles={
|
||||
online:'Sincronizado em tempo real',
|
||||
syncing:'Sincronizando...',
|
||||
offline:'Sem conexão (mudanças salvas localmente)',
|
||||
disabled:'Sync na nuvem desligado — toque em Arquivo › Nuvem',
|
||||
error:'Erro de sincronização'
|
||||
};
|
||||
el.textContent=labels[status]||'';
|
||||
el.title=(titles[status]||'')+(detail?' — '+detail:'');
|
||||
}
|
||||
|
||||
function rtConnect(){
|
||||
if(!cloudConfigured()){setSyncStatus('disabled');return}
|
||||
if(_wsConn&&(_wsConn.readyState===WebSocket.OPEN||_wsConn.readyState===WebSocket.CONNECTING))return;
|
||||
try{
|
||||
const baseUrl=state.cloud.url.replace(/\/$/,'').replace(/^http/,'ws');
|
||||
const tok=(state.auth&&state.auth.accessToken)?state.auth.accessToken:state.cloud.token;
|
||||
const url=`${baseUrl}/ws?token=${encodeURIComponent(tok)}&device=${encodeURIComponent(getDeviceId())}`;
|
||||
_wsConn=new WebSocket(url);
|
||||
setSyncStatus('syncing','conectando...');
|
||||
}catch(e){
|
||||
console.warn('[rt] connect failed',e);
|
||||
scheduleRtReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
_wsConn.onopen=()=>{
|
||||
_wsReconnectAttempt=0;
|
||||
setSyncStatus('online');
|
||||
// Pull inicial: garante state fresco quando conecta
|
||||
schedulePull();
|
||||
// Heartbeat
|
||||
if(_wsHeartbeatTimer)clearInterval(_wsHeartbeatTimer);
|
||||
_wsHeartbeatTimer=setInterval(()=>{
|
||||
if(_wsConn&&_wsConn.readyState===WebSocket.OPEN){
|
||||
try{_wsConn.send(JSON.stringify({type:'ping'}))}catch(e){}
|
||||
}
|
||||
},25000);
|
||||
};
|
||||
|
||||
_wsConn.onmessage=(ev)=>{
|
||||
let msg;try{msg=JSON.parse(ev.data)}catch(e){return}
|
||||
if(msg.type==='hello'){
|
||||
console.log('[rt] hello',msg);
|
||||
}else if(msg.type==='state:changed'){
|
||||
// Outro device alterou o state: pull
|
||||
if(msg.originDeviceId===getDeviceId())return; // echo do nosso próprio push
|
||||
schedulePull();
|
||||
}else if(msg.type==='presence'){
|
||||
const el=document.getElementById('sync-indicator');
|
||||
if(el&&msg.count>1)el.title=`Sincronizado em tempo real · ${msg.count} dispositivos online`;
|
||||
}else if(msg.type==='error'){
|
||||
console.warn('[rt] server error',msg);
|
||||
setSyncStatus('error',msg.code);
|
||||
}
|
||||
};
|
||||
|
||||
_wsConn.onerror=(e)=>{console.warn('[rt] ws error',e)};
|
||||
_wsConn.onclose=(ev)=>{
|
||||
if(_wsHeartbeatTimer){clearInterval(_wsHeartbeatTimer);_wsHeartbeatTimer=null}
|
||||
_wsConn=null;
|
||||
if(!cloudConfigured()){setSyncStatus('disabled');return}
|
||||
setSyncStatus('offline');
|
||||
if(ev.code!==1008)scheduleRtReconnect(); // 1008 = auth fail, não retentar
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleRtReconnect(){
|
||||
if(_wsReconnectTimer)clearTimeout(_wsReconnectTimer);
|
||||
const delays=[1000,2000,5000,15000,30000,60000];
|
||||
const delay=delays[Math.min(_wsReconnectAttempt,delays.length-1)];
|
||||
_wsReconnectAttempt++;
|
||||
_wsReconnectTimer=setTimeout(()=>rtConnect(),delay);
|
||||
}
|
||||
|
||||
function rtDisconnect(){
|
||||
if(_wsReconnectTimer){clearTimeout(_wsReconnectTimer);_wsReconnectTimer=null}
|
||||
if(_wsHeartbeatTimer){clearInterval(_wsHeartbeatTimer);_wsHeartbeatTimer=null}
|
||||
if(_wsConn){try{_wsConn.close(1000,'client disconnect')}catch(e){}_wsConn=null}
|
||||
setSyncStatus('disabled');
|
||||
}
|
||||
|
||||
// Push debounced — chamado de saveState. Só sobe quando configurado.
|
||||
function scheduleCloudPush(){
|
||||
if(!cloudConfigured())return;
|
||||
if(_wsPushTimer)clearTimeout(_wsPushTimer);
|
||||
_wsPushTimer=setTimeout(()=>autoPushNow().catch(()=>{}),SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function autoPushNow(){
|
||||
if(!cloudConfigured())return;
|
||||
if(_wsSyncInFlight)return;
|
||||
_wsSyncInFlight=true;
|
||||
setSyncStatus('syncing','enviando...');
|
||||
try{
|
||||
const{cloud,...dataNoCloud}=state;
|
||||
await cloudFetch('/api/data?device='+encodeURIComponent(getDeviceId()),{
|
||||
method:'POST',
|
||||
headers:{'X-Device-Id':getDeviceId()},
|
||||
body:JSON.stringify({data:dataNoCloud}),
|
||||
});
|
||||
state.cloud.lastSync=Date.now();
|
||||
_wsLastPushAt=Date.now();
|
||||
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){}
|
||||
setSyncStatus(_wsConn&&_wsConn.readyState===WebSocket.OPEN?'online':'offline');
|
||||
if(typeof renderCloudStatus==='function')renderCloudStatus();
|
||||
}catch(e){
|
||||
console.warn('[rt] push failed',e.message);
|
||||
setSyncStatus('error',e.message);
|
||||
}finally{
|
||||
_wsSyncInFlight=false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pull debounced — chamado quando WS notifica mudança remota
|
||||
function schedulePull(){
|
||||
if(_wsPullTimer)clearTimeout(_wsPullTimer);
|
||||
_wsPullTimer=setTimeout(()=>autoPullNow().catch(()=>{}),SYNC_PULL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function autoPullNow(){
|
||||
if(!cloudConfigured())return;
|
||||
if(_wsSyncInFlight)return;
|
||||
// Não puxa se acabamos de empurrar (echo guard adicional)
|
||||
if(Date.now()-_wsLastPushAt<1000)return;
|
||||
_wsSyncInFlight=true;
|
||||
setSyncStatus('syncing','baixando...');
|
||||
try{
|
||||
const r=await cloudFetch('/api/data');
|
||||
const{data,updated_at}=await r.json();
|
||||
if(!data){setSyncStatus('online');_wsSyncInFlight=false;return}
|
||||
// Se o updated_at remoto é mais antigo que nosso último push, ignora (evita rollback)
|
||||
if(updated_at&&_wsLastPushAt&&updated_at<_wsLastPushAt){_wsSyncInFlight=false;setSyncStatus('online');return}
|
||||
const cloudKeep=state.cloud;
|
||||
const authKeep=state.auth;
|
||||
Object.assign(state,data);
|
||||
state.cloud=cloudKeep;
|
||||
if(authKeep)state.auth=authKeep;
|
||||
state.cloud.lastSync=Date.now();
|
||||
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){}
|
||||
bindHeader();
|
||||
if(typeof renderAll==='function')await renderAll();
|
||||
setSyncStatus('online','recebido de outro dispositivo');
|
||||
toast('🔄 Atualizado por outro dispositivo');
|
||||
}catch(e){
|
||||
console.warn('[rt] pull failed',e.message);
|
||||
setSyncStatus('error',e.message);
|
||||
}finally{
|
||||
_wsSyncInFlight=false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Auth (multi-tenant SaaS — Login/Signup) =====
|
||||
async function authSignup(email,password,name){
|
||||
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
|
||||
|
|
@ -3813,8 +4017,8 @@ function bindCloudInputs(){
|
|||
if(!u||!t)return;
|
||||
u.value=state.cloud?.url||'';
|
||||
t.value=state.cloud?.token||'';
|
||||
u.addEventListener('change',()=>{state.cloud.url=u.value.trim();saveState();renderCloudStatus()});
|
||||
t.addEventListener('change',()=>{state.cloud.token=t.value.trim();saveState();renderCloudStatus()});
|
||||
u.addEventListener('change',()=>{state.cloud.url=u.value.trim();saveState();renderCloudStatus();rtDisconnect();if(cloudConfigured())rtConnect()});
|
||||
t.addEventListener('change',()=>{state.cloud.token=t.value.trim();saveState();renderCloudStatus();rtDisconnect();if(cloudConfigured())rtConnect()});
|
||||
}
|
||||
|
||||
function renderCloudStatus(){
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ android {
|
|||
applicationId "br.com.pontualtech.shivao"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 5
|
||||
versionName "1.4.1"
|
||||
versionCode 6
|
||||
versionName "1.5.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "shivao-mobile",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.0",
|
||||
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
|
|
|||
22
server/package-lock.json
generated
22
server/package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
|||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.15",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1574,6 +1575,27 @@
|
|||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.15",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"license": "MIT"
|
||||
|
|
|
|||
|
|
@ -528,6 +528,15 @@ header{
|
|||
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
|
||||
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)}
|
||||
|
||||
/* === Sync Indicator === */
|
||||
.sync-indicator{
|
||||
display:inline-block;font-size:10px;
|
||||
margin-left:6px;cursor:help;
|
||||
vertical-align:1px;
|
||||
}
|
||||
.sync-indicator[data-status="syncing"]{animation:syncPulse 1.2s infinite}
|
||||
@keyframes syncPulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
|
||||
/* === Boat Photo === */
|
||||
.boat-photo-row{display:flex;gap:14px;align-items:flex-start}
|
||||
.boat-photo-preview{
|
||||
|
|
@ -1368,7 +1377,7 @@ header{
|
|||
</svg>
|
||||
<div class="boat-header-avatar" id="boat-header-avatar" onclick="openFleetManager()" style="cursor:pointer" title="Gerenciar frota">⛵</div>
|
||||
<div class="boat-info">
|
||||
<div class="boat-tagline">Diário de Bordo · Logbook</div>
|
||||
<div class="boat-tagline">Diário de Bordo · Logbook <span class="sync-indicator" id="sync-indicator" data-status="disabled" title="Sync na nuvem desligado">⚫</span></div>
|
||||
<button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota">
|
||||
<span id="boat-name-display">Shivao</span>
|
||||
<span class="boat-chevron" aria-hidden="true">▾</span>
|
||||
|
|
@ -2216,7 +2225,11 @@ function loadState(){try{const raw=localStorage.getItem(STORAGE_KEY);if(raw){con
|
|||
saveState();
|
||||
}
|
||||
}
|
||||
function saveState(){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){toast('Erro ao salvar')}}
|
||||
function saveState(){
|
||||
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){toast('Erro ao salvar')}
|
||||
// Auto-push pra nuvem (debounced) — só se cloud configurada
|
||||
if(typeof scheduleCloudPush==='function')scheduleCloudPush();
|
||||
}
|
||||
|
||||
function uid(){return Date.now().toString(36)+Math.random().toString(36).slice(2,7)}
|
||||
const MESES=['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ'];
|
||||
|
|
@ -2981,10 +2994,22 @@ async function updateStorageInfo(){
|
|||
initBattery();
|
||||
initServiceWorker();
|
||||
initSensorWidget();
|
||||
// Realtime sync: conecta WebSocket se cloud configurada
|
||||
setSyncStatus(cloudConfigured()?'syncing':'disabled');
|
||||
if(cloudConfigured())rtConnect();
|
||||
// tenta auto-fetch do tempo após pequeno delay
|
||||
setTimeout(maybeAutoFetchWeather,3000);
|
||||
})();
|
||||
document.addEventListener('visibilitychange',async()=>{if(document.visibilityState==='visible'){if(tracking.active&&!tracking.wakeLock)await requestWakeLock();if(anchorWatch.active&&!anchorWatch.wakeLock)await requestAnchorWakeLock()}});
|
||||
document.addEventListener('visibilitychange',async()=>{
|
||||
if(document.visibilityState==='visible'){
|
||||
if(tracking.active&&!tracking.wakeLock)await requestWakeLock();
|
||||
if(anchorWatch.active&&!anchorWatch.wakeLock)await requestAnchorWakeLock();
|
||||
// Reconecta WS ao voltar ao foreground
|
||||
if(cloudConfigured()&&(!_wsConn||_wsConn.readyState!==WebSocket.OPEN))rtConnect();
|
||||
}
|
||||
});
|
||||
window.addEventListener('online',()=>{if(cloudConfigured())rtConnect()});
|
||||
window.addEventListener('offline',()=>setSyncStatus('offline'));
|
||||
|
||||
// ============ ANCHOR WATCH ============
|
||||
const anchorWatch={active:false,watchId:null,startedAt:null,anchorPos:null,swingPos:null,radius:50,currentDist:0,maxDist:0,wakeLock:null,alarmFired:false,alarmCount:0,autoRecenter:false,recentPositions:[]};
|
||||
|
|
@ -3629,6 +3654,185 @@ async function cloudFetch(path,opts={}){
|
|||
return r;
|
||||
}
|
||||
|
||||
// ============ REALTIME SYNC (WebSocket + auto push/pull) ============
|
||||
// Conecta ao /ws do servidor, escuta notificações de mudança de outros devices
|
||||
// e dispara pull/push automáticos. Reconnect com backoff exponencial.
|
||||
|
||||
const SYNC_DEBOUNCE_MS=2500;
|
||||
const SYNC_PULL_DEBOUNCE_MS=300;
|
||||
let _wsConn=null;
|
||||
let _wsReconnectTimer=null;
|
||||
let _wsReconnectAttempt=0;
|
||||
let _wsHeartbeatTimer=null;
|
||||
let _wsPushTimer=null;
|
||||
let _wsPullTimer=null;
|
||||
let _wsLastPushAt=0;
|
||||
let _wsSyncInFlight=false;
|
||||
|
||||
function getDeviceId(){
|
||||
let id=localStorage.getItem('shivao_device_id');
|
||||
if(!id){id='dev-'+Math.random().toString(36).slice(2,12);localStorage.setItem('shivao_device_id',id)}
|
||||
return id;
|
||||
}
|
||||
|
||||
function setSyncStatus(status,detail){
|
||||
// status: 'online' | 'syncing' | 'offline' | 'disabled' | 'error'
|
||||
const el=document.getElementById('sync-indicator');
|
||||
if(!el)return;
|
||||
el.dataset.status=status;
|
||||
const labels={online:'🟢',syncing:'🟡',offline:'🔴',disabled:'⚫',error:'⚠️'};
|
||||
const titles={
|
||||
online:'Sincronizado em tempo real',
|
||||
syncing:'Sincronizando...',
|
||||
offline:'Sem conexão (mudanças salvas localmente)',
|
||||
disabled:'Sync na nuvem desligado — toque em Arquivo › Nuvem',
|
||||
error:'Erro de sincronização'
|
||||
};
|
||||
el.textContent=labels[status]||'';
|
||||
el.title=(titles[status]||'')+(detail?' — '+detail:'');
|
||||
}
|
||||
|
||||
function rtConnect(){
|
||||
if(!cloudConfigured()){setSyncStatus('disabled');return}
|
||||
if(_wsConn&&(_wsConn.readyState===WebSocket.OPEN||_wsConn.readyState===WebSocket.CONNECTING))return;
|
||||
try{
|
||||
const baseUrl=state.cloud.url.replace(/\/$/,'').replace(/^http/,'ws');
|
||||
const tok=(state.auth&&state.auth.accessToken)?state.auth.accessToken:state.cloud.token;
|
||||
const url=`${baseUrl}/ws?token=${encodeURIComponent(tok)}&device=${encodeURIComponent(getDeviceId())}`;
|
||||
_wsConn=new WebSocket(url);
|
||||
setSyncStatus('syncing','conectando...');
|
||||
}catch(e){
|
||||
console.warn('[rt] connect failed',e);
|
||||
scheduleRtReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
_wsConn.onopen=()=>{
|
||||
_wsReconnectAttempt=0;
|
||||
setSyncStatus('online');
|
||||
// Pull inicial: garante state fresco quando conecta
|
||||
schedulePull();
|
||||
// Heartbeat
|
||||
if(_wsHeartbeatTimer)clearInterval(_wsHeartbeatTimer);
|
||||
_wsHeartbeatTimer=setInterval(()=>{
|
||||
if(_wsConn&&_wsConn.readyState===WebSocket.OPEN){
|
||||
try{_wsConn.send(JSON.stringify({type:'ping'}))}catch(e){}
|
||||
}
|
||||
},25000);
|
||||
};
|
||||
|
||||
_wsConn.onmessage=(ev)=>{
|
||||
let msg;try{msg=JSON.parse(ev.data)}catch(e){return}
|
||||
if(msg.type==='hello'){
|
||||
console.log('[rt] hello',msg);
|
||||
}else if(msg.type==='state:changed'){
|
||||
// Outro device alterou o state: pull
|
||||
if(msg.originDeviceId===getDeviceId())return; // echo do nosso próprio push
|
||||
schedulePull();
|
||||
}else if(msg.type==='presence'){
|
||||
const el=document.getElementById('sync-indicator');
|
||||
if(el&&msg.count>1)el.title=`Sincronizado em tempo real · ${msg.count} dispositivos online`;
|
||||
}else if(msg.type==='error'){
|
||||
console.warn('[rt] server error',msg);
|
||||
setSyncStatus('error',msg.code);
|
||||
}
|
||||
};
|
||||
|
||||
_wsConn.onerror=(e)=>{console.warn('[rt] ws error',e)};
|
||||
_wsConn.onclose=(ev)=>{
|
||||
if(_wsHeartbeatTimer){clearInterval(_wsHeartbeatTimer);_wsHeartbeatTimer=null}
|
||||
_wsConn=null;
|
||||
if(!cloudConfigured()){setSyncStatus('disabled');return}
|
||||
setSyncStatus('offline');
|
||||
if(ev.code!==1008)scheduleRtReconnect(); // 1008 = auth fail, não retentar
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleRtReconnect(){
|
||||
if(_wsReconnectTimer)clearTimeout(_wsReconnectTimer);
|
||||
const delays=[1000,2000,5000,15000,30000,60000];
|
||||
const delay=delays[Math.min(_wsReconnectAttempt,delays.length-1)];
|
||||
_wsReconnectAttempt++;
|
||||
_wsReconnectTimer=setTimeout(()=>rtConnect(),delay);
|
||||
}
|
||||
|
||||
function rtDisconnect(){
|
||||
if(_wsReconnectTimer){clearTimeout(_wsReconnectTimer);_wsReconnectTimer=null}
|
||||
if(_wsHeartbeatTimer){clearInterval(_wsHeartbeatTimer);_wsHeartbeatTimer=null}
|
||||
if(_wsConn){try{_wsConn.close(1000,'client disconnect')}catch(e){}_wsConn=null}
|
||||
setSyncStatus('disabled');
|
||||
}
|
||||
|
||||
// Push debounced — chamado de saveState. Só sobe quando configurado.
|
||||
function scheduleCloudPush(){
|
||||
if(!cloudConfigured())return;
|
||||
if(_wsPushTimer)clearTimeout(_wsPushTimer);
|
||||
_wsPushTimer=setTimeout(()=>autoPushNow().catch(()=>{}),SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function autoPushNow(){
|
||||
if(!cloudConfigured())return;
|
||||
if(_wsSyncInFlight)return;
|
||||
_wsSyncInFlight=true;
|
||||
setSyncStatus('syncing','enviando...');
|
||||
try{
|
||||
const{cloud,...dataNoCloud}=state;
|
||||
await cloudFetch('/api/data?device='+encodeURIComponent(getDeviceId()),{
|
||||
method:'POST',
|
||||
headers:{'X-Device-Id':getDeviceId()},
|
||||
body:JSON.stringify({data:dataNoCloud}),
|
||||
});
|
||||
state.cloud.lastSync=Date.now();
|
||||
_wsLastPushAt=Date.now();
|
||||
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){}
|
||||
setSyncStatus(_wsConn&&_wsConn.readyState===WebSocket.OPEN?'online':'offline');
|
||||
if(typeof renderCloudStatus==='function')renderCloudStatus();
|
||||
}catch(e){
|
||||
console.warn('[rt] push failed',e.message);
|
||||
setSyncStatus('error',e.message);
|
||||
}finally{
|
||||
_wsSyncInFlight=false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pull debounced — chamado quando WS notifica mudança remota
|
||||
function schedulePull(){
|
||||
if(_wsPullTimer)clearTimeout(_wsPullTimer);
|
||||
_wsPullTimer=setTimeout(()=>autoPullNow().catch(()=>{}),SYNC_PULL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function autoPullNow(){
|
||||
if(!cloudConfigured())return;
|
||||
if(_wsSyncInFlight)return;
|
||||
// Não puxa se acabamos de empurrar (echo guard adicional)
|
||||
if(Date.now()-_wsLastPushAt<1000)return;
|
||||
_wsSyncInFlight=true;
|
||||
setSyncStatus('syncing','baixando...');
|
||||
try{
|
||||
const r=await cloudFetch('/api/data');
|
||||
const{data,updated_at}=await r.json();
|
||||
if(!data){setSyncStatus('online');_wsSyncInFlight=false;return}
|
||||
// Se o updated_at remoto é mais antigo que nosso último push, ignora (evita rollback)
|
||||
if(updated_at&&_wsLastPushAt&&updated_at<_wsLastPushAt){_wsSyncInFlight=false;setSyncStatus('online');return}
|
||||
const cloudKeep=state.cloud;
|
||||
const authKeep=state.auth;
|
||||
Object.assign(state,data);
|
||||
state.cloud=cloudKeep;
|
||||
if(authKeep)state.auth=authKeep;
|
||||
state.cloud.lastSync=Date.now();
|
||||
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){}
|
||||
bindHeader();
|
||||
if(typeof renderAll==='function')await renderAll();
|
||||
setSyncStatus('online','recebido de outro dispositivo');
|
||||
toast('🔄 Atualizado por outro dispositivo');
|
||||
}catch(e){
|
||||
console.warn('[rt] pull failed',e.message);
|
||||
setSyncStatus('error',e.message);
|
||||
}finally{
|
||||
_wsSyncInFlight=false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Auth (multi-tenant SaaS — Login/Signup) =====
|
||||
async function authSignup(email,password,name){
|
||||
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
|
||||
|
|
@ -3813,8 +4017,8 @@ function bindCloudInputs(){
|
|||
if(!u||!t)return;
|
||||
u.value=state.cloud?.url||'';
|
||||
t.value=state.cloud?.token||'';
|
||||
u.addEventListener('change',()=>{state.cloud.url=u.value.trim();saveState();renderCloudStatus()});
|
||||
t.addEventListener('change',()=>{state.cloud.token=t.value.trim();saveState();renderCloudStatus()});
|
||||
u.addEventListener('change',()=>{state.cloud.url=u.value.trim();saveState();renderCloudStatus();rtDisconnect();if(cloudConfigured())rtConnect()});
|
||||
t.addEventListener('change',()=>{state.cloud.token=t.value.trim();saveState();renderCloudStatus();rtDisconnect();if(cloudConfigured())rtConnect()});
|
||||
}
|
||||
|
||||
function renderCloudStatus(){
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import express from 'express';
|
||||
import http from 'node:http';
|
||||
import multer from 'multer';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import path from 'node:path';
|
||||
|
|
@ -9,6 +10,7 @@ import { dispatchAlarm, dispatchTest, listConfiguredChannels } from './notificat
|
|||
import { validate, setStateSchema, updateZonesSchema, signupSchema, loginSchema, checkoutSchema } from './schemas/index.js';
|
||||
import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verifyToken, PLANS, planFeatures } from './auth.js';
|
||||
import * as billing from './billing.js';
|
||||
import { initRealtime, broadcastStateChange, getOnlineCount } from './realtime.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = parseInt(process.env.PORT || '3000');
|
||||
|
|
@ -264,7 +266,7 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
|
|||
});
|
||||
|
||||
// Atalho: /apk redireciona pra última APK release no Forgejo
|
||||
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.4.1/Shivao-v1.4.1.apk';
|
||||
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.5.0/Shivao-v1.5.0.apk';
|
||||
app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL));
|
||||
|
||||
// Página A4 imprimível com QR Code + instruções (cola no barco/marina)
|
||||
|
|
@ -565,7 +567,10 @@ app.post('/api/data', requireAuth, validate(setStateSchema), (req, res) => {
|
|||
const { data } = req.body;
|
||||
const ts = db.setState(req.user.id, data);
|
||||
db.audit(req.user.id, 'state_set', 'state', null, { bytes: JSON.stringify(data).length }, req.ip);
|
||||
res.json({ ok: true, updated_at: ts });
|
||||
// Notifica outros devices do mesmo user em tempo real (não bloqueia resposta)
|
||||
const originDeviceId = req.headers['x-device-id'] || req.query.device || null;
|
||||
broadcastStateChange(req.user.id, { kind: 'state', updated_at: ts, originDeviceId });
|
||||
res.json({ ok: true, updated_at: ts, online_devices: getOnlineCount(req.user.id) });
|
||||
});
|
||||
|
||||
// --- Media ---
|
||||
|
|
@ -920,7 +925,10 @@ async function checkDeadman() {
|
|||
setInterval(checkDeadman, 30000); // check every 30s
|
||||
|
||||
// ==== Start ====
|
||||
app.listen(PORT, () => {
|
||||
const httpServer = http.createServer(app);
|
||||
initRealtime(httpServer);
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`Shivao Cloud rodando em :${PORT}`);
|
||||
console.log(`Canais configurados: ${listConfiguredChannels().join(', ') || '(nenhum!)'}`);
|
||||
console.log(`Dead-man switch: ${HEARTBEAT_TIMEOUT/1000}s`);
|
||||
|
|
|
|||
126
server/src/realtime.js
Normal file
126
server/src/realtime.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// Realtime sync via WebSocket — broadcast state-change notifications between devices of the same user.
|
||||
// Cliente reage à notificação fazendo pull do estado via REST. Sem entity-level diffing.
|
||||
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { verifyToken } from './auth.js';
|
||||
|
||||
const TOKEN = process.env.BOAT_TOKEN;
|
||||
const HEARTBEAT_INTERVAL = 30000;
|
||||
|
||||
// Map<userId, Set<WebSocket>>
|
||||
const clientsByUser = new Map();
|
||||
|
||||
function addClient(userId, ws) {
|
||||
if (!clientsByUser.has(userId)) clientsByUser.set(userId, new Set());
|
||||
clientsByUser.get(userId).add(ws);
|
||||
}
|
||||
|
||||
function removeClient(userId, ws) {
|
||||
const set = clientsByUser.get(userId);
|
||||
if (!set) return;
|
||||
set.delete(ws);
|
||||
if (set.size === 0) clientsByUser.delete(userId);
|
||||
}
|
||||
|
||||
// Resolve um token (JWT ou BOAT_TOKEN) → userId. Retorna null se inválido.
|
||||
function authenticateToken(token) {
|
||||
if (!token) return null;
|
||||
if (token === TOKEN) return 1; // legacy single-tenant
|
||||
const payload = verifyToken(token);
|
||||
if (!payload || payload.type !== 'access' || !payload.uid) return null;
|
||||
return payload.uid;
|
||||
}
|
||||
|
||||
export function initRealtime(httpServer) {
|
||||
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token') || '';
|
||||
const deviceId = url.searchParams.get('device') || ('anon-' + Math.random().toString(36).slice(2, 8));
|
||||
const userId = authenticateToken(token);
|
||||
|
||||
if (!userId) {
|
||||
ws.send(JSON.stringify({ type: 'error', code: 'auth_failed' }));
|
||||
ws.close(1008, 'auth failed');
|
||||
return;
|
||||
}
|
||||
|
||||
ws.userId = userId;
|
||||
ws.deviceId = deviceId;
|
||||
ws.isAlive = true;
|
||||
addClient(userId, ws);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'hello', userId, deviceId, ts: Date.now() }));
|
||||
|
||||
// Quantos devices estão online pro mesmo user
|
||||
broadcastPresence(userId);
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
// Cliente pode mandar pings explícitos ou notificações. Ignoramos qualquer outra coisa.
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
removeClient(userId, ws);
|
||||
broadcastPresence(userId);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.warn('[ws] error', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Heartbeat: drop dead connections
|
||||
const heartbeat = setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.isAlive === false) return ws.terminate();
|
||||
ws.isAlive = false;
|
||||
try { ws.ping(); } catch (e) {}
|
||||
});
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
|
||||
wss.on('close', () => clearInterval(heartbeat));
|
||||
|
||||
console.log('[ws] WebSocket server attached at /ws');
|
||||
return wss;
|
||||
}
|
||||
|
||||
// Notifica todos os devices do user (exceto o que originou) que o estado mudou.
|
||||
// payload: { kind: 'state'|'pending'|'trip'|..., ts, originDeviceId? }
|
||||
export function broadcastStateChange(userId, payload = {}) {
|
||||
const set = clientsByUser.get(userId);
|
||||
if (!set) return;
|
||||
const msg = JSON.stringify({
|
||||
type: 'state:changed',
|
||||
ts: Date.now(),
|
||||
...payload,
|
||||
});
|
||||
for (const ws of set) {
|
||||
if (ws.readyState !== ws.OPEN) continue;
|
||||
if (payload.originDeviceId && ws.deviceId === payload.originDeviceId) continue;
|
||||
try { ws.send(msg); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastPresence(userId) {
|
||||
const set = clientsByUser.get(userId);
|
||||
if (!set) return;
|
||||
const msg = JSON.stringify({ type: 'presence', count: set.size, ts: Date.now() });
|
||||
for (const ws of set) {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
try { ws.send(msg); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getOnlineCount(userId) {
|
||||
return clientsByUser.get(userId)?.size || 0;
|
||||
}
|
||||
Loading…
Reference in a new issue