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

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:
PontualTech / Karlão 2026-04-28 06:51:35 -03:00
parent 5833efcc48
commit 21b91b3522
8 changed files with 581 additions and 16 deletions

View file

@ -528,6 +528,15 @@ header{
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)} .fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)} .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 === */
.boat-photo-row{display:flex;gap:14px;align-items:flex-start} .boat-photo-row{display:flex;gap:14px;align-items:flex-start}
.boat-photo-preview{ .boat-photo-preview{
@ -1368,7 +1377,7 @@ header{
</svg> </svg>
<div class="boat-header-avatar" id="boat-header-avatar" onclick="openFleetManager()" style="cursor:pointer" title="Gerenciar frota"></div> <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-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"> <button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota">
<span id="boat-name-display">Shivao</span> <span id="boat-name-display">Shivao</span>
<span class="boat-chevron" aria-hidden="true"></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(); 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)} 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']; const MESES=['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ'];
@ -2981,10 +2994,22 @@ async function updateStorageInfo(){
initBattery(); initBattery();
initServiceWorker(); initServiceWorker();
initSensorWidget(); initSensorWidget();
// Realtime sync: conecta WebSocket se cloud configurada
setSyncStatus(cloudConfigured()?'syncing':'disabled');
if(cloudConfigured())rtConnect();
// tenta auto-fetch do tempo após pequeno delay // tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000); 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 ============ // ============ 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:[]}; 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; 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) ===== // ===== Auth (multi-tenant SaaS — Login/Signup) =====
async function authSignup(email,password,name){ async function authSignup(email,password,name){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro'); if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
@ -3813,8 +4017,8 @@ function bindCloudInputs(){
if(!u||!t)return; if(!u||!t)return;
u.value=state.cloud?.url||''; u.value=state.cloud?.url||'';
t.value=state.cloud?.token||''; t.value=state.cloud?.token||'';
u.addEventListener('change',()=>{state.cloud.url=u.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()}); t.addEventListener('change',()=>{state.cloud.token=t.value.trim();saveState();renderCloudStatus();rtDisconnect();if(cloudConfigured())rtConnect()});
} }
function renderCloudStatus(){ function renderCloudStatus(){

View file

@ -7,8 +7,8 @@ android {
applicationId "br.com.pontualtech.shivao" applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 5 versionCode 6
versionName "1.4.1" versionName "1.5.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -1,6 +1,6 @@
{ {
"name": "shivao-mobile", "name": "shivao-mobile",
"version": "1.4.1", "version": "1.5.0",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)", "description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View file

@ -16,6 +16,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15", "nodemailer": "^6.9.15",
"ws": "^8.20.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"engines": { "engines": {
@ -1574,6 +1575,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -19,6 +19,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15", "nodemailer": "^6.9.15",
"ws": "^8.20.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"license": "MIT" "license": "MIT"

View file

@ -528,6 +528,15 @@ header{
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)} .fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)} .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 === */
.boat-photo-row{display:flex;gap:14px;align-items:flex-start} .boat-photo-row{display:flex;gap:14px;align-items:flex-start}
.boat-photo-preview{ .boat-photo-preview{
@ -1368,7 +1377,7 @@ header{
</svg> </svg>
<div class="boat-header-avatar" id="boat-header-avatar" onclick="openFleetManager()" style="cursor:pointer" title="Gerenciar frota"></div> <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-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"> <button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota">
<span id="boat-name-display">Shivao</span> <span id="boat-name-display">Shivao</span>
<span class="boat-chevron" aria-hidden="true"></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(); 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)} 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']; const MESES=['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ'];
@ -2981,10 +2994,22 @@ async function updateStorageInfo(){
initBattery(); initBattery();
initServiceWorker(); initServiceWorker();
initSensorWidget(); initSensorWidget();
// Realtime sync: conecta WebSocket se cloud configurada
setSyncStatus(cloudConfigured()?'syncing':'disabled');
if(cloudConfigured())rtConnect();
// tenta auto-fetch do tempo após pequeno delay // tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000); 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 ============ // ============ 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:[]}; 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; 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) ===== // ===== Auth (multi-tenant SaaS — Login/Signup) =====
async function authSignup(email,password,name){ async function authSignup(email,password,name){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro'); if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
@ -3813,8 +4017,8 @@ function bindCloudInputs(){
if(!u||!t)return; if(!u||!t)return;
u.value=state.cloud?.url||''; u.value=state.cloud?.url||'';
t.value=state.cloud?.token||''; t.value=state.cloud?.token||'';
u.addEventListener('change',()=>{state.cloud.url=u.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()}); t.addEventListener('change',()=>{state.cloud.token=t.value.trim();saveState();renderCloudStatus();rtDisconnect();if(cloudConfigured())rtConnect()});
} }
function renderCloudStatus(){ function renderCloudStatus(){

View file

@ -1,4 +1,5 @@
import express from 'express'; import express from 'express';
import http from 'node:http';
import multer from 'multer'; import multer from 'multer';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import path from 'node:path'; 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 { validate, setStateSchema, updateZonesSchema, signupSchema, loginSchema, checkoutSchema } from './schemas/index.js';
import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verifyToken, PLANS, planFeatures } from './auth.js'; import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verifyToken, PLANS, planFeatures } from './auth.js';
import * as billing from './billing.js'; import * as billing from './billing.js';
import { initRealtime, broadcastStateChange, getOnlineCount } from './realtime.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '3000'); 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 // 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)); 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) // 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 { data } = req.body;
const ts = db.setState(req.user.id, data); const ts = db.setState(req.user.id, data);
db.audit(req.user.id, 'state_set', 'state', null, { bytes: JSON.stringify(data).length }, req.ip); 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 --- // --- Media ---
@ -920,7 +925,10 @@ async function checkDeadman() {
setInterval(checkDeadman, 30000); // check every 30s setInterval(checkDeadman, 30000); // check every 30s
// ==== Start ==== // ==== Start ====
app.listen(PORT, () => { const httpServer = http.createServer(app);
initRealtime(httpServer);
httpServer.listen(PORT, () => {
console.log(`Shivao Cloud rodando em :${PORT}`); console.log(`Shivao Cloud rodando em :${PORT}`);
console.log(`Canais configurados: ${listConfiguredChannels().join(', ') || '(nenhum!)'}`); console.log(`Canais configurados: ${listConfiguredChannels().join(', ') || '(nenhum!)'}`);
console.log(`Dead-man switch: ${HEARTBEAT_TIMEOUT/1000}s`); console.log(`Dead-man switch: ${HEARTBEAT_TIMEOUT/1000}s`);

126
server/src/realtime.js Normal file
View 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;
}