fix(client): v1.3.0 — anti-loop updater, config backup, NSIS safe

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-05-31 05:05:36 +02:00
parent b23114ace2
commit 45b4c2215c
3 changed files with 171 additions and 16 deletions
+37 -6
View File
@@ -41,11 +41,42 @@ if (!fs.existsSync(appDataFolder)) {
// Carregar configuração dinamicamente para poder atualizá-la // Carregar configuração dinamicamente para poder atualizá-la
const configPath = path.join(appDataFolder, 'config.json'); 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 config = {};
let configChanged = false; let configChanged = false;
try { // Tenta principal e, se faltar/corromper, restaura do backup antes de cair no default
config = JSON.parse(fs.readFileSync(configPath, 'utf8')); config = readConfigFileSafe(configPath) || readConfigFileSafe(configBackupPath);
} catch (e) { if (config) {
if (!fs.existsSync(configPath)) {
// Restaurado do backup: regrava o principal
configChanged = true;
}
} else {
config = { config = {
serverUrl: "https://rx.scoreodonto.com", serverUrl: "https://rx.scoreodonto.com",
monitorPath: "C:\\ProgramData\\RF\\Dental Sensor\\Images", monitorPath: "C:\\ProgramData\\RF\\Dental Sensor\\Images",
@@ -67,7 +98,7 @@ if (!config.clientId) {
if (configChanged) { if (configChanged) {
try { try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); persistConfig();
} catch (err) { } catch (err) {
console.error('Erro ao inicializar clientId no config.json:', err.message); console.error('Erro ao inicializar clientId no config.json:', err.message);
} }
@@ -858,7 +889,7 @@ function startAdminServer() {
config.adminPassword = newConfig.adminPassword; config.adminPassword = newConfig.adminPassword;
try { try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); persistConfig();
addLog(`💾 Novas configurações persistidas em config.json`); addLog(`💾 Novas configurações persistidas em config.json`);
} catch (writeErr) { } catch (writeErr) {
res.writeHead(500, { 'Content-Type': 'application/json' }); res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -1493,7 +1524,7 @@ function runSetupWizard() {
config.clientName = chosenName; config.clientName = chosenName;
try { try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); persistConfig();
console.log('═══════════════════════════════════════════════════════'); console.log('═══════════════════════════════════════════════════════');
console.log('✅ CONFIGURAÇÃO SALVA COM SUCESSO EM config.json!'); console.log('✅ CONFIGURAÇÃO SALVA COM SUCESSO EM config.json!');
console.log(` Servidor Destino: ${config.serverUrl}`); console.log(` Servidor Destino: ${config.serverUrl}`);
+132 -9
View File
@@ -66,6 +66,55 @@ autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.logger = require('electron').app ? console : null; 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', () => { autoUpdater.on('checking-for-update', () => {
console.log('🔍 Verificando atualizações...'); console.log('🔍 Verificando atualizações...');
}); });
@@ -100,7 +149,28 @@ autoUpdater.on('download-progress', (progress) => {
}); });
autoUpdater.on('update-downloaded', (info) => { 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"; downloadProgress = "Pronto";
if (tray) { if (tray) {
@@ -131,10 +201,12 @@ autoUpdater.on('error', (err) => {
console.error('❌ Erro no auto-updater:', err.message); console.error('❌ Erro no auto-updater:', err.message);
downloadProgress = null; downloadProgress = null;
updateTrayMenu(); updateTrayMenu();
notifyUpdaterError(`Não foi possível atualizar: ${err.message}`);
}); });
const appDataFolder = app.getPath('userData'); const appDataFolder = app.getPath('userData');
const configPath = path.join(appDataFolder, 'config.json'); const configPath = path.join(appDataFolder, 'config.json');
const configBackupPath = path.join(appDataFolder, 'config.backup.json');
// Ensure directory exists // Ensure directory exists
if (!fs.existsSync(appDataFolder)) { if (!fs.existsSync(appDataFolder)) {
@@ -155,24 +227,71 @@ const defaultConfig = {
adminPassword: "admin" adminPassword: "admin"
}; };
function loadConfig() { // Lê e valida um JSON de config; retorna null se ausente/corrompido
function readConfigFile(filePath) {
try { try {
if (!fs.existsSync(configPath)) { if (!fs.existsSync(filePath)) return null;
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8'); const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
return defaultConfig; // 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')); return null;
// Ensure required fields exist
return { ...defaultConfig, ...userConfig };
} catch (e) { } 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; 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) { function saveConfig(newConfig) {
try { try {
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), 'utf8'); fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), 'utf8');
// Atualiza o backup logo após gravar com sucesso
writeConfigBackup(newConfig);
return true; return true;
} catch (e) { } catch (e) {
console.error("Error saving config:", e); console.error("Error saving config:", e);
@@ -401,6 +520,10 @@ app.on('second-instance', () => {
app.whenReady().then(() => { app.whenReady().then(() => {
initTray(); 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 // Verificar atualizações silenciosamente ao iniciar
autoUpdater.checkForUpdatesAndNotify(); autoUpdater.checkForUpdatesAndNotify();
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "rx.scoreodonto.com-client", "name": "rx.scoreodonto.com-client",
"version": "1.2.9", "version": "1.3.0",
"author": "ScoreOdonto", "author": "ScoreOdonto",
"description": "Cliente Windows para envio de imagens dentais da ScoreOdonto", "description": "Cliente Windows para envio de imagens dentais da ScoreOdonto",
"main": "index.js", "main": "index.js",
@@ -23,6 +23,7 @@
"nsis": { "nsis": {
"oneClick": true, "oneClick": true,
"perMachine": false, "perMachine": false,
"deleteAppDataOnUninstall": false,
"allowToChangeInstallationDirectory": false, "allowToChangeInstallationDirectory": false,
"createDesktopShortcut": true, "createDesktopShortcut": true,
"createStartMenuShortcut": true, "createStartMenuShortcut": true,