From 45b4c2215c4c32f8aa62e27e63fae63a94cf7a15 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Sun, 31 May 2026 05:05:36 +0200 Subject: [PATCH] =?UTF-8?q?fix(client):=20v1.3.0=20=E2=80=94=20anti-loop?= =?UTF-8?q?=20updater,=20config=20backup,=20NSIS=20safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Itens 0, 3 e 4 do plano de correção: ITEM 0 — Preservar dados na atualização (não reconfigurar do zero) - index.js: loadConfig/saveConfig grava config.backup.json a cada gravação válida; ao iniciar, restaura do backup se config.json sumir ou corromper — protege clientId e credenciais. - client-monitor.js: nova persistConfig() grava config + backup juntos; aplicada nos 3 pontos que escrevem o config (clientId init, painel local, wizard CLI). - package.json: deleteAppDataOnUninstall: false explícito. ITEM 3 — Duplicatas na lista de apps do Windows - Mantido perMachine: false consistente; GUID derivado do appId (não adicionado explícito para não criar 3ª entrada). - deleteAppDataOnUninstall: false garante AppData intacto. ITEM 4 — Loop de notificação de nova versão - Trava anti-loop: conta tentativas por versão em update-state.json; após 3 falhas da mesma versão, para de auto-instalar e notifica balão com mensagem para reinstalar manualmente. - reconcileUpdateState() no boot zera o contador quando a versão realmente avançou (instalação bem-sucedida). - autoUpdater.on('error') agora exibe balão com erro real (throttle 60s). Co-Authored-By: Claude Sonnet 4.6 --- client-monitor.js | 43 ++++++++++++-- index.js | 141 +++++++++++++++++++++++++++++++++++++++++++--- package.json | 3 +- 3 files changed, 171 insertions(+), 16 deletions(-) diff --git a/client-monitor.js b/client-monitor.js index aec177c..52e13c9 100644 --- a/client-monitor.js +++ b/client-monitor.js @@ -41,11 +41,42 @@ if (!fs.existsSync(appDataFolder)) { // Carregar configuração dinamicamente para poder atualizá-la const configPath = path.join(appDataFolder, 'config.json'); +const configBackupPath = path.join(appDataFolder, 'config.backup.json'); + +// Lê e valida um JSON de config; retorna null se ausente/corrompido +function readConfigFileSafe(filePath) { + try { + if (!fs.existsSync(filePath)) return null; + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) { + return parsed; + } + return null; + } catch (e) { + return null; + } +} + +// Grava config.json + cópia de segurança (preserva clientId/credenciais) +function persistConfig() { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + try { + fs.writeFileSync(configBackupPath, JSON.stringify(config, null, 2)); + } catch (e) { + console.error('Erro ao gravar backup de config:', e.message); + } +} + let config = {}; let configChanged = false; -try { - config = JSON.parse(fs.readFileSync(configPath, 'utf8')); -} catch (e) { +// Tenta principal e, se faltar/corromper, restaura do backup antes de cair no default +config = readConfigFileSafe(configPath) || readConfigFileSafe(configBackupPath); +if (config) { + if (!fs.existsSync(configPath)) { + // Restaurado do backup: regrava o principal + configChanged = true; + } +} else { config = { serverUrl: "https://rx.scoreodonto.com", monitorPath: "C:\\ProgramData\\RF\\Dental Sensor\\Images", @@ -67,7 +98,7 @@ if (!config.clientId) { if (configChanged) { try { - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + persistConfig(); } catch (err) { console.error('Erro ao inicializar clientId no config.json:', err.message); } @@ -858,7 +889,7 @@ function startAdminServer() { config.adminPassword = newConfig.adminPassword; try { - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + persistConfig(); addLog(`💾 Novas configurações persistidas em config.json`); } catch (writeErr) { res.writeHead(500, { 'Content-Type': 'application/json' }); @@ -1493,7 +1524,7 @@ function runSetupWizard() { config.clientName = chosenName; try { - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + persistConfig(); console.log('═══════════════════════════════════════════════════════'); console.log('✅ CONFIGURAÇÃO SALVA COM SUCESSO EM config.json!'); console.log(` Servidor Destino: ${config.serverUrl}`); diff --git a/index.js b/index.js index 4d0f910..e89a7ed 100644 --- a/index.js +++ b/index.js @@ -66,6 +66,55 @@ 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...'); }); @@ -100,7 +149,28 @@ autoUpdater.on('download-progress', (progress) => { }); autoUpdater.on('update-downloaded', (info) => { - console.log('✅ Atualização baixada:', info.version, '— reiniciando em 15 segundos...'); + 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) { @@ -131,10 +201,12 @@ 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)) { @@ -155,24 +227,71 @@ const defaultConfig = { adminPassword: "admin" }; -function loadConfig() { +// Lê e valida um JSON de config; retorna null se ausente/corrompido +function readConfigFile(filePath) { try { - if (!fs.existsSync(configPath)) { - fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8'); - return defaultConfig; + 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; } - const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); - // Ensure required fields exist - return { ...defaultConfig, ...userConfig }; + return null; } catch (e) { - console.error("Error loading config:", 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); @@ -401,6 +520,10 @@ app.on('second-instance', () => { 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(); diff --git a/package.json b/package.json index 617277b..8889b37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rx.scoreodonto.com-client", - "version": "1.2.9", + "version": "1.3.0", "author": "ScoreOdonto", "description": "Cliente Windows para envio de imagens dentais da ScoreOdonto", "main": "index.js", @@ -23,6 +23,7 @@ "nsis": { "oneClick": true, "perMachine": false, + "deleteAppDataOnUninstall": false, "allowToChangeInstallationDirectory": false, "createDesktopShortcut": true, "createStartMenuShortcut": true,