diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e7b9d95
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+*.exe
+*.blockmap
+uploaded_cache.json
+config.json
diff --git a/client-monitor.js b/client-monitor.js
new file mode 100644
index 0000000..541ca59
--- /dev/null
+++ b/client-monitor.js
@@ -0,0 +1,1488 @@
+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');
+const sharp = require('sharp');
+
+// Função para comprimir imagem PNG/JPEG de alta resolução para JPEG otimizado
+async function comprimirImagem(buffer) {
+ return await sharp(buffer)
+ .resize({
+ width: 1600,
+ withoutEnlargement: true,
+ fit: 'inside'
+ })
+ .jpeg({
+ quality: 75,
+ mozjpeg: true,
+ progressive: true
+ })
+ .toBuffer();
+}
+
+// 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
+
+
+
+
+
+
+
+
+
📜 Atividades Recentes
+
+
+
+
+
+
+
+`;
+}
+
+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
+ 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;
+ }
+
+ 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) {
+ 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;
+
+async function processQueue() {
+ if (isProcessingQueue) return;
+ isProcessingQueue = true;
+
+ while (uploadQueue.length > 0) {
+ 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;
+}
+
+// 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);
+
+ if (isImage) {
+ try {
+ fileBuffer = await comprimirImagem(fileBuffer);
+ addLog(`⚡ Imagem comprimida com sucesso: ${fileName} (${Math.round(originalSize / 1024)} KB -> ${Math.round(fileBuffer.length / 1024)} KB)`);
+ } catch (compressErr) {
+ addLog(`⚠️ Falha na compressão da imagem ${fileName}, enviando original: ${compressErr.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 encontrados localmente: "${patientName}", Dentista: "${doctor}", Obs: "${remark}", Data: "${photographTime}"`);
+ } else {
+ addLog(`🔍 Nenhum registro no banco local para o arquivo: ${fileName}`);
+ 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())}`;
+ addLog(`ℹ️ Usando data de modificação do arquivo como fallback: "${photographTime}"`);
+ } catch (e) {
+ addLog(`⚠️ Erro ao ler metadados do arquivo: ${e.message}`);
+ }
+ }
+
+ addLog(`📤 Enviando: ${fileName} (${Math.round(fileBuffer.length / 1024)} KB) ${isBacklog ? '[Backlog]' : '[Novo]'}`);
+
+ // Definir timeout de 45 segundos para segurança
+ 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: {
+ fileName: fileName,
+ guid: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ fileSize: fileBuffer.length
+ },
+ patientData: {
+ name: patientName,
+ doctor: doctor,
+ remark: remark,
+ photographTime: photographTime
+ }
+ }, (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();
+ }
+ });
+}
diff --git a/config-ui.css b/config-ui.css
new file mode 100644
index 0000000..e78e8e9
--- /dev/null
+++ b/config-ui.css
@@ -0,0 +1,396 @@
+:root {
+ --bg: #0a0910;
+ --card-bg: rgba(18, 16, 30, 0.75);
+ --border: rgba(255, 255, 255, 0.08);
+ --primary: #8257e5;
+ --primary-hover: #9466ff;
+ --primary-glow: rgba(130, 87, 229, 0.3);
+ --accent: #04d361;
+ --text: #f8f8f2;
+ --muted: #8f8f9d;
+ --input-bg: rgba(0, 0, 0, 0.4);
+ --danger: #e96379;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ font-family: 'Inter', sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+
+body {
+ background: var(--bg);
+ background-image:
+ radial-gradient(at 0% 0%, rgba(130, 87, 229, 0.15) 0px, transparent 65%),
+ radial-gradient(at 100% 100%, rgba(4, 211, 97, 0.08) 0px, transparent 65%);
+ background-attachment: fixed;
+ color: var(--text);
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.page-container {
+ width: 100%;
+ min-height: 100vh;
+ padding: 24px;
+ background: var(--card-bg);
+ backdrop-filter: blur(20px);
+ border: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+}
+
+/* HEADER */
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.logo-icon {
+ font-size: 28px;
+ line-height: 1;
+}
+
+.logo-text h1 {
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: -0.5px;
+ background: linear-gradient(135deg, #fff 0%, var(--muted) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+.logo-text p {
+ font-size: 11px;
+ color: var(--muted);
+ margin-top: 1px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.brand-domain {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--primary-hover);
+ background: rgba(130, 87, 229, 0.1);
+ padding: 4px 10px;
+ border-radius: 20px;
+ border: 1px solid rgba(130, 87, 229, 0.2);
+}
+
+/* MAIN CONTENT AREA */
+.main-content {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin-top: 16px;
+}
+
+/* CONTAINER PANELS (LOGIN & SETTINGS) */
+.container-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ justify-content: space-between;
+ transition: opacity 0.25s ease, transform 0.25s ease;
+}
+
+.container-panel.hidden {
+ display: none !important;
+ opacity: 0;
+ transform: translateY(15px);
+}
+
+.panel-header {
+ margin-bottom: 20px;
+}
+
+.panel-header h2 {
+ font-size: 18px;
+ font-weight: 700;
+ margin-bottom: 4px;
+ color: #fff;
+}
+
+.panel-header p {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+/* LOGIN FORM SPECIFICS */
+.login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 400px;
+ width: 100%;
+ margin: 0 auto;
+}
+
+.login-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 24px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+}
+
+/* SETTINGS FORM SPECIFICS */
+.config-form {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ justify-content: space-between;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+}
+
+.form-group.full-width {
+ grid-column: span 2;
+}
+
+label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--muted);
+ margin-bottom: 4px;
+}
+
+input {
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 8px 12px;
+ color: #fff;
+ font-size: 13px;
+ transition: all 0.2s ease-in-out;
+}
+
+input:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px var(--primary-glow);
+ background: rgba(0, 0, 0, 0.6);
+}
+
+.help-text {
+ font-size: 10px;
+ color: var(--muted);
+ margin-top: 3px;
+}
+
+/* ACTIONS */
+.form-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+}
+
+.right-buttons {
+ display: flex;
+ gap: 12px;
+}
+
+/* BUTTONS */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 9px 16px;
+ font-size: 13px;
+ font-weight: 600;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ border: none;
+ outline: none;
+}
+
+.btn:active {
+ transform: scale(0.98);
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: #fff;
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+ box-shadow: 0 4px 15px var(--primary-glow);
+}
+
+.btn-outline {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border);
+ color: var(--text);
+}
+
+.btn-outline:hover {
+ background: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.btn-text {
+ background: transparent;
+ color: var(--muted);
+}
+
+.btn-text:hover {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+/* SPINNER */
+.spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: white;
+ animation: spin 0.8s linear infinite;
+ display: none;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* TOAST */
+.toast {
+ position: fixed;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%) translateY(-50px);
+ opacity: 0;
+ background: var(--primary);
+ color: white;
+ padding: 10px 20px;
+ border-radius: 30px;
+ font-size: 12px;
+ font-weight: 600;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+ pointer-events: none;
+ white-space: nowrap;
+}
+
+.toast.show {
+ transform: translateX(-50%) translateY(0);
+ opacity: 1;
+}
+
+.toast.success {
+ background: var(--accent);
+ color: #050408;
+}
+
+.toast.error {
+ background: var(--danger);
+}
+
+.password-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+}
+
+.password-wrapper input {
+ width: 100%;
+ padding-right: 40px;
+}
+
+.btn-toggle-password {
+ position: absolute;
+ right: 12px;
+ background: transparent;
+ border: none;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 14px;
+ outline: none;
+ user-select: none;
+}
+
+.btn-toggle-password:hover {
+ color: #fff;
+}
+
+/* TERMINAL LOGS */
+.terminal-container {
+ margin-top: 20px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: rgba(0,0,0,0.4);
+ display: flex;
+ flex-direction: column;
+ height: 150px;
+}
+.terminal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(255,255,255,0.02);
+ border-radius: 8px 8px 0 0;
+}
+.terminal-header h3 {
+ font-size: 11px;
+ color: var(--muted);
+ font-weight: 600;
+ text-transform: uppercase;
+}
+.terminal-header .btn-sm {
+ padding: 4px 8px;
+ font-size: 11px;
+}
+.terminal-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 11px;
+ color: #a0a0b0;
+ line-height: 1.5;
+}
+.terminal-body .log-line {
+ margin-bottom: 3px;
+ border-left: 2px solid rgba(130, 87, 229, 0.4);
+ padding-left: 6px;
+ word-break: break-all;
+}
diff --git a/config-ui.html b/config-ui.html
new file mode 100644
index 0000000..6511717
--- /dev/null
+++ b/config-ui.html
@@ -0,0 +1,145 @@
+
+
+
+
+
+ ScoreOdonto - Configuração
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
[Sistema] Aguardando logs do monitor...
+
+
+
+
+
+
+
+
+
+
diff --git a/config-ui.js b/config-ui.js
new file mode 100644
index 0000000..d0bf10e
--- /dev/null
+++ b/config-ui.js
@@ -0,0 +1,262 @@
+document.addEventListener('DOMContentLoaded', async () => {
+ // Containers
+ const loginContainer = document.getElementById('login-container');
+ const settingsContainer = document.getElementById('settings-container');
+
+ // Login Form Elements
+ const loginForm = document.getElementById('login-form');
+ const loginUsernameInput = document.getElementById('login-username');
+ const loginPasswordInput = document.getElementById('login-password');
+ const btnLoginCancel = document.getElementById('btn-login-cancel');
+
+ // Config Form Elements
+ const configForm = document.getElementById('config-form');
+ const clientNameInput = document.getElementById('clientName');
+ const serverUrlInput = document.getElementById('serverUrl');
+ const monitorPathInput = document.getElementById('monitorPath');
+ const serverEmailInput = document.getElementById('serverEmail');
+ const serverPasswordInput = document.getElementById('serverPassword');
+ const tokenInput = document.getElementById('token');
+ const adminUsernameInput = document.getElementById('adminUsername');
+ const adminPasswordInput = document.getElementById('adminPassword');
+
+ // Action Buttons
+ const btnTest = document.getElementById('btn-test');
+ const testSpinner = document.getElementById('test-spinner');
+ const testText = document.getElementById('test-text');
+ const btnCancel = document.getElementById('btn-cancel');
+
+ let loadedConfig = null;
+
+ // Show message notification
+ function showToast(message, type = 'success') {
+ const toast = document.getElementById('toast');
+ toast.textContent = message;
+ toast.className = `toast show ${type}`;
+ setTimeout(() => {
+ toast.className = 'toast';
+ }, 3000);
+ }
+
+ // Initialize layout and load config
+ try {
+ loadedConfig = await window.api.getConfig();
+
+ // Populate settings form
+ if (loadedConfig.clientName === 'DIGITE-O-NOME-DA-MAQUINA') {
+ clientNameInput.value = '';
+ } else {
+ clientNameInput.value = loadedConfig.clientName || '';
+ }
+
+ serverUrlInput.value = loadedConfig.serverUrl || 'https://rx.scoreodonto.com';
+ monitorPathInput.value = loadedConfig.monitorPath || 'C:\\ProgramData\\RF\\Dental Sensor\\Images';
+ serverEmailInput.value = loadedConfig.serverEmail || '';
+ serverPasswordInput.value = loadedConfig.serverPassword || '';
+ tokenInput.value = loadedConfig.token || '';
+ adminUsernameInput.value = loadedConfig.adminUsername || 'admin';
+ adminPasswordInput.value = loadedConfig.adminPassword || 'admin';
+
+ // Check if it is a first run (clientName not configured yet)
+ const isFirstRun = !loadedConfig.clientName || loadedConfig.clientName === 'DIGITE-O-NOME-DA-MAQUINA';
+
+ if (isFirstRun) {
+ // Bypass login overlay and show setup screen directly
+ loginContainer.classList.add('hidden');
+ settingsContainer.classList.remove('hidden');
+ } else {
+ // Show login screen
+ loginContainer.classList.remove('hidden');
+ settingsContainer.classList.add('hidden');
+ }
+ } catch (e) {
+ showToast('Erro ao carregar configurações: ' + e.message, 'error');
+ }
+
+ // Handle Login Submit
+ loginForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+
+ const username = loginUsernameInput.value.trim();
+ const password = loginPasswordInput.value;
+
+ const correctUsername = loadedConfig.adminUsername || 'admin';
+ const correctPassword = loadedConfig.adminPassword || 'admin';
+
+ if (username === correctUsername && password === correctPassword) {
+ showToast('Autenticado com sucesso!', 'success');
+ // Transition panels
+ loginContainer.classList.add('hidden');
+ settingsContainer.classList.remove('hidden');
+ } else {
+ showToast('Usuário ou senha inválidos.', 'error');
+ loginPasswordInput.value = '';
+ loginPasswordInput.focus();
+ }
+ });
+
+ // Handle Config Form Submit
+ configForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const name = clientNameInput.value.trim();
+ if (!name || name === 'DIGITE-O-NOME-DA-MAQUINA') {
+ showToast('Por favor, informe um nome válido para o computador.', 'error');
+ return;
+ }
+
+ const serverUrl = serverUrlInput.value.trim();
+ const serverEmail = serverEmailInput.value.trim();
+ const serverPassword = serverPasswordInput.value;
+ const token = tokenInput.value.trim();
+
+ // Validar credenciais no servidor
+ const saveSpinner = document.getElementById('save-spinner');
+ const saveBtn = document.getElementById('btn-save');
+ saveBtn.disabled = true;
+ if (saveSpinner) saveSpinner.style.display = 'inline-block';
+
+ try {
+ showToast('Validando credenciais com o servidor...', 'info');
+ const verifyResult = await window.api.verifyCredentials(serverUrl, serverEmail, serverPassword, token);
+
+ if (saveSpinner) saveSpinner.style.display = 'none';
+ saveBtn.disabled = false;
+
+ if (!verifyResult.success) {
+ showToast('Falha na validação: ' + verifyResult.error, 'error');
+ return;
+ }
+
+ showToast('Credenciais confirmadas pelo servidor!', 'success');
+ } catch (authErr) {
+ if (saveSpinner) saveSpinner.style.display = 'none';
+ saveBtn.disabled = false;
+ showToast('Erro ao conectar ao servidor para validar: ' + authErr.message, 'error');
+ return;
+ }
+
+ const newConfig = {
+ ...loadedConfig,
+ clientName: name,
+ serverUrl: serverUrl,
+ monitorPath: monitorPathInput.value.trim(),
+ apiKey: token, // Salva o token também como apiKey para compatibilidade retroativa
+ serverEmail: serverEmail,
+ serverPassword: serverPassword,
+ token: token,
+ adminUsername: adminUsernameInput.value.trim(),
+ adminPassword: adminPasswordInput.value.trim(),
+ clientType: 'windows'
+ };
+
+ try {
+ const success = await window.api.saveConfig(newConfig);
+ if (success) {
+ showToast('Configurações salvas!');
+ setTimeout(() => {
+ window.api.closeWindow();
+ }, 1000);
+ } else {
+ showToast('Falha ao salvar as configurações.', 'error');
+ }
+ } catch (err) {
+ showToast('Erro: ' + err.message, 'error');
+ }
+ });
+
+ // Handle Test Connection
+ btnTest.addEventListener('click', async () => {
+ const url = serverUrlInput.value.trim();
+ if (!url) {
+ showToast('Informe a URL do servidor.', 'error');
+ return;
+ }
+
+ btnTest.disabled = true;
+ testSpinner.style.display = 'inline-block';
+ testText.textContent = ' Testando...';
+
+ try {
+ const result = await window.api.testServer(url);
+ if (result.success) {
+ showToast('Conexão estabelecida com sucesso!', 'success');
+ } else {
+ showToast('Falha na conexão: ' + result.error, 'error');
+ }
+ } catch (err) {
+ showToast('Erro: ' + err.message, 'error');
+ } finally {
+ btnTest.disabled = false;
+ testSpinner.style.display = 'none';
+ testText.textContent = '⚡ Testar Conexão';
+ }
+ });
+
+ // Toggle Admin Password Visibility
+ const toggleAdminPassword = document.getElementById('toggle-admin-password');
+ if (toggleAdminPassword) {
+ toggleAdminPassword.addEventListener('click', () => {
+ const type = adminPasswordInput.getAttribute('type') === 'password' ? 'text' : 'password';
+ adminPasswordInput.setAttribute('type', type);
+ toggleAdminPassword.textContent = type === 'password' ? '👁️' : '🙈';
+ });
+ }
+
+ // Handle Minimize / Close Window
+ btnCancel.addEventListener('click', () => {
+ window.api.closeWindow();
+ });
+
+ btnLoginCancel.addEventListener('click', () => {
+ window.api.closeWindow();
+ });
+
+ // Terminal Logs
+ const terminalLogs = document.getElementById('terminal-logs');
+ const btnCopyLogs = document.getElementById('btn-copy-logs');
+
+ if (window.api.onLog) {
+ window.api.onLog((log) => {
+ if (terminalLogs) {
+ // remove the waiting message if present
+ const waitingMsg = terminalLogs.querySelector('.log-line');
+ if (waitingMsg && waitingMsg.textContent.includes('Aguardando logs')) {
+ terminalLogs.innerHTML = '';
+ }
+
+ const div = document.createElement('div');
+ div.className = 'log-line';
+ div.textContent = log;
+ terminalLogs.appendChild(div);
+
+ // Auto scroll to bottom
+ terminalLogs.scrollTop = terminalLogs.scrollHeight;
+
+ // Keep max 100 logs
+ while (terminalLogs.children.length > 100) {
+ terminalLogs.removeChild(terminalLogs.firstChild);
+ }
+ }
+ });
+ }
+
+ if (btnCopyLogs && terminalLogs) {
+ btnCopyLogs.addEventListener('click', () => {
+ const logsText = Array.from(terminalLogs.children)
+ .map(el => el.textContent)
+ .join('\n');
+
+ navigator.clipboard.writeText(logsText).then(() => {
+ const icon = document.getElementById('copy-icon');
+ const originalText = btnCopyLogs.innerHTML;
+ btnCopyLogs.innerHTML = '✅ Copiado';
+ setTimeout(() => {
+ btnCopyLogs.innerHTML = originalText;
+ }, 2000);
+ }).catch(err => {
+ showToast('Erro ao copiar logs', 'error');
+ });
+ });
+ }
+});
diff --git a/configurar.bat b/configurar.bat
new file mode 100644
index 0000000..4399fa1
--- /dev/null
+++ b/configurar.bat
@@ -0,0 +1,4 @@
+@echo off
+echo Abrindo painel de configuracao do Dental Client...
+start http://localhost:3001
+exit
diff --git a/deploy-client.sh b/deploy-client.sh
new file mode 100755
index 0000000..b4f814d
--- /dev/null
+++ b/deploy-client.sh
@@ -0,0 +1,67 @@
+#!/bin/bash
+# ─── deploy-client.sh (RX ScoreOdonto - Desktop Client) ──────────────────────
+# Cross-compile do Electron na VPS 4 (Linux) gerando .exe para Windows
+# e distribui os binários de atualização para a VPS 1 (Produção).
+# ──────────────────────────────────────────────────────────────────────────────
+set -e
+
+CLIENT_PATH="/home/deploy/stack/dental-client"
+PROD_VPS="10.99.0.1"
+PROD_USER="deploy"
+UPDATES_PATH="/home/deploy/stack/rx.scoreodonto.com/dental-server/updates"
+LOG_FILE="/home/deploy/stack/webhook-listener/deploy.log"
+LOG_TAG="[dental-client]"
+
+log() {
+ echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] ${LOG_TAG} $1" | tee -a "$LOG_FILE"
+}
+
+log "🚀 Iniciando build do client desktop..."
+
+# 1. Atualizar repositório local
+log "📦 Sincronizando repositório Git..."
+cd "$CLIENT_PATH"
+git fetch origin main
+git reset --hard origin/main
+
+# 2. Instalar dependências
+log "📦 Instalando dependências (npm install)..."
+npm install
+
+# 3. Build cross-platform (Linux → Windows .exe)
+log "⚙️ Buildando .exe para Windows via electron-builder (cross-compile)..."
+npx electron-builder --win --x64 --publish never
+
+# 4. Verificar se o build gerou os artefatos
+if [ ! -d "$CLIENT_PATH/dist" ]; then
+ log "❌ ERRO: Pasta dist/ não encontrada após o build!"
+ exit 1
+fi
+
+EXE_COUNT=$(find "$CLIENT_PATH/dist" -name "*.exe" | wc -l)
+YML_COUNT=$(find "$CLIENT_PATH/dist" -name "latest.yml" | wc -l)
+
+if [ "$EXE_COUNT" -eq 0 ] || [ "$YML_COUNT" -eq 0 ]; then
+ log "❌ ERRO: Artefatos incompletos (exe: $EXE_COUNT, yml: $YML_COUNT)"
+ exit 1
+fi
+
+log "✅ Build concluído: $EXE_COUNT .exe(s), $YML_COUNT latest.yml"
+
+# 5. Garantir que a pasta de updates existe na VPS 1
+ssh -o BatchMode=yes -o StrictHostKeyChecking=no ${PROD_USER}@${PROD_VPS} \
+ "mkdir -p ${UPDATES_PATH}"
+
+# 6. Transferir binários para VPS 1
+log "🚚 Transferindo binários para VPS 1 (${UPDATES_PATH})..."
+rsync -avz --delete \
+ "$CLIENT_PATH/dist/"*.exe \
+ "$CLIENT_PATH/dist/"*.yml \
+ "$CLIENT_PATH/dist/"*.blockmap \
+ ${PROD_USER}@${PROD_VPS}:${UPDATES_PATH}/ 2>/dev/null || \
+rsync -avz \
+ "$CLIENT_PATH/dist/"*.exe \
+ "$CLIENT_PATH/dist/"*.yml \
+ ${PROD_USER}@${PROD_VPS}:${UPDATES_PATH}/
+
+log "✅ Build e distribuição do client concluídos com sucesso!"
diff --git a/favicon.png b/favicon.png
new file mode 100644
index 0000000..9e601f6
Binary files /dev/null and b/favicon.png differ
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..71719e8
--- /dev/null
+++ b/index.js
@@ -0,0 +1,396 @@
+const { app, BrowserWindow, Tray, Menu, ipcMain, shell, dialog } = require('electron');
+const path = require('path');
+const fs = require('fs');
+const { fork } = require('child_process');
+const { autoUpdater } = require('electron-updater');
+
+// ================================================================
+// AUTO-UPDATER CONFIG
+// ================================================================
+autoUpdater.autoDownload = true;
+autoUpdater.autoInstallOnAppQuit = true;
+autoUpdater.logger = require('electron').app ? console : null;
+
+autoUpdater.on('checking-for-update', () => {
+ console.log('🔍 Verificando atualizações...');
+});
+
+autoUpdater.on('update-available', (info) => {
+ console.log('🔄 Nova versão disponível:', info.version);
+});
+
+autoUpdater.on('update-not-available', () => {
+ console.log('✅ Aplicativo está na versão mais recente.');
+});
+
+autoUpdater.on('download-progress', (progress) => {
+ console.log(`⬇️ Baixando atualização: ${Math.round(progress.percent)}%`);
+});
+
+autoUpdater.on('update-downloaded', (info) => {
+ console.log('✅ Atualização baixada:', info.version, '— será instalada ao fechar o app.');
+ // Notificar o usuário na tray
+ if (tray) {
+ tray.displayBalloon({
+ title: 'ScoreOdonto - Atualização Pronta',
+ content: `Versão ${info.version} será instalada automaticamente ao fechar o aplicativo.`,
+ iconType: 'info'
+ });
+ }
+});
+
+autoUpdater.on('error', (err) => {
+ console.error('❌ Erro no auto-updater:', err.message);
+});
+
+let tray = null;
+let configWindow = null;
+let monitorProcess = null;
+let currentStatus = { connected: false, identified: false };
+
+const appDataFolder = app.getPath('userData');
+const configPath = path.join(appDataFolder, 'config.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"
+};
+
+function loadConfig() {
+ try {
+ if (!fs.existsSync(configPath)) {
+ fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8');
+ return defaultConfig;
+ }
+ const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ // Ensure required fields exist
+ return { ...defaultConfig, ...userConfig };
+ } catch (e) {
+ console.error("Error loading config:", e);
+ return defaultConfig;
+ }
+}
+
+function saveConfig(newConfig) {
+ try {
+ fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), 'utf8');
+ 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') {
+ if (configWindow && !configWindow.isDestroyed()) {
+ configWindow.webContents.send('new-log', message.log);
+ }
+ }
+ });
+
+ monitorProcess.on('exit', (code) => {
+ console.log(`Monitor process exited with code ${code}`);
+ currentStatus.connected = false;
+ currentStatus.identified = false;
+ updateTrayMenu();
+ });
+}
+
+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;
+ });
+}
+
+function updateTrayMenu() {
+ if (!tray) return;
+
+ let statusText = "ScoreOdonto: Desconectado 🔴";
+ if (currentStatus.connected) {
+ statusText = currentStatus.identified
+ ? "ScoreOdonto: Identificado 🟢"
+ : "ScoreOdonto: Conectado... 🟡";
+ }
+
+ const contextMenu = Menu.buildFromTemplate([
+ { label: statusText, enabled: false },
+ { type: 'separator' },
+ {
+ label: 'Forçar Sincronização 🔄',
+ click: () => {
+ 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();
+ }
+ }
+ ]);
+
+ 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();
+
+ // 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('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();
+ }
+});
diff --git a/iniciar-cliente.bat b/iniciar-cliente.bat
new file mode 100644
index 0000000..750ee39
--- /dev/null
+++ b/iniciar-cliente.bat
@@ -0,0 +1,5 @@
+@echo off
+title Dental Client - Monitor
+echo Iniciando Dental Client...
+node client-monitor.js
+pause
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..117b4bb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "dental-client",
+ "version": "1.2.0",
+ "description": "Cliente Windows para envio de imagens dentais da ScoreOdonto",
+ "main": "index.js",
+ "scripts": {
+ "start": "electron .",
+ "dist": "electron-builder"
+ },
+ "dependencies": {
+ "chokidar": "^3.5.3",
+ "electron-updater": "^6.3.0",
+ "sharp": "^0.34.5",
+ "socket.io-client": "^4.7.5"
+ },
+ "build": {
+ "appId": "com.scoreodonto.dentalclient",
+ "productName": "ScoreOdonto Dental Client",
+ "win": {
+ "target": "nsis",
+ "icon": "favicon.png"
+ },
+ "nsis": {
+ "oneClick": true,
+ "perMachine": true,
+ "allowToChangeInstallationDirectory": false,
+ "createDesktopShortcut": true,
+ "createStartMenuShortcut": true,
+ "shortcutName": "ScoreOdonto Dental Client"
+ },
+ "publish": [
+ {
+ "provider": "generic",
+ "url": "https://rx.scoreodonto.com/updates/"
+ }
+ ],
+ "files": [
+ "**/*",
+ "!docs-client/**/*",
+ "!Output/**/*",
+ "!dist/**/*"
+ ],
+ "asar": false
+ },
+ "devDependencies": {
+ "electron": "^42.2.0",
+ "electron-builder": "^26.8.1"
+ }
+}
\ No newline at end of file
diff --git a/preload.js b/preload.js
new file mode 100644
index 0000000..87f9b7e
--- /dev/null
+++ b/preload.js
@@ -0,0 +1,11 @@
+const { contextBridge, ipcRenderer } = require('electron');
+
+contextBridge.exposeInMainWorld('api', {
+ getConfig: () => ipcRenderer.invoke('get-config'),
+ saveConfig: (config) => ipcRenderer.invoke('save-config', config),
+ testServer: (url) => ipcRenderer.invoke('test-server', url),
+ verifyCredentials: (url, email, password, token) => ipcRenderer.invoke('verify-credentials', url, email, password, token),
+ openFolder: () => ipcRenderer.invoke('open-folder'),
+ closeWindow: () => ipcRenderer.send('close-window'),
+ onLog: (callback) => ipcRenderer.on('new-log', (event, log) => callback(log))
+});