const io = require('socket.io-client'); const chokidar = require('chokidar'); const fs = require('fs'); const path = require('path'); const readline = require('readline'); const http = require('http'); const { exec } = require('child_process'); // Função para gerar o thumbnail usando a CPU local do cliente (via IPC com o index.js do Electron) async function gerarThumbnail(buffer) { return new Promise((resolve) => { if (!process.send) return resolve(null); // Fallback se não for fork const id = Date.now().toString() + Math.random().toString(); const timeout = setTimeout(() => resolve(null), 5000); const listener = (message) => { if (message && message.event === 'resize-result' && message.id === id) { clearTimeout(timeout); process.removeListener('message', listener); if (message.success) resolve(Buffer.from(message.thumbBase64, 'base64')); else resolve(null); } }; process.on('message', listener); process.send({ event: 'resize-image', id, base64: buffer.toString('base64') }); }); } // Resolve dynamic folder in AppData or default path passed by Electron const appDataFolder = process.env.DENTAL_CONFIG_DIR || path.join( process.env.APPDATA || (process.platform === 'darwin' ? path.join(process.env.HOME, 'Library', 'Application Support') : path.join(process.env.HOME, '.config')), 'ScoreOdonto Dental Client' ); if (!fs.existsSync(appDataFolder)) { fs.mkdirSync(appDataFolder, { recursive: true }); } // Carregar configuração dinamicamente para poder atualizá-la const configPath = path.join(appDataFolder, 'config.json'); let config = {}; let configChanged = false; try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (e) { config = { serverUrl: "https://rx.scoreodonto.com", monitorPath: "C:\\ProgramData\\RF\\Dental Sensor\\Images", clientName: "COMPUTADOR-LOCAL", clientType: "windows", apiKey: "rf-dental-secure-key-2026", adminUsername: "admin", adminPassword: "admin" }; configChanged = true; } // Garantir que temos um clientId exclusivo para este computador if (!config.clientId) { const crypto = require('crypto'); config.clientId = crypto.randomUUID ? crypto.randomUUID() : 'client_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); configChanged = true; } if (configChanged) { try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); } catch (err) { console.error('Erro ao inicializar clientId no config.json:', err.message); } } // Função para verificar se o nome do cliente está disponível no servidor remoto async function checkClientNameAvailability(serverUrl, clientName, apiKey, clientId) { return new Promise((resolve) => { try { let base = serverUrl.trim(); if (base.endsWith('/')) { base = base.slice(0, -1); } const checkUrl = `${base}/api/clients/check-name?name=${encodeURIComponent(clientName.trim())}&token=${encodeURIComponent(apiKey || '')}&clientId=${encodeURIComponent(clientId || '')}`; const clientModule = checkUrl.startsWith('https') ? require('https') : require('http'); const req = clientModule.get(checkUrl, { timeout: 6000 }, (res) => { let body = ''; res.on('data', chunk => body += chunk); res.on('end', () => { try { if (res.statusCode === 200) { const data = JSON.parse(body); if (data.success) { resolve({ success: true, available: data.available }); return; } } const errMsg = res.statusCode === 401 ? 'Chave de API inválida' : `Código HTTP ${res.statusCode}`; resolve({ success: false, error: errMsg }); } catch (e) { resolve({ success: false, error: `Erro de parse: ${e.message}` }); } }); }); req.on('error', (err) => { resolve({ success: false, error: err.message }); }); req.on('timeout', () => { req.destroy(); resolve({ success: false, error: 'Tempo limite esgotado' }); }); } catch (error) { resolve({ success: false, error: error.message }); } }); } // Variáveis de estado global let socket; let isConnected = false; let isIdentified = false; let watcher; function sendStatusToParent() { if (process.send) { process.send({ event: 'status', connected: isConnected, identified: isIdentified }); } } let isSyncing = false; const recentLogs = []; // Instancia única global do banco local para evitar sobrecarga de I/O let localDbInstance = null; function getLocalDb() { if (!localDbInstance) { try { const { DatabaseSync } = require('node:sqlite'); const dbPath = path.join('C:', 'ProgramData', 'RF', 'Dental Sensor', 'data'); if (fs.existsSync(dbPath)) { localDbInstance = new DatabaseSync(dbPath); } } catch (e) { console.error('Erro ao abrir banco local:', e.message); } } return localDbInstance; } // Cache local de uploads concluídos para evitar reenvio de arquivos no scan inicial const uploadCachePath = path.join(appDataFolder, 'uploaded_cache.json'); let uploadedCache = new Set(); try { if (fs.existsSync(uploadCachePath)) { const cachedFiles = JSON.parse(fs.readFileSync(uploadCachePath, 'utf8')); uploadedCache = new Set(cachedFiles); console.log(`💾 Cache de uploads carregado com ${uploadedCache.size} arquivos.`); } } catch (e) { console.error('Erro ao carregar cache de uploads:', e.message); } function saveUploadCache() { try { fs.writeFileSync(uploadCachePath, JSON.stringify([...uploadedCache], null, 2)); } catch (e) { console.error('Erro ao salvar cache de uploads:', e.message); } } // Função auxiliar para registrar logs locais e expor no painel function addLog(msg) { const timestamp = new Date().toLocaleTimeString(); const formatted = `[${timestamp}] ${msg}`; console.log(formatted); recentLogs.push(formatted); if (recentLogs.length > 50) { recentLogs.shift(); } if (process.send) { process.send({ event: 'log', log: formatted }); } } // Interface Web de Administração em HTML/CSS (zero dependências) function getAdminHtml() { return ` Dental Client - Painel
Carregando...
⚙️ Configurações do Cliente
📊 Status do Sistema
Sistema -
Uptime -
Socket ID -
🛠️ Atalhos
📜 Atividades Recentes
Carregando logs...
`; } function startAdminServer() { const server = http.createServer((req, res) => { // Habilitar CORS res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } // Parse da URL const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); // Rota pública: Favicon if (parsedUrl.pathname === '/favicon.png' && req.method === 'GET') { const faviconPath = path.join(__dirname, 'favicon.png'); if (fs.existsSync(faviconPath)) { res.writeHead(200, { 'Content-Type': 'image/png' }); res.end(fs.readFileSync(faviconPath)); } else { res.writeHead(404); res.end(); } return; } // --- PROTEÇÃO DO PAINEL LOCAL (Basic Auth) --- const authHeader = req.headers['authorization']; if (config.adminPassword && config.adminPassword.trim() !== '') { if (!authHeader) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Dental Client Admin"' }); res.end('Acesso Negado'); return; } const authStr = Buffer.from(authHeader.split(' ')[1], 'base64').toString(); const [user, pass] = authStr.split(':'); // Autenticamos apenas pela senha, o usuário pode ser admin ou vazio if (pass !== config.adminPassword) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Dental Client Admin"' }); res.end('Acesso Negado'); return; } } // --------------------------------------------- if (parsedUrl.pathname === '/' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(getAdminHtml()); } else if (parsedUrl.pathname === '/api/status' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ connected: isConnected, identified: isIdentified, socketId: socket ? socket.id : null, config: config, logs: recentLogs, platform: process.platform, uptime: process.uptime() })); } else if (parsedUrl.pathname === '/api/config' && req.method === 'POST') { let body = ''; req.on('data', chunk => body += chunk); req.on('end', async () => { try { const newConfig = JSON.parse(body); if (!newConfig.serverUrl || !newConfig.monitorPath || !newConfig.clientName || !newConfig.apiKey || !newConfig.adminPassword) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: 'Campos obrigatórios ausentes.' })); return; } // Verificar se o nome mudou ou se o servidor mudou para checar disponibilidade if (newConfig.clientName !== config.clientName || newConfig.serverUrl !== config.serverUrl) { addLog(`🔍 Verificando se o nome "${newConfig.clientName}" está disponível no servidor...`); const check = await checkClientNameAvailability(newConfig.serverUrl, newConfig.clientName, newConfig.apiKey, config.clientId); if (check.success && !check.available) { addLog(`❌ O nome "${newConfig.clientName}" já está em uso no servidor.`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: `O nome "${newConfig.clientName}" já está sendo usado por outro computador neste servidor. Escolha um nome exclusivo.` })); return; } } // Gravar no config.json config.serverUrl = newConfig.serverUrl; config.monitorPath = newConfig.monitorPath; config.clientName = newConfig.clientName; config.apiKey = newConfig.apiKey; config.adminPassword = newConfig.adminPassword; try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); addLog(`💾 Novas configurações persistidas em config.json`); } catch (writeErr) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: 'Erro ao salvar config.json: ' + writeErr.message })); return; } // Aplicar dinamicamente reconnectAndRestart(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: e.message })); } }); } else if (parsedUrl.pathname === '/api/test-server' && req.method === 'POST') { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { try { const data = JSON.parse(body); const testUrl = data.url; if (!testUrl) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: 'URL inválida.' })); return; } const checkUrl = testUrl.endsWith('/') ? `${testUrl}status` : `${testUrl}/status`; const clientModule = checkUrl.startsWith('https') ? require('https') : require('http'); addLog(`⚡ Testando conectividade com: ${checkUrl}`); const testReq = clientModule.get(checkUrl, { timeout: 4000 }, (testRes) => { let resBody = ''; testRes.on('data', chunk => resBody += chunk); testRes.on('end', () => { if (testRes.statusCode >= 200 && testRes.statusCode < 300) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, message: `Conexão bem sucedida! Código: ${testRes.statusCode}` })); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: `Código de status inesperado: ${testRes.statusCode}` })); } }); }); testReq.on('error', (err) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: err.message })); }); testReq.on('timeout', () => { testReq.destroy(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: 'Timeout de 4 segundos esgotado.' })); }); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: e.message })); } }); } else if (parsedUrl.pathname === '/api/open-folder' && req.method === 'POST') { try { const normalizedPath = path.normalize(config.monitorPath); if (!fs.existsSync(normalizedPath)) { fs.mkdirSync(normalizedPath, { recursive: true }); } exec(`explorer.exe "${normalizedPath}"`); addLog(`📁 Pasta de imagens aberta no Explorer: ${normalizedPath}`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: e.message })); } } else { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); } }); server.listen(3001, '127.0.0.1', () => { addLog('🌐 Painel de Administração iniciado em: http://localhost:3001'); }); } // Aplicar alterações do painel dinamicamente function reconnectAndRestart() { addLog('🔄 Aplicando novas configurações dinamicamente...'); // 1. Fechar conexão socket anterior if (socket) { addLog('🔌 Desconectando socket ativo...'); socket.disconnect(); socket.removeAllListeners(); } isConnected = false; isIdentified = false; sendStatusToParent(); // 2. Parar watcher antigo if (watcher) { addLog('👀 Interrompendo monitoramento anterior...'); watcher.close(); } // 3. Conectar novamente com novas definições connectToServer(); // 4. Reiniciar monitoramento setTimeout(() => { startMonitoring(); }, 1000); } // Função de Conexão com o Servidor Dental (Socket.IO) function connectToServer() { const serverUrl = process.env.SERVER_URL || config.serverUrl; const apiKey = process.env.CLIENT_API_KEY || config.apiKey; const serverEmail = config.serverEmail || ''; const serverPassword = config.serverPassword || ''; const token = config.token || apiKey || ''; addLog(`🔄 Conectando ao servidor: ${serverUrl}...`); socket = io(serverUrl, { transports: ['websocket', 'polling'], upgrade: true, timeout: 20000, reconnection: true, reconnectionDelay: 2000, reconnectionAttempts: Infinity, forceNew: true, auth: { token: token, email: serverEmail, password: serverPassword } }); socket.on('connect_error', (err) => { addLog(`❌ Erro de Conexão: ${err.message}`); if (err.message.includes('Authentication error')) { addLog(`🛑 VERIFIQUE A CHAVE DA API! Acesso negado pelo servidor.`); } }); socket.on('connect', () => { addLog('✅ Conectado ao servidor Socket.IO com sucesso!'); isConnected = true; sendStatusToParent(); setTimeout(() => identifyClient(), 500); }); socket.on('disconnect', (reason) => { addLog(`❌ Desconectado do servidor. Motivo: ${reason}`); isConnected = false; isIdentified = false; sendStatusToParent(); }); socket.on('connection-ack', (data) => { addLog(`✅ Confirmação de socket recebida. Socket ID: ${data.socketId}`); }); socket.on('please-identify', () => { addLog('⚠️ Servidor solicitou identificação do cliente.'); identifyClient(); }); socket.on('server-identified', (data) => { addLog(`✅ Identificação confirmada pelo servidor para o cliente: ${data.clientInfo?.name}`); isIdentified = true; sendStatusToParent(); }); // Ouvinte para o Sinal de Sincronização Remota (Enviado pelo Painel Web) socket.on('sync-request', async (data) => { addLog(`🔄 [Sincronização] Sinal recebido do servidor. Timestamp do sinal: ${data?.timestamp || 'N/A'}`); try { addLog('🚀 Iniciando varredura local de imagens do sensor...'); await executarSincronizacaoLocal(); } catch (error) { addLog(`❌ Erro durante o processamento da sincronização remota: ${error.message}`); } }); socket.on('connect_error', (error) => { addLog(`❌ Erro de conexão Socket.IO: ${error.message}`); }); socket.on('error', (error) => { addLog(`❌ Erro no socket: ${error.message}`); }); socket.on('image-received', (data) => { addLog(`✅ Imagem processada e salva no servidor! ID: ${data.imageId}, Arquivo: ${data.filename}`); if (data.originalFilename) { uploadedCache.add(data.originalFilename); saveUploadCache(); } }); socket.on('test-connection', (data) => { addLog(`🧪 Recebido teste de conexão do servidor. Test ID: ${data?.testId}`); const startTime = data?.timestamp || Date.now(); const latency = Date.now() - startTime; socket.emit('test-connection-ack', { testId: data?.testId, socketId: socket.id, latency: latency, timestamp: new Date().toISOString(), message: 'Teste de conexão respondido pelo cliente' }); }); } // Função para Identificar o Cliente function identifyClient() { if (!isConnected || isIdentified) return; addLog(`👤 Enviando identificação: Nome=${config.clientName || 'Notebook Consultório Principal'}, Tipo=windows`); socket.emit('client-identify', { type: 'windows', // MANDATÓRIO: Identifica o notebook na Área de Sincronização name: config.clientName || 'Notebook Consultório Principal', // Nome amigável que aparecerá no painel clinicName: config.clinicName || '', // Nome da clínica (preenchido pelo servidor via machine_token auth) version: '1.1.0', system: 'windows', path: config.monitorPath, clientId: config.clientId // Identificador exclusivo }); // Reenviar identificação em 3 segundos se não confirmado setTimeout(() => { if (!isIdentified && isConnected) { addLog('⚠️ Confirmação de identificação pendente. Tentando novamente...'); identifyClient(); } }, 3000); } // Função para executar a varredura local e sincronização remota async function executarSincronizacaoLocal() { if (isSyncing) { addLog('⚠️ Sincronização local já está em execução. Ignorando nova chamada concorrente.'); return; } if (isSendingPaused) { addLog('⏸️ Envio pausado — sincronização ignorada. Clique em "Continuar Envio".'); return; } isSyncing = true; const pastaImagensSensor = config.monitorPath; if (!fs.existsSync(pastaImagensSensor)) { addLog(`⚠️ Pasta de imagens não encontrada no caminho: ${pastaImagensSensor}`); isSyncing = false; return; } try { const arquivos = fs.readdirSync(pastaImagensSensor); const imagens = arquivos.filter(file => /\.(png|jpg|jpeg)$/i.test(file)); addLog(`🔎 Encontradas ${imagens.length} imagens locais no total. Verificando pendências com o servidor...`); let processadas = 0; for (const file of imagens) { if (isSendingPaused) { addLog(`⏸️ Envio pausado durante a sincronização — interrompendo. ${processadas} já processadas.`); break; } const filePath = path.join(pastaImagensSensor, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { continue; } try { // sendImageToServer envia o arquivo e valida no servidor via check-image-exists, ignorando o cache local. // Se a imagem existir no servidor, ela será adicionada ao cache e não será reenviada. await sendImageToServer(filePath, true, true); // isBacklog = true, bypassCache = true processadas++; } catch (err) { addLog(`❌ Falha ao processar imagem ${file} na sincronização: ${err.message}`); } } addLog(`✅ Sincronização concluída. ${processadas} imagens locais processadas.`); } catch (e) { addLog(`❌ Erro durante a varredura local de imagens: ${e.message}`); } finally { isSyncing = false; } } // Fila para processar uploads de forma sequencial const uploadQueue = []; let isProcessingQueue = false; let isSendingPaused = false; // Controle de pausa/continuação do envio async function processQueue() { if (isProcessingQueue) return; if (isSendingPaused) { addLog(`⏸️ Envio pausado — ${uploadQueue.length} imagem(ns) aguardando na fila.`); return; } isProcessingQueue = true; while (uploadQueue.length > 0 && !isSendingPaused) { const item = uploadQueue.shift(); const nextPath = item.filePath; const isBacklog = item.isBacklog; try { await sendImageToServer(nextPath, isBacklog); } catch (e) { addLog(`⚠️ Falha ao processar upload de ${path.basename(nextPath)}: ${e.message}`); } } isProcessingQueue = false; if (isSendingPaused && uploadQueue.length > 0) { addLog(`⏸️ Envio pausado — ${uploadQueue.length} imagem(ns) permanecem na fila.`); } } // Função de Monitoramento da Pasta de Imagens (Chokidar) function startMonitoring() { const targetDir = config.monitorPath; if (!fs.existsSync(targetDir)) { addLog(`💡 Criando pasta de monitoramento: ${targetDir}`); fs.mkdirSync(targetDir, { recursive: true }); } addLog(`👀 Iniciando monitoramento de arquivos PNG em: ${targetDir}`); let isReady = false; watcher = chokidar.watch(path.join(targetDir, '**/*.png'), { persistent: true, ignoreInitial: false, awaitWriteFinish: { stabilityThreshold: 1500, // aumentar estabilidade para raios-x grandes pollInterval: 500 } }); watcher.on('add', (filePath) => { // Ignorar pastas internas do servidor se estiver na raiz errada if (filePath.includes('dental-server')) return; uploadQueue.push({ filePath, isBacklog: !isReady }); processQueue(); }); watcher.on('ready', () => { isReady = true; addLog('✅ Monitoramento Chokidar pronto e aguardando novos raios-X!'); }); watcher.on('error', (error) => { addLog(`❌ Erro no Chokidar: ${error.message}`); }); } // Função para buscar dados do paciente no banco SQLite local do Dental Sensor function lookupPatientInfo(fileName) { try { const db = getLocalDb(); if (!db) { return null; } // Tentar busca exata pelo nome do arquivo let img = db.prepare("SELECT * FROM Images WHERE LOWER(FileName) = LOWER(?)").get(fileName); // Se não encontrar, tentar busca por prefixo (primeiros 10 caracteres do GUID) if (!img) { const baseName = fileName.replace(/\.png$/i, '').toLowerCase(); if (baseName.length >= 10) { const prefix = baseName.substring(0, 10); img = db.prepare("SELECT * FROM Images WHERE FileName LIKE ? LIMIT 1").get(`%${prefix}%`); } } if (img) { let photographTime = img.PhotographTime; if (photographTime && photographTime.includes('.')) { photographTime = photographTime.split('.')[0]; } const patient = db.prepare("SELECT * FROM Patients WHERE Id = ?").get(img.PatientId); const firstName = patient ? (patient.FirstName || '').trim() : ''; const lastName = patient ? (patient.LastName || '').trim() : ''; const fullName = `${firstName} ${lastName}`.trim() || 'Sem nome'; const doctor = patient ? (patient.Doctor || '').trim() || 'Sem médico' : 'Sem médico'; const remark = patient ? (patient.Remark || '').trim() || 'Sem observação' : 'Sem observação'; return { name: fullName, doctor, remark, photographTime }; } } catch (e) { addLog(`⚠️ Erro ao buscar dados do paciente no banco local: ${e.message}`); } return null; } // Enviar imagem para o servidor via Socket.IO e aguardar confirmação function sendImageToServer(filePath, isBacklog, bypassCache = false) { return new Promise(async (resolve, reject) => { try { if (!isConnected) { addLog(`⚠️ Falha ao enviar: Sem conexão com o servidor.`); resolve(); return; } if (!isIdentified) { identifyClient(); await new Promise(r => setTimeout(r, 1200)); } const fileName = path.basename(filePath); if (!bypassCache && uploadedCache.has(fileName)) { resolve(); return; } // Verificar no servidor se a imagem já existe antes de ler e codificar em base64 const existsOnServer = await new Promise((res) => { socket.emit('check-image-exists', { fileName }, (resData) => { if (resData && resData.success && resData.exists) { res(true); } else { res(false); } }); // Timeout de segurança para a verificação setTimeout(() => res(false), 5000); }); if (existsOnServer) { addLog(`ℹ️ Imagem ${fileName} já existe no servidor. Adicionando ao cache local.`); uploadedCache.add(fileName); saveUploadCache(); resolve(); return; } let fileBuffer = fs.readFileSync(filePath); const originalSize = fileBuffer.length; const isImage = /\.(png|jpg|jpeg)$/i.test(fileName); let thumbBuffer = null; if (isImage) { try { addLog(`⚡ Gerando miniatura localmente para: ${fileName}...`); thumbBuffer = await gerarThumbnail(fileBuffer); if (thumbBuffer) { addLog(`✅ Miniatura gerada com sucesso (${Math.round(thumbBuffer.length / 1024)} KB)`); } else { addLog(`⚠️ Não foi possível gerar miniatura, o servidor precisará usar o original.`); } } catch (e) { addLog(`⚠️ Erro ao gerar miniatura: ${e.message}`); } } let patientName = "Cliente não identificado"; let doctor = 'Sem médico'; let remark = 'Sem observação'; let photographTime = null; const localInfo = lookupPatientInfo(fileName); if (localInfo) { patientName = localInfo.name; doctor = localInfo.doctor; remark = localInfo.remark; photographTime = localInfo.photographTime; addLog(`🔍 Dados do paciente: "${patientName}", Dentista: "${doctor}"`); } else { try { const stat = fs.statSync(filePath); const fileDate = stat.mtime || stat.birthtime || new Date(); const pad = (num) => String(num).padStart(2, '0'); photographTime = `${fileDate.getFullYear()}-${pad(fileDate.getMonth() + 1)}-${pad(fileDate.getDate())} ${pad(fileDate.getHours())}:${pad(fileDate.getMinutes())}:${pad(fileDate.getSeconds())}`; } catch (e) { addLog(`⚠️ Erro ao ler metadados: ${e.message}`); } } const metadata = { fileName: fileName, guid: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, fileSize: fileBuffer.length }; const patientData = { name: patientName, doctor: doctor, remark: remark, photographTime: photographTime }; addLog(`📤 Solicitando URLs temporárias para envio de: ${fileName} (${Math.round(fileBuffer.length / 1024)} KB)`); // Pedir Presigned URLs socket.emit('request-presigned-urls', { metadata, patientData }, async (urlResponse) => { if (urlResponse && urlResponse.success) { try { addLog(`🌐 Fazendo Direct Upload pro Wasabi (Bypass VPS)...`); // Fazer PUT da original await fetch(urlResponse.originalUrl, { method: 'PUT', body: fileBuffer, headers: { 'Content-Type': 'image/png' } }); // Fazer PUT do thumb se existir if (thumbBuffer && urlResponse.thumbnailUrl) { await fetch(urlResponse.thumbnailUrl, { method: 'PUT', body: thumbBuffer, headers: { 'Content-Type': 'image/jpeg' } }); } addLog(`✅ Upload direto concluído! Notificando servidor...`); // Notificar o servidor que o upload terminou socket.emit('image-upload-direct', { metadata, patientData, filename: urlResponse.filename, thumbFilename: thumbBuffer ? urlResponse.thumbFilename : null }, (response) => { if (response && response.success) { uploadedCache.add(response.originalFilename); saveUploadCache(); resolve(); } else { reject(new Error(response ? response.error : 'Erro na notificação de direct-upload')); } }); } catch (uploadErr) { addLog(`❌ Falha no Direct Upload: ${uploadErr.message}. Tentando via VPS...`); fallbackUpload(); } } else { addLog(`ℹ️ Wasabi não disponível no servidor. Usando envio padrão via VPS.`); fallbackUpload(); } }); function fallbackUpload() { let didTimeout = false; const timeoutId = setTimeout(() => { didTimeout = true; reject(new Error(`Timeout de 45 segundos aguardando resposta do servidor para: ${fileName}`)); }, 45000); socket.emit('send-image', { imageBase64: fileBuffer.toString('base64'), isBacklog: isBacklog, metadata: metadata, patientData: patientData }, (response) => { if (didTimeout) return; clearTimeout(timeoutId); if (response && response.success) { addLog(`✅ Confirmação do servidor recebida: ${response.message || 'sucesso'}`); if (response.originalFilename) { uploadedCache.add(response.originalFilename); saveUploadCache(); } resolve(); } else { reject(new Error(response ? response.error : 'Erro desconhecido do servidor')); } }); } } catch (error) { addLog(`❌ Erro ao ler/enviar arquivo: ${error.message}`); reject(error); } }); } // Configuração interativa via terminal CLI (fallback) function runSetupWizard() { return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); console.log('\n======================================================='); console.log('⚙️ CONFIGURAÇÃO DO DENTAL CLIENT'); console.log('======================================================='); console.log('Pressione ENTER para manter o valor atual entre colchetes.\n'); rl.question(`🌐 IP/URL do Servidor Destino [${config.serverUrl}]: `, (urlInput) => { config.serverUrl = urlInput.trim() || config.serverUrl; rl.question(`📁 Pasta para Monitorar Imagens [${config.monitorPath}]: `, (pathInput) => { config.monitorPath = pathInput.trim() || config.monitorPath; const askName = () => { rl.question(`🖥️ Nome deste Computador [${config.clientName}]: `, async (nameInput) => { const chosenName = nameInput.trim() || config.clientName; console.log(`🔍 Verificando se o nome "${chosenName}" está disponível no servidor...`); const check = await checkClientNameAvailability(config.serverUrl, chosenName, config.apiKey, config.clientId); if (check.success && !check.available) { console.log(`❌ O nome "${chosenName}" JÁ está sendo usado no servidor!`); console.log(`⚠️ Escolha outro nome exclusivo para evitar duplicidade de buckets ou vazamento de dados.\n`); askName(); } else { if (!check.success) { console.log(`⚠️ Não foi possível validar o nome no servidor (${check.error}).`); console.log(` Prosseguindo com o nome "${chosenName}" por precaução...\n`); } else { console.log(`✅ Nome "${chosenName}" disponível!\n`); } config.clientName = chosenName; try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); console.log('═══════════════════════════════════════════════════════'); console.log('✅ CONFIGURAÇÃO SALVA COM SUCESSO EM config.json!'); console.log(` Servidor Destino: ${config.serverUrl}`); console.log(` Pasta Monitorada: ${config.monitorPath}`); console.log(` Nome do Cliente : ${config.clientName}`); console.log('═══════════════════════════════════════════════════════\n'); } catch (err) { console.error('❌ Erro ao salvar config.json:', err.message); } rl.close(); resolve(); } }); }; askName(); }); }); }); } // Função Principal async function main() { const args = process.argv.slice(2); const needsSetup = args.includes('--setup') || args.includes('--config') || !config.serverUrl; if (needsSetup) { await runSetupWizard(); } addLog('🦷 DENTAL CLIENT - Monitor de Imagens'); addLog('═══════════════════════════════════════════════════════'); addLog(`📁 Monitorando: ${config.monitorPath}`); addLog(`🌐 Servidor : ${config.serverUrl}`); addLog(`🖥️ Cliente : ${config.clientName}`); addLog('═══════════════════════════════════════════════════════'); // Iniciar servidor HTTP de administração local apenas se não rodar no Electron if (!process.send) { startAdminServer(); } else { sendStatusToParent(); } // Conectar ao Socket.IO connectToServer(); // Iniciar monitor de arquivos após 2 segundos setTimeout(startMonitoring, 2000); } main(); // Encerrar processos de forma limpa process.on('SIGINT', () => { addLog('🛑 Sinal de encerramento recebido. Desconectando e saindo...'); if (socket) socket.disconnect(); if (watcher) watcher.close(); process.exit(0); }); process.on('uncaughtException', (error) => { addLog(`❌ Erro não capturado no processo: ${error.message}`); }); if (process.send) { process.on('message', async (msg) => { if (msg && msg.command === 'sync') { addLog('🔄 [IPC] Recebida solicitação de varredura local...'); await executarSincronizacaoLocal(); } if (msg && msg.command === 'pause') { isSendingPaused = true; addLog('⏸️ [IPC] Envio de imagens PAUSADO pelo usuário.'); } if (msg && msg.command === 'resume') { isSendingPaused = false; addLog('▶️ [IPC] Envio de imagens RETOMADO. Processando fila pendente...'); processQueue(); } }); }