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
⚙️ Configurações do Cliente
📊 Status do Sistema
Sistema
-
Uptime
-
Socket ID
-
🛠️ Atalhos
`;
}
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();
}
});
}