Files
rx.scoreodonto.com-client/index.js
T
VPS 4 Builder e6051db720
continuous-integration/webhook Client .exe publicado em produção (VPS4)
feat: add force-update IPC and auto-update flow
2026-06-03 06:16:46 +02:00

692 lines
23 KiB
JavaScript

const { app, BrowserWindow, Tray, Menu, ipcMain, shell, dialog, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const { fork } = require('child_process');
const { autoUpdater } = require('electron-updater');
let tray = null;
let configWindow = null;
let monitorProcess = null;
let currentStatus = { connected: false, identified: false };
let downloadProgress = null; // Variável para controlar o progresso de download
const recentLogs = [];
// ================================================================
// CONTROLE DE PAUSA DE ENVIO
// ================================================================
let isSendingPaused = false; // true = envio de imagens pausado
let autoResumeTimer = null; // timer que retoma o envio sozinho
const AUTO_RESUME_MS = 10 * 60 * 1000; // 10 minutos
function pauseSending() {
isSendingPaused = true;
if (monitorProcess) monitorProcess.send({ command: 'pause' });
// Agenda retomada automática após 10 min, caso o usuário esqueça de continuar
if (autoResumeTimer) clearTimeout(autoResumeTimer);
autoResumeTimer = setTimeout(() => {
addLog('▶️ Auto-retomada: 10 minutos se passaram, retomando envio automaticamente.');
resumeSending(true);
}, AUTO_RESUME_MS);
if (tray) {
tray.displayBalloon({
title: 'ScoreOdonto - Envio Pausado',
content: 'O envio de imagens foi pausado. Ele será retomado automaticamente em 10 minutos se você não continuar antes.',
iconType: 'warning'
});
}
updateTrayMenu();
}
function resumeSending(isAuto = false) {
isSendingPaused = false;
if (autoResumeTimer) { clearTimeout(autoResumeTimer); autoResumeTimer = null; }
if (monitorProcess) monitorProcess.send({ command: 'resume' });
if (tray && !isAuto) {
tray.displayBalloon({
title: 'ScoreOdonto - Envio Retomado',
content: 'O envio de imagens foi retomado.',
iconType: 'info'
});
}
updateTrayMenu();
}
function toggleSending() {
if (isSendingPaused) resumeSending(false);
else pauseSending();
}
// ================================================================
// AUTO-UPDATER CONFIG
// ================================================================
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.logger = require('electron').app ? console : null;
// ---- Trava anti-loop de atualização --------------------------------
// Se uma instalação falha (ex.: install incompleto), o app reabre na MESMA
// versão, detecta o update de novo e reentra em loop. Aqui contamos quantas
// vezes tentamos instalar cada versão; após o limite, paramos de auto-instalar
// e avisamos o usuário para reinstalar manualmente — quebrando o loop.
const MAX_UPDATE_ATTEMPTS = 3;
const updateStatePath = () => path.join(app.getPath('userData'), 'update-state.json');
function readUpdateState() {
try {
return JSON.parse(fs.readFileSync(updateStatePath(), 'utf8')) || {};
} catch (e) {
return {};
}
}
function writeUpdateState(state) {
try {
fs.writeFileSync(updateStatePath(), JSON.stringify(state, null, 2), 'utf8');
} catch (e) {
console.error('Erro ao gravar update-state.json:', e.message);
}
}
// Chamado no boot: se a versão atual já alcançou (ou passou) a versão tentada,
// a instalação teve sucesso → zera o contador.
function reconcileUpdateState() {
const state = readUpdateState();
if (state.version && state.version === app.getVersion()) {
console.log(`✅ Atualização para v${state.version} concluída — limpando contador.`);
writeUpdateState({});
}
}
let lastUpdaterErrorAt = 0;
function notifyUpdaterError(message) {
const now = Date.now();
// Throttle: no máximo 1 balão a cada 60s para não inundar o usuário
if (now - lastUpdaterErrorAt < 60000) return;
lastUpdaterErrorAt = now;
if (tray) {
tray.displayBalloon({
title: 'ScoreOdonto - Falha na Atualização',
content: message,
iconType: 'error'
});
}
}
autoUpdater.on('checking-for-update', () => {
console.log('🔍 Verificando atualizações...');
});
autoUpdater.on('update-available', (info) => {
console.log('🔄 Nova versão disponível:', info.version);
downloadProgress = 0; // Inicializa o progresso
updateTrayMenu(); // Atualiza o menu para mostrar a aba "Baixando..."
if (tray) {
tray.displayBalloon({
title: 'Nova Atualização Encontrada',
content: `A versão ${info.version} está sendo baixada. Clique com botão direito no ícone para acompanhar o progresso!`,
iconType: 'info'
});
}
});
autoUpdater.on('update-not-available', () => {
console.log('✅ Aplicativo está na versão mais recente.');
});
autoUpdater.on('download-progress', (progress) => {
const p = Math.round(progress.percent);
console.log(`⬇️ Baixando atualização: ${p}%`);
if (downloadProgress !== p) {
downloadProgress = p;
// Atualiza a bandeja com throttling para não piscar demais
if (p % 5 === 0 || p === 100) {
updateTrayMenu();
}
}
});
autoUpdater.on('update-downloaded', (info) => {
console.log('✅ Atualização baixada:', info.version);
// --- Trava anti-loop: não reinstalar a mesma versão indefinidamente ---
const state = readUpdateState();
const attempts = (state.version === info.version ? state.attempts : 0) || 0;
if (attempts >= MAX_UPDATE_ATTEMPTS) {
console.warn(`🛑 v${info.version} já falhou ${attempts}x — abortando auto-instalação para evitar loop.`);
downloadProgress = null;
updateTrayMenu();
notifyUpdaterError(
`A atualização para a versão ${info.version} falhou ${attempts} vezes e foi interrompida. ` +
`Por favor, baixe e instale o cliente manualmente para concluir.`
);
return;
}
// Registra mais uma tentativa ANTES de reiniciar; será zerado no próximo
// boot se a versão tiver realmente avançado (reconcileUpdateState).
writeUpdateState({ version: info.version, attempts: attempts + 1 });
console.log(`↻ Reiniciando em 15s para instalar v${info.version} (tentativa ${attempts + 1}/${MAX_UPDATE_ATTEMPTS})...`);
downloadProgress = "Pronto";
if (tray) {
tray.displayBalloon({
title: 'ScoreOdonto - Atualização Pronta',
content: `Versão ${info.version} baixada. O aplicativo será reiniciado automaticamente em 15 segundos.`,
iconType: 'info'
});
}
// Contagem regressiva no tray tooltip
let countdown = 15;
const countdownInterval = setInterval(() => {
countdown--;
if (tray) tray.setToolTip(`ScoreOdonto — Reiniciando em ${countdown}s para instalar v${info.version}...`);
if (countdown <= 0) {
clearInterval(countdownInterval);
// isSilent=true (sem janela do instalador), isForceRunAfter=true (reabre após instalar)
autoUpdater.quitAndInstall(true, true);
}
}, 1000);
// Permite cancelar o restart manual pelo menu
updateTrayMenu(countdown, countdownInterval, info.version);
});
autoUpdater.on('error', (err) => {
console.error('❌ Erro no auto-updater:', err.message);
downloadProgress = null;
updateTrayMenu();
notifyUpdaterError(`Não foi possível atualizar: ${err.message}`);
});
const appDataFolder = app.getPath('userData');
const configPath = path.join(appDataFolder, 'config.json');
const configBackupPath = path.join(appDataFolder, 'config.backup.json');
// Ensure directory exists
if (!fs.existsSync(appDataFolder)) {
fs.mkdirSync(appDataFolder, { recursive: true });
}
// Default config template
const defaultConfig = {
serverUrl: "https://rx.scoreodonto.com",
monitorPath: "C:\\ProgramData\\RF\\Dental Sensor\\Images",
clientName: "DIGITE-O-NOME-DA-MAQUINA",
clientType: "windows",
apiKey: "rf-dental-secure-key-2026",
serverEmail: "",
serverPassword: "",
token: "",
adminUsername: "admin",
adminPassword: "admin"
};
// Lê e valida um JSON de config; retorna null se ausente/corrompido
function readConfigFile(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
// Só consideramos válido se for um objeto com ao menos um campo
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) {
return parsed;
}
return null;
} catch (e) {
console.error(`Error reading config file ${filePath}:`, e.message);
return null;
}
}
// Mantém uma cópia de segurança do config para não perder clientId/credenciais
// em caso de corrupção (evita reconfiguração do cliente do zero).
function writeConfigBackup(cfg) {
try {
fs.writeFileSync(configBackupPath, JSON.stringify(cfg, null, 2), 'utf8');
} catch (e) {
console.error("Error writing config backup:", e.message);
}
}
function loadConfig() {
// 1. Tenta o arquivo principal
let userConfig = readConfigFile(configPath);
// 2. Se falhar, tenta restaurar do backup (rede de segurança)
if (!userConfig) {
const backup = readConfigFile(configBackupPath);
if (backup) {
console.warn("⚠️ config.json ausente/corrompido — restaurando a partir de config.backup.json");
userConfig = backup;
try {
fs.writeFileSync(configPath, JSON.stringify(backup, null, 2), 'utf8');
} catch (e) {
console.error("Falha ao restaurar config.json do backup:", e.message);
}
}
}
// 3. Sem principal nem backup: primeira execução → grava template padrão
if (!userConfig) {
try {
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8');
} catch (e) {
console.error("Error writing default config:", e.message);
}
return defaultConfig;
}
const merged = { ...defaultConfig, ...userConfig };
// Mantém o backup atualizado sempre que carregamos um config válido
writeConfigBackup(merged);
return merged;
}
function saveConfig(newConfig) {
try {
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), 'utf8');
// Atualiza o backup logo após gravar com sucesso
writeConfigBackup(newConfig);
return true;
} catch (e) {
console.error("Error saving config:", e);
return false;
}
}
// Start background child process
function startMonitorProcess() {
if (monitorProcess) {
monitorProcess.kill();
}
const config = loadConfig();
// Don't start if it's default/placeholder
if (!config.clientName || config.clientName === defaultConfig.clientName) {
console.log("Monitor process not started: clientName not configured.");
return;
}
console.log("Starting client-monitor.js as background process...");
monitorProcess = fork(path.join(__dirname, 'client-monitor.js'), [], {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: {
...process.env,
DENTAL_CONFIG_DIR: appDataFolder
}
});
monitorProcess.on('message', (message) => {
if (message && message.event === 'status') {
currentStatus.connected = message.connected;
currentStatus.identified = message.identified;
updateTrayMenu();
} else if (message && message.event === 'log') {
recentLogs.push(message.log);
if (recentLogs.length > 100) recentLogs.shift();
if (configWindow && !configWindow.isDestroyed()) {
configWindow.webContents.send('new-log', message.log);
}
} else if (message && message.event === 'resize-image') {
try {
const img = nativeImage.createFromBuffer(Buffer.from(message.base64, 'base64'));
const thumb = img.resize({ width: 300, quality: "good" }).toJPEG(80);
monitorProcess.send({ event: 'resize-result', id: message.id, success: true, thumbBase64: thumb.toString('base64') });
} catch (e) {
monitorProcess.send({ event: 'resize-result', id: message.id, success: false, error: e.message });
}
} else if (message && message.event === 'wasabi-error') {
if (tray) {
tray.displayBalloon({
title: 'ScoreOdonto — Falha no Armazenamento',
content: message.message || 'Erro ao comunicar com o Wasabi.',
iconType: 'error'
});
}
} else if (message && message.event === 'reupload-requested') {
if (tray) {
tray.displayBalloon({
title: 'ScoreOdonto — Reenvio em Andamento',
content: `O servidor solicitou o reenvio de ${message.count} imagem(ns) ausente(s) no armazenamento. Reenviando automaticamente...`,
iconType: 'info'
});
}
} else if (message && message.event === 'force-update') {
autoUpdater.checkForUpdatesAndNotify();
}
});
monitorProcess.on('exit', (code) => {
console.log(`Monitor process exited with code ${code}`);
currentStatus.connected = false;
currentStatus.identified = false;
updateTrayMenu();
});
// Se o envio estava pausado, reaplica o estado no monitor recém-iniciado
if (isSendingPaused) {
setTimeout(() => {
if (monitorProcess) monitorProcess.send({ command: 'pause' });
}, 2500);
}
}
function createConfigWindow() {
if (configWindow) {
configWindow.focus();
return;
}
configWindow = new BrowserWindow({
width: 600,
height: 680,
minHeight: 520,
title: "ScoreOdonto Dental Client - Configuração",
icon: path.join(__dirname, 'favicon.png'),
resizable: true,
maximizable: true,
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
configWindow.loadFile(path.join(__dirname, 'config-ui.html'));
configWindow.on('closed', () => {
configWindow = null;
});
}
let pendingCountdownInterval = null;
function updateTrayMenu(countdown = null, countdownInterval = null, updateVersion = null) {
if (!tray) return;
// Salva o interval ativo para poder cancelar
if (countdownInterval !== null) pendingCountdownInterval = countdownInterval;
let statusText = "ScoreOdonto: Desconectado 🔴";
if (currentStatus.connected) {
statusText = currentStatus.identified
? "ScoreOdonto: Identificado 🟢"
: "ScoreOdonto: Conectado... 🟡";
}
const template = [
{ label: statusText, enabled: false },
{ type: 'separator' }
];
// Se houver um download em andamento, mostra a barra no menu
if (downloadProgress !== null) {
if (downloadProgress === "Pronto") {
const countdownLabel = countdown !== null
? `⏱️ Reiniciando em ${countdown}s para instalar v${updateVersion}...`
: '📦 Atualização pronta para instalar';
template.push({ label: countdownLabel, enabled: false });
template.push({
label: '🚀 Instalar Agora (Reiniciar)',
click: () => {
if (pendingCountdownInterval) clearInterval(pendingCountdownInterval);
autoUpdater.quitAndInstall(true, true);
}
});
} else {
template.push({ label: `⬇️ Baixando atualização: ${downloadProgress}%`, enabled: false });
}
template.push({ type: 'separator' });
}
template.push(
{
label: isSendingPaused ? '⏸️ Envio PAUSADO (retoma em ~10 min)' : '▶️ Envio Ativo',
enabled: false
},
{
label: 'Pausar Envio de Imagens',
type: 'checkbox',
checked: isSendingPaused,
click: () => toggleSending()
},
{ type: 'separator' },
{
label: 'Forçar Sincronização 🔄',
click: () => {
if (isSendingPaused) {
resumeSending(false);
}
if (monitorProcess) {
monitorProcess.send({ command: 'sync' });
} else {
startMonitorProcess();
}
}
},
{
label: 'Verificar Atualizações 🔄',
click: () => {
autoUpdater.checkForUpdatesAndNotify();
}
},
{
label: 'Configurações ⚙️',
click: () => {
createConfigWindow();
}
},
{
label: 'Abrir Pasta de Imagens 📂',
click: () => {
const config = loadConfig();
if (fs.existsSync(config.monitorPath)) {
shell.openPath(config.monitorPath);
} else {
shell.openPath(path.dirname(config.monitorPath));
}
}
},
{ type: 'separator' },
{
label: 'Sair 🚪',
click: () => {
app.isQuitting = true;
app.quit();
}
}
);
const contextMenu = Menu.buildFromTemplate(template);
tray.setContextMenu(contextMenu);
tray.setToolTip(`ScoreOdonto Dental Client\n${statusText}`);
}
function initTray() {
const iconPath = path.join(__dirname, 'favicon.png');
tray = new Tray(iconPath);
updateTrayMenu();
tray.on('double-click', () => {
createConfigWindow();
});
}
// Single instance lock
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
return;
}
app.on('second-instance', () => {
if (configWindow) {
if (configWindow.isMinimized()) configWindow.restore();
configWindow.focus();
} else {
createConfigWindow();
}
});
app.whenReady().then(() => {
initTray();
// Se a versão atual já é a que tentávamos instalar, a atualização teve
// sucesso → zera o contador anti-loop antes de checar novamente.
reconcileUpdateState();
// Verificar atualizações silenciosamente ao iniciar
autoUpdater.checkForUpdatesAndNotify();
const config = loadConfig();
// If not configured, show config UI
if (!config.clientName || config.clientName === defaultConfig.clientName) {
createConfigWindow();
} else {
// Start background process
startMonitorProcess();
}
});
app.on('window-all-closed', () => {
// Keep running in tray on close
if (process.platform !== 'darwin') {
// do nothing, let it run in tray
}
});
app.on('before-quit', () => {
if (monitorProcess) {
monitorProcess.kill();
}
});
// IPC communication
ipcMain.handle('get-config', () => {
return loadConfig();
});
ipcMain.handle('get-version', () => {
return app.getVersion();
});
ipcMain.handle('get-logs', () => {
return recentLogs;
});
ipcMain.handle('save-config', (event, newConfig) => {
const success = saveConfig(newConfig);
if (success) {
// Restart child process with new settings
startMonitorProcess();
}
return success;
});
ipcMain.handle('test-server', async (event, url) => {
return new Promise((resolve) => {
try {
const checkUrl = url.endsWith('/') ? `${url}status` : `${url}/status`;
const clientModule = checkUrl.startsWith('https') ? require('https') : require('http');
const req = clientModule.get(checkUrl, { timeout: 4000 }, (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ success: true, message: `Conexão bem sucedida! Código: ${res.statusCode}` });
} else {
resolve({ success: false, error: `Código de status inesperado: ${res.statusCode}` });
}
});
req.on('error', (err) => {
resolve({ success: false, error: err.message });
});
req.on('timeout', () => {
req.destroy();
resolve({ success: false, error: 'Timeout de 4 segundos esgotado.' });
});
} catch (e) {
resolve({ success: false, error: e.message });
}
});
});
ipcMain.handle('verify-credentials', async (event, url, email, password, token) => {
return new Promise((resolve) => {
try {
let base = url.trim();
if (base.endsWith('/')) {
base = base.slice(0, -1);
}
const verifyUrl = `${base}/api/auth/verify-client-credentials`;
const clientModule = verifyUrl.startsWith('https') ? require('https') : require('http');
const parsedUrl = new URL(verifyUrl);
const postData = JSON.stringify({ email, password, token });
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (verifyUrl.startsWith('https') ? 443 : 80),
path: parsedUrl.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
},
timeout: 8000
};
const req = clientModule.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
try {
const data = JSON.parse(body);
if (res.statusCode >= 200 && res.statusCode < 300 && data.success) {
resolve({ success: true, clinic: data.clinic });
} else {
resolve({ success: false, error: data.error || `Erro de validação: ${res.statusCode}` });
}
} catch (e) {
resolve({ success: false, error: `Resposta inválida do servidor: ${e.message}` });
}
});
});
req.on('error', (err) => {
resolve({ success: false, error: `Falha de rede: ${err.message}` });
});
req.on('timeout', () => {
req.destroy();
resolve({ success: false, error: 'Tempo limite esgotado ao tentar validar credenciais (8s).' });
});
req.write(postData);
req.end();
} catch (e) {
resolve({ success: false, error: `Erro interno: ${e.message}` });
}
});
});
ipcMain.handle('open-folder', () => {
const config = loadConfig();
const targetPath = config.monitorPath;
if (!fs.existsSync(targetPath)) {
fs.mkdirSync(targetPath, { recursive: true });
}
shell.openPath(targetPath);
return true;
});
ipcMain.on('close-window', () => {
if (configWindow) {
configWindow.close();
}
});