diff --git a/apps/web/src/lib/components/call/CallWindow.svelte b/apps/web/src/lib/components/call/CallWindow.svelte index 8ebe538..d13b9da 100644 --- a/apps/web/src/lib/components/call/CallWindow.svelte +++ b/apps/web/src/lib/components/call/CallWindow.svelte @@ -5,69 +5,14 @@ import { api } from '@sgse-app/backend/convex/_generated/api'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { X, GripVertical, GripHorizontal } from 'lucide-svelte'; - - // Tipos para Jitsi (evitando 'any') - interface JitsiConnection { - connect(): void; - disconnect(): void; - addEventListener(event: string, handler: (data?: unknown) => void): void; - initJitsiConference(roomName: string, options: Record): JitsiConference; - } - - interface JitsiConference { - join(): void; - leave(): void; - on(event: string, handler: (...args: unknown[]) => void): void; - removeEventListener(event: string, handler: (...args: unknown[]) => void): void; - muteAudio(): void; - unmuteAudio(): void; - muteVideo(): void; - unmuteVideo(): void; - getParticipants(): Map; - getLocalTracks(): JitsiTrack[]; - setDisplayName(name: string): void; - addTrack(track: JitsiTrack): Promise; - } - - interface JitsiTrack { - getType(): 'audio' | 'video'; - isMuted(): boolean; - mute(): Promise; - unmute(): Promise; - attach(element: HTMLElement): void; - detach(element: HTMLElement): void; - dispose(): Promise; - getParticipantId(): string; - track: MediaStreamTrack; - } - - interface JitsiMeetJSLib { - JitsiConnection: new (appId: string | null, token: string | null, options: Record) => JitsiConnection; - constants: { - events: { - connection: { - CONNECTION_ESTABLISHED: string; - CONNECTION_FAILED: string; - CONNECTION_DISCONNECTED: string; - }; - conference: { - USER_JOINED: string; - USER_LEFT: string; - TRACK_ADDED: string; - TRACK_REMOVED: string; - TRACK_MUTE_CHANGED: string; - CONFERENCE_JOINED: string; - CONFERENCE_LEFT: string; - }; - }; - logLevels: { - ERROR: number; - }; - }; - init(options: Record): void; - setLogLevel(level: number): void; - createLocalTracks(options: Record): Promise; - } + import type { + JitsiConnection, + JitsiConference, + JitsiTrack, + JitsiMeetJSLib, + JitsiConnectionOptions, + WindowWithBlobBuilder + } from '$lib/types/jitsi'; // Importação dinâmica do Jitsi apenas no cliente let JitsiMeetJS: JitsiMeetJSLib | null = $state(null); @@ -149,6 +94,12 @@ let errorMessage = $state(''); let errorDetails = $state(undefined); + // Estados de conexão e qualidade + let qualidadeConexao = $state<'excelente' | 'boa' | 'regular' | 'ruim' | 'desconhecida'>('desconhecida'); + let tentativasReconexao = $state(0); + const MAX_TENTATIVAS_RECONEXAO = 3; + let reconectando = $state(false); + // Queries const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId }); const chamada = $derived(chamadaQuery?.data); @@ -161,12 +112,29 @@ // Configuração Jitsi (busca do backend primeiro, depois fallback para env vars) const configJitsi = $derived.by(() => obterConfiguracaoJitsi(configJitsiBackend?.data || null)); - // Handler de erro - function handleError(message: string, details?: string): void { + // Handler de erro melhorado + function handleError(message: string, details?: string, podeReconectar: boolean = false): void { const erroTraduzido = traduzirErro(new Error(message)); errorTitle = erroTraduzido.titulo; errorMessage = erroTraduzido.mensagem; - errorDetails = details || erroTraduzido.instrucoes; + + // Adicionar sugestões de solução baseadas no tipo de erro + let sugestoes = ''; + if (message.includes('conectar') || message.includes('servidor')) { + sugestoes = '\n\nSugestões:\n• Verifique sua conexão com a internet\n• Verifique se o servidor Jitsi está acessível\n• Tente recarregar a página'; + } else if (message.includes('permissão') || message.includes('microfone') || message.includes('câmera')) { + sugestoes = '\n\nSugestões:\n• Verifique as permissões do navegador para microfone e câmera\n• Certifique-se de que nenhum outro aplicativo está usando os dispositivos\n• Tente recarregar a página e permitir novamente'; + } else if (message.includes('certificado') || message.includes('SSL')) { + sugestoes = '\n\nSugestões:\n• Se estiver em desenvolvimento local, aceite o certificado autoassinado\n• Verifique as configurações de segurança do navegador'; + } + + errorDetails = (details || erroTraduzido.instrucoes) + sugestoes; + + // Se pode reconectar e ainda há tentativas + if (podeReconectar && tentativasReconexao < MAX_TENTATIVAS_RECONEXAO) { + errorDetails += `\n\nTentando reconectar automaticamente... (${tentativasReconexao + 1}/${MAX_TENTATIVAS_RECONEXAO})`; + } + showErrorModal = true; console.error(message, details); } @@ -180,13 +148,37 @@ // Polyfill BlobBuilder já deve estar disponível via app.html // Verificar se está disponível antes de carregar a biblioteca - if (typeof (window as any).BlobBuilder === 'undefined') { + const windowWithBlobBuilder = window as WindowWithBlobBuilder; + if ( + typeof windowWithBlobBuilder.BlobBuilder === 'undefined' && + typeof windowWithBlobBuilder.webkitBlobBuilder === 'undefined' && + typeof windowWithBlobBuilder.MozBlobBuilder === 'undefined' + ) { console.warn('⚠️ Polyfill BlobBuilder não encontrado, pode causar erros'); } // Tentar carregar o módulo lib-jitsi-meet dinamicamente // Usar import dinâmico para evitar problemas de SSR e permitir carregamento apenas no browser - const module = await import('lib-jitsi-meet'); + let module; + try { + module = await import('lib-jitsi-meet'); + } catch (importError) { + const importErrorMessage = importError instanceof Error ? importError.message : String(importError); + console.error('❌ Erro ao importar lib-jitsi-meet:', importError); + + // Verificar se é um erro de módulo não encontrado + if (importErrorMessage.includes('Failed to fetch') || + importErrorMessage.includes('Cannot find module') || + importErrorMessage.includes('Failed to resolve') || + importErrorMessage.includes('Dynamic import')) { + throw new Error( + 'A biblioteca Jitsi não pôde ser carregada. ' + + 'Verifique se o pacote "lib-jitsi-meet" está instalado corretamente. ' + + 'Se o problema persistir, tente limpar o cache do navegador e recarregar a página.' + ); + } + throw importError; + } console.log('📦 Módulo carregado, verificando exportações...', { hasDefault: !!module.default, @@ -267,7 +259,11 @@ }); // Verificar se é um erro de módulo não encontrado - if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Cannot find module')) { + if (errorMessage.includes('Failed to fetch') || + errorMessage.includes('Cannot find module') || + errorMessage.includes('Failed to resolve') || + errorMessage.includes('Dynamic import') || + errorMessage.includes('biblioteca Jitsi não pôde ser carregada')) { handleError( 'Biblioteca de vídeo não encontrada', 'A biblioteca Jitsi não pôde ser encontrada. Verifique se o pacote "lib-jitsi-meet" está instalado. Se o problema persistir, tente limpar o cache do navegador e recarregar a página.' @@ -320,26 +316,54 @@ const { host, porta } = obterHostEPorta(config.domain); const protocol = config.useHttps ? 'https' : 'http'; - // Para Docker Jitsi local, a configuração deve ser: - // - domain: apenas o host (sem porta) - // - serviceUrl: URL completa com porta para BOSH - // - muc: geralmente conference.host ou apenas host - const options: Record = { + // Configuração conforme documentação oficial do Jitsi Meet + // https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-ljm-api/ + const baseUrl = `${protocol}://${host}${porta && porta !== (config.useHttps ? 443 : 80) ? `:${porta}` : ''}`; + const boshUrl = `${baseUrl}/http-bind`; + + // Determinar MUC baseado no host + // Para localhost, usar conference.localhost + // Para domínios reais, usar conference.{host} + const mucDomain = host === 'localhost' || host.startsWith('127.0.0.1') + ? `conference.${host}` + : `conference.${host}`; + + const options: JitsiConnectionOptions = { hosts: { - domain: host, // Apenas o host para o domain - muc: `conference.${host}` // MUC no mesmo domínio + domain: host, + muc: mucDomain, + focus: `focus.${host}` }, - serviceUrl: `${protocol}://${host}:${porta}/http-bind`, // BOSH endpoint com porta - bosh: `${protocol}://${host}:${porta}/http-bind`, // BOSH alternativo - clientNode: config.appId + serviceUrl: boshUrl, + bosh: boshUrl, + clientNode: config.appId, + // Opções de performance recomendadas + enableLayerSuspension: true, + enableLipSync: false, + disableAudioLevels: false, + disableSimulcast: false, + enableRemb: true, + enableTcc: true, + useStunTurn: true, + // Configurações de codec + preferredVideoCodec: 'VP8', + disableVP8: false, + disableVP9: false, + disableH264: false, + // Configurações de áudio + stereo: false, + enableOpusRed: true, + enableDtmf: true }; - console.log('🔧 Configurando conexão Jitsi:', { + console.log('🔧 Configurando conexão Jitsi (conforme documentação oficial):', { host, porta, protocol, + baseUrl, serviceUrl: options.serviceUrl, - muc: options.hosts.muc + muc: options.hosts?.muc, + focus: options.hosts?.focus }); const connection = new JitsiMeetJS.JitsiConnection(null, null, options); @@ -350,15 +374,34 @@ connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_ESTABLISHED, () => { console.log('✅ Conexão estabelecida'); atualizarStatusConexao(true); + tentativasReconexao = 0; // Resetar contador de tentativas + reconectando = false; + qualidadeConexao = 'boa'; // Inicial como boa // Iniciar chamada no backend client.mutation(api.chamadas.iniciarChamada, { chamadaId }); - // Criar conferência + // Criar conferência com opções recomendadas pela documentação oficial const estadoAtual = get(callState); const conferenceOptions: Record = { startAudioMuted: !estadoAtual.audioHabilitado, - startVideoMuted: !estadoAtual.videoHabilitado + startVideoMuted: !estadoAtual.videoHabilitado, + // Opções de P2P (peer-to-peer) para melhor performance + p2p: { + enabled: true, + stunServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] + }, + // Configurações de qualidade de vídeo + resolution: 720, + maxBitrate: 2500000, // 2.5 Mbps + // Configurações de áudio + audioQuality: { + stereo: false, + opusMaxAverageBitrate: 64000 + } }; const conference = connection.initJitsiConference(roomName, conferenceOptions); @@ -377,15 +420,41 @@ atualizarStatusConexao(false); const errorMsg = error instanceof Error ? error.message : String(error); - handleError( - 'Erro ao conectar com servidor de vídeo', - `Não foi possível conectar ao servidor Jitsi. Verifique se o servidor está rodando e acessível.\n\nErro: ${errorMsg}` - ); + + // Tentar reconectar se ainda houver tentativas + if (tentativasReconexao < MAX_TENTATIVAS_RECONEXAO) { + tentativasReconexao++; + reconectando = true; + + setTimeout(() => { + console.log(`🔄 Tentativa de reconexão ${tentativasReconexao}/${MAX_TENTATIVAS_RECONEXAO}...`); + connection.connect(); + }, 2000 * tentativasReconexao); // Backoff exponencial + } else { + reconectando = false; + handleError( + 'Erro ao conectar com servidor de vídeo', + `Não foi possível conectar ao servidor Jitsi após ${MAX_TENTATIVAS_RECONEXAO} tentativas.\n\nErro: ${errorMsg}`, + false + ); + } }); connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_DISCONNECTED, () => { console.log('🔌 Conexão desconectada'); atualizarStatusConexao(false); + qualidadeConexao = 'desconhecida'; + + // Tentar reconectar automaticamente se não foi intencional + if (tentativasReconexao < MAX_TENTATIVAS_RECONEXAO && !reconectando) { + tentativasReconexao++; + reconectando = true; + + setTimeout(() => { + console.log(`🔄 Tentando reconectar após desconexão (${tentativasReconexao}/${MAX_TENTATIVAS_RECONEXAO})...`); + connection.connect(); + }, 3000); + } }); // Conectar @@ -411,7 +480,12 @@ conference.on(JitsiMeetJS.constants.events.conference.USER_JOINED, (id: unknown, user: unknown) => { console.log('👤 Participante entrou:', id, user); // Atualizar lista de participantes - atualizarListaParticipantes(); + atualizarListaParticipantes().then(() => { + // Atualizar nomes nos overlays + if (typeof id === 'string') { + atualizarNomeParticipante(id); + } + }); }); // Participante saiu @@ -420,22 +494,17 @@ atualizarListaParticipantes(); }); - // Áudio mutado/desmutado + // Áudio/vídeo mutado/desmutado conference.on(JitsiMeetJS.constants.events.conference.TRACK_MUTE_CHANGED, (track: unknown) => { const jitsiTrack = track as JitsiTrack; console.log('🎤 Mute mudou:', jitsiTrack); - if (jitsiTrack.getType() === 'audio') { - // Atualizar estado do participante - atualizarListaParticipantes(); - } - }); - - // Vídeo mutado/desmutado - conference.on(JitsiMeetJS.constants.events.conference.TRACK_MUTE_CHANGED, (track: unknown) => { - const jitsiTrack = track as JitsiTrack; - if (jitsiTrack.getType() === 'video') { - atualizarListaParticipantes(); - } + const participantId = jitsiTrack.getParticipantId(); + + // Atualizar estado do participante + atualizarListaParticipantes().then(() => { + // Atualizar indicadores visuais + atualizarIndicadoresParticipante(participantId); + }); }); // Novo track remoto @@ -458,9 +527,34 @@ } ); + // Monitorar qualidade de conexão (se disponível) + if ('getConnectionQuality' in conference && typeof conference.getConnectionQuality === 'function') { + setInterval(() => { + try { + // Tentar obter estatísticas de conexão + const stats = (conference as unknown as { getConnectionQuality(): string }).getConnectionQuality(); + if (stats === 'high' || stats === 'veryhigh') { + qualidadeConexao = 'excelente'; + } else if (stats === 'medium') { + qualidadeConexao = 'boa'; + } else if (stats === 'low') { + qualidadeConexao = 'regular'; + } else { + qualidadeConexao = 'ruim'; + } + } catch { + // Se não disponível, manter como boa + if (qualidadeConexao === 'desconhecida') { + qualidadeConexao = 'boa'; + } + } + }, 5000); // Verificar a cada 5 segundos + } + // Conferência iniciada - criar tracks locais conference.on(JitsiMeetJS.constants.events.conference.CONFERENCE_JOINED, async () => { console.log('🎉 Conferência iniciada! Criando tracks locais...'); + qualidadeConexao = 'boa'; // Inicializar como boa ao entrar na conferência try { const estadoAtual = get(callState); @@ -522,7 +616,13 @@ } // Atualizar lista de participantes - atualizarListaParticipantes(); + atualizarListaParticipantes().then(() => { + // Atualizar todos os nomes e indicadores + remoteVideoElements.forEach((_, participantId) => { + atualizarNomeParticipante(participantId); + atualizarIndicadoresParticipante(participantId); + }); + }); } catch (error) { console.error('Erro ao criar tracks locais:', error); handleError( @@ -543,11 +643,30 @@ }); localTracks = []; + // Limpar elementos remotos + remoteVideoElements.forEach((data) => { + const videoElement = data.element.querySelector('video'); + if (videoElement) { + data.track.detach(videoElement); + } + data.element.remove(); + }); + remoteVideoElements.clear(); + + remoteAudioElements.forEach((audioElement) => { + audioElement.remove(); + }); + remoteAudioElements.clear(); + setStreamLocal(null); finalizarChamadaStore(); }); } + // Mapa para rastrear elementos de vídeo remotos + let remoteVideoElements = $state>(new Map()); + let remoteAudioElements = $state>(new Map()); + // Adicionar track remoto ao container function adicionarTrackRemoto(track: JitsiTrack): void { if (!videoContainer) return; @@ -557,6 +676,11 @@ // Para áudio, criar elemento de áudio invisível if (trackType === 'audio') { + // Verificar se já existe + if (remoteAudioElements.has(participantId)) { + return; + } + const audioElement = document.createElement('audio'); audioElement.id = `remote-audio-${participantId}`; audioElement.autoplay = true; @@ -565,19 +689,115 @@ track.attach(audioElement); videoContainer.appendChild(audioElement); + remoteAudioElements.set(participantId, audioElement); return; } - // Para vídeo, criar elemento de vídeo + // Para vídeo, criar elemento de vídeo com container melhorado if (trackType === 'video') { + // Verificar se já existe + if (remoteVideoElements.has(participantId)) { + return; + } + + // Criar container para o vídeo com indicadores + const container = document.createElement('div'); + container.id = `remote-video-container-${participantId}`; + container.className = 'relative aspect-video w-full rounded-lg bg-base-200 overflow-hidden shadow-lg transition-all duration-300'; + + // Criar elemento de vídeo const videoElement = document.createElement('video'); videoElement.id = `remote-video-${participantId}`; videoElement.autoplay = true; videoElement.playsInline = true; - videoElement.className = 'h-full w-full object-cover rounded-lg'; + videoElement.className = 'h-full w-full object-cover'; + + // Criar overlay com nome do participante + const overlay = document.createElement('div'); + overlay.className = 'absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2'; + + const nomeParticipante = document.createElement('div'); + nomeParticipante.className = 'text-white text-sm font-medium'; + nomeParticipante.textContent = 'Participante'; + + // Criar indicadores de áudio/vídeo + const indicators = document.createElement('div'); + indicators.className = 'absolute top-2 right-2 flex gap-1'; + + // Indicador de áudio + const audioIndicator = document.createElement('div'); + audioIndicator.id = `audio-indicator-${participantId}`; + audioIndicator.className = 'bg-base-300 opacity-80 rounded-full p-1'; + audioIndicator.innerHTML = ''; + + // Indicador de vídeo + const videoIndicator = document.createElement('div'); + videoIndicator.id = `video-indicator-${participantId}`; + videoIndicator.className = 'bg-base-300 opacity-80 rounded-full p-1'; + videoIndicator.innerHTML = ''; + + indicators.appendChild(audioIndicator); + indicators.appendChild(videoIndicator); + overlay.appendChild(nomeParticipante); + + container.appendChild(videoElement); + container.appendChild(overlay); + container.appendChild(indicators); track.attach(videoElement); - videoContainer.appendChild(videoElement); + videoContainer.appendChild(container); + remoteVideoElements.set(participantId, { element: container, track }); + + // Atualizar nome do participante se disponível + atualizarNomeParticipante(participantId); + } + } + + // Atualizar nome do participante no overlay + function atualizarNomeParticipante(participantId: string): void { + const container = remoteVideoElements.get(participantId); + if (!container) return; + + const overlay = container.element.querySelector('.absolute.bottom-0'); + if (!overlay) return; + + const nomeElement = overlay.querySelector('.text-white'); + if (!nomeElement) return; + + // Buscar nome do participante no estado + const participante = estadoChamada.participantes.find( + p => p.participantId === participantId + ); + + if (participante) { + nomeElement.textContent = participante.nome; + } + } + + // Atualizar indicadores de áudio/vídeo + function atualizarIndicadoresParticipante(participantId: string): void { + const container = remoteVideoElements.get(participantId); + if (!container) return; + + const participante = estadoChamada.participantes.find( + p => p.participantId === participantId + ); + + if (!participante) return; + + const audioIndicator = container.element.querySelector(`#audio-indicator-${participantId}`); + const videoIndicator = container.element.querySelector(`#video-indicator-${participantId}`); + + if (audioIndicator) { + audioIndicator.className = participante.audioHabilitado + ? 'bg-success opacity-80 rounded-full p-1' + : 'bg-error opacity-80 rounded-full p-1'; + } + + if (videoIndicator) { + videoIndicator.className = participante.videoHabilitado + ? 'bg-success opacity-80 rounded-full p-1' + : 'bg-error opacity-80 rounded-full p-1'; } } @@ -587,14 +807,24 @@ const participantId = track.getParticipantId(); const trackType = track.getType(); - const elementId = trackType === 'audio' - ? `remote-audio-${participantId}` - : `remote-video-${participantId}`; - const element = document.getElementById(elementId); - if (element) { - track.detach(element); - element.remove(); + if (trackType === 'audio') { + const audioElement = remoteAudioElements.get(participantId); + if (audioElement) { + track.detach(audioElement); + audioElement.remove(); + remoteAudioElements.delete(participantId); + } + } else if (trackType === 'video') { + const videoData = remoteVideoElements.get(participantId); + if (videoData) { + const videoElement = videoData.element.querySelector('video'); + if (videoElement) { + track.detach(videoElement); + } + videoData.element.remove(); + remoteVideoElements.delete(participantId); + } } } @@ -605,13 +835,13 @@ const participants = jitsiConference.getParticipants(); // Mapear participantes para o formato esperado // Isso pode ser expandido para buscar informações do backend - const participantesAtualizados = Array.from(participants.values()).map((p: unknown) => { - const participant = p as { getId(): string; getDisplayName(): string; isAudioMuted(): boolean; isVideoMuted(): boolean }; + const participantesAtualizados = Array.from(participants.values()).map((participant) => { return { usuarioId: participant.getId() as Id<'usuarios'>, nome: participant.getDisplayName() || 'Participante', audioHabilitado: !participant.isAudioMuted(), - videoHabilitado: !participant.isVideoMuted() + videoHabilitado: !participant.isVideoMuted(), + participantId: participant.getId() }; }); @@ -937,7 +1167,12 @@ // Polyfill BlobBuilder já deve estar disponível via app.html // Verificar se está disponível - if (typeof (window as any).BlobBuilder === 'undefined') { + const windowWithBlobBuilder = window as WindowWithBlobBuilder; + if ( + typeof windowWithBlobBuilder.BlobBuilder === 'undefined' && + typeof windowWithBlobBuilder.webkitBlobBuilder === 'undefined' && + typeof windowWithBlobBuilder.MozBlobBuilder === 'undefined' + ) { console.warn('⚠️ Polyfill BlobBuilder não encontrado no onMount'); } @@ -1004,12 +1239,24 @@ {/if} -
+ {#if true} + {@const numVideosRemotos = remoteVideoElements.size} + {@const temVideoLocal = tipo === 'video' && estadoChamada.videoHabilitado && localVideo} + {@const totalVideos = (temVideoLocal ? 1 : 0) + numVideosRemotos} + {@const usarGrid = estadoChamada.estaConectado && totalVideos > 0} + {@const numColunas = totalVideos === 1 ? 1 : totalVideos <= 4 ? 2 : 3} +
{#if !estadoChamada.estaConectado} -
+

Conectando à chamada...

@@ -1021,19 +1268,62 @@ {:else} {#if tipo === 'video' && estadoChamada.videoHabilitado && localVideo} -
+
+ +
+
+ {meuPerfil?.data?.nome || 'Você'} +
+
+ +
+
+ + + +
+
+ + + +
+
+
+ {:else if tipo === 'audio'} + +
+
+
+ + + +
+

Chamada de Áudio

+

+ {estadoChamada.participantes.length} participante{estadoChamada.participantes.length !== 1 ? 's' : ''} +

+
{/if} {/if} -
+
+ {/if} {#if ehAnfitriao && estadoChamada.participantes.length > 0} diff --git a/apps/web/src/lib/components/chat/ChatWindow.svelte b/apps/web/src/lib/components/chat/ChatWindow.svelte index 17a37d2..20f1053 100644 --- a/apps/web/src/lib/components/chat/ChatWindow.svelte +++ b/apps/web/src/lib/components/chat/ChatWindow.svelte @@ -145,7 +145,7 @@ } // Funções para chamadas - async function iniciarChamada(tipo: 'audio' | 'video'): Promise { + async function iniciarChamada(tipo: 'audio' | 'video', abrirEmNovaJanela: boolean = false): Promise { if (chamadaAtual) { errorTitle = 'Chamada já em andamento'; errorMessage = @@ -165,6 +165,45 @@ videoHabilitado: tipo === 'video' }); + // Se deve abrir em nova janela + if (abrirEmNovaJanela && browser) { + const { abrirCallWindowEmPopup, verificarSuportePopup } = await import('$lib/utils/callWindowManager'); + + if (!verificarSuportePopup()) { + errorTitle = 'Popups bloqueados'; + errorMessage = 'Seu navegador está bloqueando popups. Por favor, permita popups para este site e tente novamente.'; + errorInstructions = 'Verifique as configurações do seu navegador para permitir popups.'; + showErrorModal = true; + return; + } + + // Buscar informações da chamada para obter roomName + const chamadaInfo = await client.query(api.chamadas.obterChamada, { chamadaId }); + + if (!chamadaInfo) { + throw new Error('Chamada não encontrada'); + } + + const meuPerfil = await client.query(api.auth.getCurrentUser, {}); + const ehAnfitriao = chamadaInfo.criadoPor === meuPerfil?._id; + + // Abrir em popup + const popup = abrirCallWindowEmPopup({ + chamadaId: chamadaId as string, + conversaId: conversaId as string, + tipo, + roomName: chamadaInfo.roomName, + ehAnfitriao + }); + + if (!popup) { + throw new Error('Não foi possível abrir a janela de chamada'); + } + + // Não definir chamadaAtiva aqui, pois será gerenciada pela janela popup + return; + } + chamadaAtiva = chamadaId; } catch (error) { console.error('Erro ao iniciar chamada:', error); @@ -316,33 +355,99 @@
- {#if !chamadaAtual} - - + {#if !chamadaAtual && !chamadaAtiva} + + {/if} diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index 8a16bc1..d06a0fc 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -108,9 +108,14 @@ } // Verificar permissões de localização e webcam - async function verificarPermissoes(): Promise<{ localizacao: boolean; webcam: boolean }> { + async function verificarPermissoes(): Promise<{ + localizacao: boolean; + webcam: boolean; + permissoesNecessarias: string[]; + }> { let localizacaoAutorizada = false; let webcamAutorizada = false; + const permissoesNecessarias: string[] = []; // Verificar permissão de geolocalização if (navigator.geolocation) { @@ -126,8 +131,11 @@ localizacaoAutorizada = true; resolve(); }, - () => { + (error) => { clearTimeout(timeoutId); + if (error.code === error.PERMISSION_DENIED) { + permissoesNecessarias.push('localização'); + } reject(new Error('Permissão de localização negada')); }, { timeout: 5000, maximumAge: 0, enableHighAccuracy: false } @@ -147,10 +155,11 @@ stream.getTracks().forEach(track => track.stop()); } catch (error) { console.warn('Permissão de webcam não concedida:', error); + permissoesNecessarias.push('câmera'); } } - return { localizacao: localizacaoAutorizada, webcam: webcamAutorizada }; + return { localizacao: localizacaoAutorizada, webcam: webcamAutorizada, permissoesNecessarias }; } async function registrarPonto() { @@ -176,7 +185,8 @@ const permissoes = await verificarPermissoes(); if (!permissoes.localizacao || !permissoes.webcam) { mensagemErroModal = 'Permissões necessárias'; - detalhesErroModal = 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.'; + const permissoesLista = permissoes.permissoesNecessarias.join(', '); + detalhesErroModal = `Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.\n\nPermissões negadas: ${permissoesLista || 'localização e/ou câmera'}`; mostrarModalErro = true; return; } @@ -188,6 +198,8 @@ try { // Coletar informações do dispositivo const informacoesDispositivo = await obterInformacoesDispositivo(); + // Nota: A permissão de sensor não é impeditiva - apenas câmera e localização são obrigatórias + coletandoInfo = false; // Obter tempo sincronizado e aplicar GMT offset (igual ao relógio) @@ -278,30 +290,80 @@ }, 1000); } catch (error) { console.error('Erro ao registrar ponto:', error); - const mensagemErro = error instanceof Error ? error.message : 'Erro ao registrar ponto'; + let mensagemErro = 'Erro desconhecido ao registrar ponto'; + let detalhesErro = 'Tente novamente em alguns instantes.'; - // Verificar se é erro de registro duplicado - if ( - mensagemErro.includes('Já existe um registro neste minuto') || - mensagemErro.includes('já existe um registro') - ) { - mensagemErroModal = 'Registro de ponto duplicado'; - const tipoLabelErro = config - ? getTipoRegistroLabel(proximoTipo, { - nomeEntrada: config.nomeEntrada, - nomeSaidaAlmoco: config.nomeSaidaAlmoco, - nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida, - }) - : getTipoRegistroLabel(proximoTipo); - detalhesErroModal = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${tipoLabelErro} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`; - mostrarModalErro = true; - } else { - // Outros erros também mostram no modal - mensagemErroModal = 'Erro ao registrar ponto'; - detalhesErroModal = mensagemErro; - mostrarModalErro = true; + if (error instanceof Error) { + const erroMessage = error.message || ''; + + // Erro de registro duplicado + if ( + erroMessage.includes('Já existe um registro neste minuto') || + erroMessage.includes('já existe um registro') + ) { + mensagemErro = 'Registro de ponto duplicado'; + const tipoLabelErro = config + ? getTipoRegistroLabel(proximoTipo, { + nomeEntrada: config.nomeEntrada, + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + nomeSaida: config.nomeSaida, + }) + : getTipoRegistroLabel(proximoTipo); + detalhesErro = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${tipoLabelErro} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`; + } + // Erro de validação de argumentos + else if ( + erroMessage.includes('ArgumentValidationError') || + erroMessage.includes('Object contains extra field') || + erroMessage.includes('validation') + ) { + mensagemErro = 'Erro na validação dos dados'; + detalhesErro = 'Ocorreu um erro ao validar as informações do dispositivo.\n\nPor favor, tente novamente ou recarregue a página.'; + } + // Erro de autenticação + else if ( + erroMessage.includes('não autenticado') || + erroMessage.includes('autenticado') || + erroMessage.includes('auth') + ) { + mensagemErro = 'Erro de autenticação'; + detalhesErro = 'Sua sessão pode ter expirado. Por favor, faça login novamente.'; + } + // Erro de permissão/validação de localização + else if ( + erroMessage.includes('localização') || + erroMessage.includes('Localização') || + erroMessage.includes('location') + ) { + mensagemErro = 'Erro na validação de localização'; + detalhesErro = 'Não foi possível validar sua localização.\n\nPor favor, verifique se você autorizou o compartilhamento de localização e tente novamente.'; + } + // Erro genérico do servidor + else if (erroMessage.includes('Server Error') || erroMessage.includes('Server')) { + mensagemErro = 'Erro no servidor'; + detalhesErro = 'Ocorreu um erro no servidor ao processar seu registro.\n\nPor favor, tente novamente em alguns instantes.'; + } + // Outros erros - mostrar mensagem simplificada + else { + mensagemErro = 'Erro ao registrar ponto'; + // Se a mensagem de erro for muito técnica, mostrar mensagem genérica + if ( + erroMessage.includes('Error:') || + erroMessage.includes('TypeError') || + erroMessage.includes('ReferenceError') || + erroMessage.length > 200 + ) { + detalhesErro = 'Ocorreu um erro ao processar o registro.\n\nPor favor, tente novamente ou recarregue a página.'; + } else { + detalhesErro = erroMessage; + } + } } + + mensagemErroModal = mensagemErro; + detalhesErroModal = detalhesErro; + mostrarModalErro = true; } finally { registrando = false; coletandoInfo = false; diff --git a/apps/web/src/lib/components/ti/SystemMonitorCardLocal.svelte b/apps/web/src/lib/components/ti/SystemMonitorCardLocal.svelte index 766e9ae..bf35e9f 100644 --- a/apps/web/src/lib/components/ti/SystemMonitorCardLocal.svelte +++ b/apps/web/src/lib/components/ti/SystemMonitorCardLocal.svelte @@ -9,6 +9,7 @@ import AreaChart from './charts/AreaChart.svelte'; import DoughnutChart from './charts/DoughnutChart.svelte'; import BarChart from './charts/BarChart.svelte'; + import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo'; const client = useConvexClient(); @@ -45,6 +46,18 @@ serviceWorkerStatus: string; domNodes: number; jsHeapSize: number; + // GPS + latitude?: number; + longitude?: number; + gpsPrecision?: number; + gpsConfidence?: number; + // Acelerômetro + accelerometerX?: number; + accelerometerY?: number; + accelerometerZ?: number; + movementDetected?: boolean; + movementMagnitude?: number; + sensorAvailable?: boolean; }; type NetworkInformationEx = { @@ -548,7 +561,8 @@ usuariosOnline, tempoRespostaMedio, batteryInfo, - indexedDBSize + indexedDBSize, + deviceInfo ] = await Promise.all([ estimateCPU(), Promise.resolve(getMemoryUsage()), @@ -557,14 +571,15 @@ getUsuariosOnline(), getResponseTime(), getBatteryInfo(), - getIndexedDBSize() + getIndexedDBSize(), + obterInformacoesDispositivo().catch(() => ({}) as Record) // Capturar erro se falhar ]); const browserInfo = getBrowserInfo(); const networkInfo = getNetworkInfo(); const wsInfo = getWebSocketStatus(); - const newMetrics = { + const newMetrics: Metrics = { timestamp: Date.now(), cpuUsage, memoryUsage, @@ -594,7 +609,19 @@ indexedDBSize, serviceWorkerStatus: getServiceWorkerStatus(), domNodes: getDOMNodeCount(), - jsHeapSize: getJSHeapSize() + jsHeapSize: getJSHeapSize(), + // GPS + latitude: deviceInfo.latitude, + longitude: deviceInfo.longitude, + gpsPrecision: deviceInfo.precisao, + gpsConfidence: deviceInfo.confiabilidadeGPS, + // Acelerômetro + accelerometerX: deviceInfo.acelerometro?.x, + accelerometerY: deviceInfo.acelerometro?.y, + accelerometerZ: deviceInfo.acelerometro?.z, + movementDetected: deviceInfo.acelerometro?.movimentoDetectado, + movementMagnitude: deviceInfo.acelerometro?.magnitude, + sensorAvailable: deviceInfo.sensorDisponivel }; // Resetar contadores @@ -1445,6 +1472,181 @@
+ +
+
+

+ + + + + GPS e Sensores +

+
+ +
+ + {#if metrics.latitude !== undefined} +
+
Latitude
+
{metrics.latitude.toFixed(6)}
+
+
GPS
+
+
+ {/if} + + + {#if metrics.longitude !== undefined} +
+
Longitude
+
{metrics.longitude.toFixed(6)}
+
+
GPS
+
+
+ {/if} + + + {#if metrics.gpsPrecision !== undefined} +
+
Precisão GPS
+
{metrics.gpsPrecision.toFixed(2)}m
+
+
Precisão
+
+
+ {/if} + + + {#if metrics.gpsConfidence !== undefined} +
+
Confiança GPS
+
+ {(metrics.gpsConfidence * 100).toFixed(1)}% +
+
+
Confiança
+
+
+ {/if} + + + {#if metrics.accelerometerX !== undefined} +
+
Acelerômetro X
+
{metrics.accelerometerX.toFixed(3)}
+
+
m/s²
+
+
+ {/if} + + + {#if metrics.accelerometerY !== undefined} +
+
Acelerômetro Y
+
{metrics.accelerometerY.toFixed(3)}
+
+
m/s²
+
+
+ {/if} + + + {#if metrics.accelerometerZ !== undefined} +
+
Acelerômetro Z
+
{metrics.accelerometerZ.toFixed(3)}
+
+
m/s²
+
+
+ {/if} + + + {#if metrics.movementMagnitude !== undefined} +
+
Magnitude de Movimento
+
{metrics.movementMagnitude.toFixed(3)}
+
+
m/s²
+
+
+ {/if} + + + {#if metrics.movementDetected !== undefined} +
+
Movimento Detectado
+
+ {metrics.movementDetected ? 'Sim' : 'Não'} +
+
+
+ {metrics.movementDetected ? 'Ativo' : 'Inativo'} +
+
+
+ {/if} + + + {#if metrics.sensorAvailable !== undefined} +
+
Sensor Disponível
+
+ {metrics.sensorAvailable ? 'Sim' : 'Não'} +
+
+
+ {metrics.sensorAvailable ? 'Disponível' : 'Indisponível'} +
+
+
+ {/if} +
+
+ {#if metricsHistory.length > 5}
@@ -1608,14 +1810,16 @@

- Monitoramento Ativo (Modo Local) - 23 Métricas Técnicas + 4 Gráficos Interativos + Monitoramento Ativo (Modo Local) - Métricas Técnicas + GPS + Sensores + 4 Gráficos Interativos

Sistema: CPU, RAM, Latência, Storage | Aplicação: Usuários, Mensagens, Tempo Resposta, Erros, HTTP Requests | Performance: FPS, Conexão, Navegador, Tela | Hardware: RAM Física, Núcleos CPU, Cache, Bateria, Uptime | - Avançado: WebSocket, IndexedDB, Service Worker, DOM Nodes, JS Heap + Avançado: WebSocket, IndexedDB, Service Worker, DOM Nodes, JS Heap | + GPS: Latitude, Longitude, Precisão, Confiança | + Sensores: Acelerômetro (X, Y, Z), Magnitude, Movimento Detectado
Gráficos: Linha (Recursos), Área (Atividade), Donut (Distribuição), Barras (Métricas) diff --git a/apps/web/src/lib/stores/callStore.ts b/apps/web/src/lib/stores/callStore.ts index 6fe5022..adc2bf5 100644 --- a/apps/web/src/lib/stores/callStore.ts +++ b/apps/web/src/lib/stores/callStore.ts @@ -4,6 +4,7 @@ import { writable, derived, get } from 'svelte/store'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; +import type { JitsiApi } from '$lib/types/jitsi'; export interface ParticipanteChamada { usuarioId: Id<'usuarios'>; @@ -32,7 +33,7 @@ export interface EstadoChamada { cameraId: string | null; speakerId: string | null; }; - jitsiApi: any | null; + jitsiApi: JitsiApi; streamLocal: MediaStream | null; } @@ -282,7 +283,7 @@ export function atualizarDispositivos(dispositivos: { /** * Definir API Jitsi */ -export function setJitsiApi(api: any | null): void { +export function setJitsiApi(api: JitsiApi): void { callState.update((state) => ({ ...state, jitsiApi: api diff --git a/apps/web/src/lib/types/jitsi.d.ts b/apps/web/src/lib/types/jitsi.d.ts new file mode 100644 index 0000000..c202b6d --- /dev/null +++ b/apps/web/src/lib/types/jitsi.d.ts @@ -0,0 +1,331 @@ +/** + * Definições de tipo para lib-jitsi-meet + * Baseado na documentação oficial do Jitsi Meet + */ + +export interface JitsiConnection { + connect(): void; + disconnect(): void; + addEventListener(event: string, handler: (data?: unknown) => void): void; + removeEventListener(event: string, handler: (data?: unknown) => void): void; + initJitsiConference(roomName: string, options: Record): JitsiConference; +} + +export interface JitsiConference { + join(): void; + leave(): void; + on(event: string, handler: (...args: unknown[]) => void): void; + off(event: string, handler: (...args: unknown[]) => void): void; + removeEventListener(event: string, handler: (...args: unknown[]) => void): void; + muteAudio(): void; + unmuteAudio(): void; + muteVideo(): void; + unmuteVideo(): void; + getParticipants(): Map; + getLocalTracks(): JitsiTrack[]; + setDisplayName(name: string): void; + addTrack(track: JitsiTrack): Promise; + removeTrack(track: JitsiTrack): Promise; + getLocalVideoTrack(): JitsiTrack | null; + getLocalAudioTrack(): JitsiTrack | null; +} + +export interface JitsiTrack { + getType(): 'audio' | 'video'; + isMuted(): boolean; + mute(): Promise; + unmute(): Promise; + attach(element: HTMLElement): void; + detach(element: HTMLElement): void; + dispose(): Promise; + getParticipantId(): string; + track: MediaStreamTrack; + isLocal(): boolean; + getVideoType(): 'camera' | 'desktop' | undefined; +} + +export interface JitsiParticipant { + getId(): string; + getDisplayName(): string; + isAudioMuted(): boolean; + isVideoMuted(): boolean; + getRole(): string; +} + +export interface JitsiMeetJSLib { + JitsiConnection: new ( + appId: string | null, + token: string | null, + options: JitsiConnectionOptions + ) => JitsiConnection; + constants: { + events: { + connection: { + CONNECTION_ESTABLISHED: string; + CONNECTION_FAILED: string; + CONNECTION_DISCONNECTED: string; + WRONG_STATE: string; + }; + conference: { + USER_JOINED: string; + USER_LEFT: string; + TRACK_ADDED: string; + TRACK_REMOVED: string; + TRACK_MUTE_CHANGED: string; + CONFERENCE_JOINED: string; + CONFERENCE_LEFT: string; + CONFERENCE_ERROR: string; + DISPLAY_NAME_CHANGED: string; + DOMINANT_SPEAKER_CHANGED: string; + }; + }; + logLevels: { + ERROR: number; + WARN: number; + INFO: number; + DEBUG: number; + }; + }; + init(options: JitsiInitOptions): void; + setLogLevel(level: number): void; + createLocalTracks( + options: MediaStreamConstraints, + advancedOptions?: JitsiLocalTrackOptions + ): Promise; + mediaDevices: { + enumerateDevices(): Promise; + isDeviceChangeAvailable(type: 'audio' | 'video'): Promise; + }; +} + +export interface JitsiConnectionOptions { + hosts?: { + domain?: string; + muc?: string; + focus?: string; + }; + serviceUrl?: string; + bosh?: string; + websocket?: string; + clientNode?: string; + useStunTurn?: boolean; + iceServers?: RTCIceServer[]; + enableLayerSuspension?: boolean; + enableLipSync?: boolean; + disableAudioLevels?: boolean; + disableSimulcast?: boolean; + enableRemb?: boolean; + enableTcc?: boolean; + useRoomAsSharedDocumentName?: boolean; + enableStatsID?: boolean; + channelLastN?: number; + startBitrate?: number; + stereo?: boolean; + forcedVideoCodec?: string; + preferredVideoCodec?: string; + disableH264?: boolean; + disableVP8?: boolean; + disableVP9?: boolean; + enableOpusRed?: boolean; + enableDtmf?: boolean; + openBridgeChannel?: string | boolean; +} + +export interface JitsiInitOptions { + disableAudioLevels?: boolean; + disableSimulcast?: boolean; + enableWindowOnErrorHandler?: boolean; + enableRemb?: boolean; + enableTcc?: boolean; + disableThirdPartyRequests?: boolean; + useStunTurn?: boolean; + iceServers?: RTCIceServer[]; +} + +export interface JitsiLocalTrackOptions { + devices?: string[]; + cameraDeviceId?: string; + micDeviceId?: string; + facingMode?: 'user' | 'environment'; + resolution?: number; + frameRate?: number; +} + +// Extensão do Window para BlobBuilder (polyfill) +export interface WindowWithBlobBuilder extends Window { + BlobBuilder?: { + new (): { + append(data: Blob | string): void; + getBlob(type?: string): Blob; + }; + }; + webkitBlobBuilder?: { + new (): { + append(data: Blob | string): void; + getBlob(type?: string): Blob; + }; + }; + MozBlobBuilder?: { + new (): { + append(data: Blob | string): void; + getBlob(type?: string): Blob; + }; + }; +} + +// Tipo para API Jitsi (pode ser Connection ou Conference) +export type JitsiApi = JitsiConnection | JitsiConference | null; + +// Declaração de módulo para lib-jitsi-meet +declare module 'lib-jitsi-meet' { + export interface JitsiConnection { + connect(): void; + disconnect(): void; + addEventListener(event: string, handler: (data?: unknown) => void): void; + removeEventListener(event: string, handler: (data?: unknown) => void): void; + initJitsiConference(roomName: string, options: Record): JitsiConference; + } + + export interface JitsiConference { + join(): void; + leave(): void; + on(event: string, handler: (...args: unknown[]) => void): void; + off(event: string, handler: (...args: unknown[]) => void): void; + removeEventListener(event: string, handler: (...args: unknown[]) => void): void; + muteAudio(): void; + unmuteAudio(): void; + muteVideo(): void; + unmuteVideo(): void; + getParticipants(): Map; + getLocalTracks(): JitsiTrack[]; + setDisplayName(name: string): void; + addTrack(track: JitsiTrack): Promise; + removeTrack(track: JitsiTrack): Promise; + getLocalVideoTrack(): JitsiTrack | null; + getLocalAudioTrack(): JitsiTrack | null; + } + + export interface JitsiTrack { + getType(): 'audio' | 'video'; + isMuted(): boolean; + mute(): Promise; + unmute(): Promise; + attach(element: HTMLElement): void; + detach(element: HTMLElement): void; + dispose(): Promise; + getParticipantId(): string; + track: MediaStreamTrack; + isLocal(): boolean; + getVideoType(): 'camera' | 'desktop' | undefined; + } + + export interface JitsiParticipant { + getId(): string; + getDisplayName(): string; + isAudioMuted(): boolean; + isVideoMuted(): boolean; + getRole(): string; + } + + export interface JitsiMeetJSLib { + JitsiConnection: new ( + appId: string | null, + token: string | null, + options: JitsiConnectionOptions + ) => JitsiConnection; + constants: { + events: { + connection: { + CONNECTION_ESTABLISHED: string; + CONNECTION_FAILED: string; + CONNECTION_DISCONNECTED: string; + WRONG_STATE: string; + }; + conference: { + USER_JOINED: string; + USER_LEFT: string; + TRACK_ADDED: string; + TRACK_REMOVED: string; + TRACK_MUTE_CHANGED: string; + CONFERENCE_JOINED: string; + CONFERENCE_LEFT: string; + CONFERENCE_ERROR: string; + DISPLAY_NAME_CHANGED: string; + DOMINANT_SPEAKER_CHANGED: string; + }; + }; + logLevels: { + ERROR: number; + WARN: number; + INFO: number; + DEBUG: number; + }; + }; + init(options: JitsiInitOptions): void; + setLogLevel(level: number): void; + createLocalTracks( + options: MediaStreamConstraints, + advancedOptions?: JitsiLocalTrackOptions + ): Promise; + mediaDevices: { + enumerateDevices(): Promise; + isDeviceChangeAvailable(type: 'audio' | 'video'): Promise; + }; + } + + export interface JitsiConnectionOptions { + hosts?: { + domain?: string; + muc?: string; + focus?: string; + }; + serviceUrl?: string; + bosh?: string; + websocket?: string; + clientNode?: string; + useStunTurn?: boolean; + iceServers?: RTCIceServer[]; + enableLayerSuspension?: boolean; + enableLipSync?: boolean; + disableAudioLevels?: boolean; + disableSimulcast?: boolean; + enableRemb?: boolean; + enableTcc?: boolean; + useRoomAsSharedDocumentName?: boolean; + enableStatsID?: boolean; + channelLastN?: number; + startBitrate?: number; + stereo?: boolean; + forcedVideoCodec?: string; + preferredVideoCodec?: string; + disableH264?: boolean; + disableVP8?: boolean; + disableVP9?: boolean; + enableOpusRed?: boolean; + enableDtmf?: boolean; + openBridgeChannel?: string | boolean; + } + + export interface JitsiInitOptions { + disableAudioLevels?: boolean; + disableSimulcast?: boolean; + enableWindowOnErrorHandler?: boolean; + enableRemb?: boolean; + enableTcc?: boolean; + disableThirdPartyRequests?: boolean; + useStunTurn?: boolean; + iceServers?: RTCIceServer[]; + } + + export interface JitsiLocalTrackOptions { + devices?: string[]; + cameraDeviceId?: string; + micDeviceId?: string; + facingMode?: 'user' | 'environment'; + resolution?: number; + frameRate?: number; + } + + const JitsiMeetJS: JitsiMeetJSLib; + export default JitsiMeetJS; +} diff --git a/apps/web/src/lib/utils/callWindowManager.ts b/apps/web/src/lib/utils/callWindowManager.ts new file mode 100644 index 0000000..4bb36ae --- /dev/null +++ b/apps/web/src/lib/utils/callWindowManager.ts @@ -0,0 +1,200 @@ +/** + * Utilitário para gerenciar abertura de CallWindow em nova janela + */ + +export interface CallWindowOptions { + width?: number; + height?: number; + left?: number; + top?: number; + features?: string; +} + +export interface CallWindowData { + chamadaId: string; + conversaId: string; + tipo: 'audio' | 'video'; + roomName: string; + ehAnfitriao: boolean; +} + +const DEFAULT_OPTIONS: Required = { + width: 1280, + height: 720, + left: undefined, + top: undefined, + features: 'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes' +}; + +/** + * Calcular posição centralizada da janela + */ +function calcularPosicaoCentralizada(width: number, height: number): { left: number; top: number } { + const left = window.screenX + (window.outerWidth - width) / 2; + const top = window.screenY + (window.outerHeight - height) / 2; + return { left, top }; +} + +/** + * Abrir CallWindow em nova janela do navegador + */ +export function abrirCallWindowEmPopup( + data: CallWindowData, + options: CallWindowOptions = {} +): Window | null { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + // Calcular posição se não fornecida + let left = opts.left; + let top = opts.top; + + if (left === undefined || top === undefined) { + const posicao = calcularPosicaoCentralizada(opts.width, opts.height); + left = left ?? posicao.left; + top = top ?? posicao.top; + } + + // Construir features da janela + const features = [ + `width=${opts.width}`, + `height=${opts.height}`, + `left=${left}`, + `top=${top}`, + opts.features + ].join(','); + + // Criar URL com dados da chamada + const url = new URL('/call', window.location.origin); + url.searchParams.set('chamadaId', data.chamadaId); + url.searchParams.set('conversaId', data.conversaId); + url.searchParams.set('tipo', data.tipo); + url.searchParams.set('roomName', data.roomName); + url.searchParams.set('ehAnfitriao', String(data.ehAnfitriao)); + + // Abrir janela + const popup = window.open(url.toString(), `call-${data.chamadaId}`, features); + + if (!popup) { + console.error('Falha ao abrir popup. Verifique se o bloqueador de popups está desabilitado.'); + return null; + } + + // Focar na nova janela + popup.focus(); + + // Configurar comunicação via postMessage + configurarComunicacaoPopup(popup, data); + + return popup; +} + +/** + * Configurar comunicação entre janelas usando postMessage + */ +function configurarComunicacaoPopup(popup: Window, data: CallWindowData): void { + // Listener para mensagens da janela popup + const messageHandler = (event: MessageEvent) => { + // Verificar origem + if (event.origin !== window.location.origin) { + return; + } + + // Verificar se a mensagem é da janela popup + if (event.data?.source === 'call-window-popup') { + switch (event.data.type) { + case 'ready': + console.log('CallWindow popup está pronto'); + break; + case 'closed': + console.log('CallWindow popup foi fechado'); + window.removeEventListener('message', messageHandler); + break; + case 'error': + console.error('Erro na CallWindow popup:', event.data.error); + break; + } + } + }; + + window.addEventListener('message', messageHandler); + + // Detectar quando a janela é fechada + const checkClosed = setInterval(() => { + if (popup.closed) { + clearInterval(checkClosed); + window.removeEventListener('message', messageHandler); + console.log('CallWindow popup foi fechado pelo usuário'); + } + }, 1000); +} + +/** + * Verificar se popups estão habilitados + */ +export function verificarSuportePopup(): boolean { + if (typeof window === 'undefined') { + return false; + } + + // Tentar abrir um popup de teste + const testPopup = window.open('about:blank', '_blank', 'width=1,height=1'); + + if (!testPopup) { + return false; + } + + // Fechar popup de teste + testPopup.close(); + return true; +} + +/** + * Obter dados da chamada da URL (para uso na página de popup) + */ +export function obterDadosChamadaDaUrl(): CallWindowData | null { + if (typeof window === 'undefined') { + return null; + } + + const params = new URLSearchParams(window.location.search); + const chamadaId = params.get('chamadaId'); + const conversaId = params.get('conversaId'); + const tipo = params.get('tipo'); + const roomName = params.get('roomName'); + const ehAnfitriao = params.get('ehAnfitriao'); + + if (!chamadaId || !conversaId || !tipo || !roomName) { + return null; + } + + if (tipo !== 'audio' && tipo !== 'video') { + return null; + } + + return { + chamadaId, + conversaId, + tipo, + roomName, + ehAnfitriao: ehAnfitriao === 'true' + }; +} + +/** + * Notificar janela pai sobre eventos + */ +export function notificarJanelaPai(type: string, data?: unknown): void { + if (typeof window === 'undefined' || !window.opener) { + return; + } + + window.opener.postMessage( + { + source: 'call-window-popup', + type, + data + }, + window.location.origin + ); +} + diff --git a/apps/web/src/lib/utils/deviceInfo.ts b/apps/web/src/lib/utils/deviceInfo.ts index ed3526a..6213454 100644 --- a/apps/web/src/lib/utils/deviceInfo.ts +++ b/apps/web/src/lib/utils/deviceInfo.ts @@ -1,5 +1,21 @@ import { getLocalIP } from './browserInfo'; +export interface DadosAcelerometro { + x: number; + y: number; + z: number; + movimentoDetectado: boolean; + magnitude: number; + variacao: number; // Variância entre leituras + timestamp: number; +} + +export interface DadosGiroscopio { + alpha: number; + beta: number; + gamma: number; +} + export interface InformacoesDispositivo { ipAddress?: string; ipPublico?: string; @@ -37,6 +53,10 @@ export interface InformacoesDispositivo { isDesktop?: boolean; connectionType?: string; memoryInfo?: string; + acelerometro?: DadosAcelerometro; + giroscopio?: DadosGiroscopio; + sensorDisponivel?: boolean; // Indica se o sensor está disponível no dispositivo + permissaoNegada?: boolean; // Indica se a permissão foi negada pelo usuário } /** @@ -637,6 +657,145 @@ async function obterIPPublico(): Promise { return undefined; } +/** + * Solicita permissão para acesso aos sensores de movimento (iOS 13+) + */ +async function solicitarPermissaoSensor(): Promise { + if (typeof DeviceMotionEvent === 'undefined' || typeof (DeviceMotionEvent as { requestPermission?: () => Promise }).requestPermission !== 'function') { + // Permissão não necessária ou já concedida (navegadores modernos) + return 'granted'; + } + + try { + const requestPermission = (DeviceMotionEvent as { requestPermission: () => Promise }).requestPermission; + const resultado = await requestPermission(); + return resultado; + } catch (error) { + console.warn('Erro ao solicitar permissão de sensor:', error); + return 'denied'; + } +} + +/** + * Obtém dados de acelerômetro e giroscópio durante um período + * @param duracaoMs Duração da coleta em milissegundos (padrão: 5000ms = 5 segundos) + */ +async function obterDadosAcelerometro(duracaoMs: number = 5000): Promise<{ + acelerometro?: DadosAcelerometro; + giroscopio?: DadosGiroscopio; + sensorDisponivel: boolean; + permissaoNegada: boolean; +}> { + // Verificar se DeviceMotionEvent está disponível + if (typeof DeviceMotionEvent === 'undefined' || typeof DeviceOrientationEvent === 'undefined') { + return { + sensorDisponivel: false, + permissaoNegada: false + }; + } + + // Solicitar permissão (especialmente necessário no iOS 13+) + const permissao = await solicitarPermissaoSensor(); + + if (permissao === 'denied') { + return { + sensorDisponivel: true, + permissaoNegada: true + }; + } + + return new Promise((resolve) => { + const leiturasAcelerometro: Array<{ x: number; y: number; z: number; timestamp: number }> = []; + const leiturasGiroscopio: Array<{ alpha: number; beta: number; gamma: number; timestamp: number }> = []; + + const timeoutId = setTimeout(() => { + window.removeEventListener('devicemotion', handleDeviceMotion); + window.removeEventListener('deviceorientation', handleDeviceOrientation); + + // Processar dados de acelerômetro + let acelerometro: DadosAcelerometro | undefined; + if (leiturasAcelerometro.length > 0) { + const ultimaLeitura = leiturasAcelerometro[leiturasAcelerometro.length - 1]!; + + // Calcular magnitude média + const magnitudes = leiturasAcelerometro.map(l => + Math.sqrt(l.x * l.x + l.y * l.y + l.z * l.z) + ); + const magnitude = magnitudes.reduce((sum, m) => sum + m, 0) / magnitudes.length; + + // Calcular variância para detectar movimento + const mediaX = leiturasAcelerometro.reduce((sum, l) => sum + l.x, 0) / leiturasAcelerometro.length; + const mediaY = leiturasAcelerometro.reduce((sum, l) => sum + l.y, 0) / leiturasAcelerometro.length; + const mediaZ = leiturasAcelerometro.reduce((sum, l) => sum + l.z, 0) / leiturasAcelerometro.length; + + const variacoes = leiturasAcelerometro.map(l => + Math.pow(l.x - mediaX, 2) + Math.pow(l.y - mediaY, 2) + Math.pow(l.z - mediaZ, 2) + ); + const variacao = variacoes.reduce((sum, v) => sum + v, 0) / variacoes.length; + + // Detectar movimento: se variância > 0.01, há movimento + const movimentoDetectado = variacao > 0.01; + + acelerometro = { + x: ultimaLeitura.x, + y: ultimaLeitura.y, + z: ultimaLeitura.z, + movimentoDetectado, + magnitude, + variacao, + timestamp: ultimaLeitura.timestamp + }; + } + + // Processar dados de giroscópio + let giroscopio: DadosGiroscopio | undefined; + if (leiturasGiroscopio.length > 0) { + const ultimaLeitura = leiturasGiroscopio[leiturasGiroscopio.length - 1]!; + giroscopio = { + alpha: ultimaLeitura.alpha || 0, + beta: ultimaLeitura.beta || 0, + gamma: ultimaLeitura.gamma || 0 + }; + } + + resolve({ + acelerometro, + giroscopio, + sensorDisponivel: true, + permissaoNegada: false + }); + }, duracaoMs); + + function handleDeviceMotion(event: DeviceMotionEvent) { + if (event.accelerationIncludingGravity) { + const acc = event.accelerationIncludingGravity; + if (acc.x !== null && acc.y !== null && acc.z !== null) { + leiturasAcelerometro.push({ + x: acc.x, + y: acc.y, + z: acc.z, + timestamp: Date.now() + }); + } + } + } + + function handleDeviceOrientation(event: DeviceOrientationEvent) { + if (event.alpha !== null && event.beta !== null && event.gamma !== null) { + leiturasGiroscopio.push({ + alpha: event.alpha, + beta: event.beta, + gamma: event.gamma, + timestamp: Date.now() + }); + } + } + + window.addEventListener('devicemotion', handleDeviceMotion); + window.addEventListener('deviceorientation', handleDeviceOrientation); + }); +} + /** * Obtém todas as informações do dispositivo */ @@ -675,13 +834,14 @@ export async function obterInformacoesDispositivo(): Promise | ''>(''); + let mostrarModalDetalhes = $state(false); + let registroDetalhesId = $state | ''>(''); let chartCanvas: HTMLCanvasElement; let chartInstance: Chart | null = null; @@ -190,8 +192,12 @@ // Aguardar um pouco para garantir que o canvas está renderizado const timeoutId = setTimeout(() => { - criarGrafico(); - }, 100); + try { + criarGrafico(); + } catch (error) { + console.error('Erro ao criar gráfico no effect:', error); + } + }, 200); return () => { clearTimeout(timeoutId); @@ -201,11 +207,20 @@ // Também tentar criar quando o canvas for montado onMount(() => { - if (chartCanvas && estatisticas && chartData) { - setTimeout(() => { - criarGrafico(); - }, 200); - } + // Tentar criar o gráfico após um pequeno delay para garantir que tudo está renderizado + const timeoutId = setTimeout(() => { + if (chartCanvas && estatisticas && chartData && !chartInstance) { + try { + criarGrafico(); + } catch (error) { + console.error('Erro ao criar gráfico no onMount:', error); + } + } + }, 500); + + return () => { + clearTimeout(timeoutId); + }; }); onDestroy(() => { @@ -683,6 +698,12 @@ minuto: number; dentroDoPrazo: boolean; dentroRaioPermitido: boolean | null | undefined; + acelerometroX?: number | undefined; + acelerometroY?: number | undefined; + acelerometroZ?: number | undefined; + movimentoDetectado?: boolean | undefined; + magnitudeMovimento?: number | undefined; + sensorDisponivel?: boolean | undefined; }> > = {}; @@ -698,6 +719,12 @@ minuto: r.minuto, dentroDoPrazo: r.dentroDoPrazo, dentroRaioPermitido: r.dentroRaioPermitido, + acelerometroX: r.acelerometroX, + acelerometroY: r.acelerometroY, + acelerometroZ: r.acelerometroZ, + movimentoDetectado: r.movimentoDetectado, + magnitudeMovimento: r.magnitudeMovimento, + sensorDisponivel: r.sensorDisponivel, }); } @@ -744,6 +771,21 @@ linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não'); + // Adicionar dados de acelerômetro (se disponível) + if (reg.acelerometroX !== undefined || reg.sensorDisponivel !== undefined) { + if (reg.sensorDisponivel === false && !reg.acelerometroX) { + linha.push('Sensor: Não disponível'); + } else if (reg.acelerometroX !== undefined) { + const movimento = reg.movimentoDetectado ? 'Sim' : 'Não'; + const magnitude = reg.magnitudeMovimento !== undefined ? reg.magnitudeMovimento.toFixed(2) : 'N/A'; + linha.push(`Mov: ${movimento} | Mag: ${magnitude} m/s²`); + } else { + linha.push('Sensor: N/A'); + } + } else { + linha.push('-'); + } + tableData.push(linha); } } @@ -754,6 +796,7 @@ } headers.push('Localização'); headers.push('Dentro do Prazo'); + headers.push('Acelerômetro'); // Salvar a posição Y antes da tabela const yPosAntesTabela = yPosition; @@ -985,6 +1028,20 @@ } } + function abrirModalDetalhes(registroId: Id<'registrosPonto'>) { + if (!registroId) { + console.error('Erro: registroId inválido'); + return; + } + registroDetalhesId = registroId; + mostrarModalDetalhes = true; + } + + function fecharModalDetalhes() { + mostrarModalDetalhes = false; + registroDetalhesId = ''; + } + async function imprimirDetalhesRegistro(registroId: Id<'registrosPonto'>) { try { // Buscar dados completos do registro @@ -1909,12 +1966,7 @@
{:else} - {#if !chartInstance && estatisticas && chartData} -
- -
- {/if} - {/if} + {/if}
@@ -2234,8 +2286,8 @@ +
+ +
+ {#if registroDetalhesQuery === undefined || registroDetalhesQuery?.isLoading} +
+ + Carregando detalhes... +
+ {:else if registroDetalhesQuery?.error} +
+ +
+

Erro ao carregar detalhes

+
{registroDetalhesQuery.error?.message || String(registroDetalhesQuery.error) || 'Erro desconhecido'}
+
+
+ {:else if !registroDetalhes} +
+ + Registro não encontrado +
+ {:else} + +
+
+

Informações do Registro

+ {#if registroDetalhes.funcionario} +

Funcionário: {registroDetalhes.funcionario.nome}

+ {#if registroDetalhes.funcionario.matricula} +

Matrícula: {registroDetalhes.funcionario.matricula}

+ {/if} + {/if} +

Data: {formatarDataDDMMAAAA(registroDetalhes.data)}

+

Horário: {formatarHoraPonto(registroDetalhes.hora, registroDetalhes.minuto)}

+

Status: {registroDetalhes.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}

+
+
+ + + {#if registroDetalhes.latitude !== undefined && registroDetalhes.longitude !== undefined} +
+
+

Localização GPS

+

Latitude: {registroDetalhes.latitude.toFixed(6)}

+

Longitude: {registroDetalhes.longitude.toFixed(6)}

+ {#if registroDetalhes.precisao !== undefined} +

Precisão: {registroDetalhes.precisao.toFixed(2)}m

+ {/if} + {#if registroDetalhes.endereco || registroDetalhes.cidade} +

Endereço: {registroDetalhes.endereco || ''} {registroDetalhes.cidade ? `, ${registroDetalhes.cidade}` : ''} {registroDetalhes.estado ? ` - ${registroDetalhes.estado}` : ''}

+ {/if} + {#if registroDetalhes.confiabilidadeGPS !== undefined} +

Confiabilidade GPS: {(registroDetalhes.confiabilidadeGPS * 100).toFixed(1)}%

+ {/if} + {#if registroDetalhes.scoreConfiancaBackend !== undefined} +

Score de Confiança: {(registroDetalhes.scoreConfiancaBackend * 100).toFixed(1)}%

+ {/if} +
+
+ {/if} + + + {#if registroDetalhes.acelerometroX !== undefined || registroDetalhes.sensorDisponivel !== undefined} +
+
+

Dados de Sensores

+ {#if registroDetalhes.sensorDisponivel === false && registroDetalhes.isDesktop !== true} +

Sensor: Não disponível neste dispositivo

+ {:else if registroDetalhes.permissaoSensorNegada === true} +

Sensor: Permissão negada

+ {:else if registroDetalhes.acelerometroX !== undefined} +

Sensor: Disponível

+

Acelerômetro X: {registroDetalhes.acelerometroX.toFixed(3)} m/s²

+ {#if registroDetalhes.acelerometroY !== undefined} +

Acelerômetro Y: {registroDetalhes.acelerometroY.toFixed(3)} m/s²

+ {/if} + {#if registroDetalhes.acelerometroZ !== undefined} +

Acelerômetro Z: {registroDetalhes.acelerometroZ.toFixed(3)} m/s²

+ {/if} + {#if registroDetalhes.magnitudeMovimento !== undefined} +

Magnitude: {registroDetalhes.magnitudeMovimento.toFixed(3)} m/s²

+ {/if} + {#if registroDetalhes.movimentoDetectado !== undefined} +

Movimento Detectado: {registroDetalhes.movimentoDetectado ? 'Sim' : 'Não'}

+ {/if} + {#if registroDetalhes.variacaoAcelerometro !== undefined} +

Variação: {registroDetalhes.variacaoAcelerometro.toFixed(6)}

+ {/if} + {:else if registroDetalhes.isDesktop === true} +

Sensor: Não disponível em desktop (normal)

+ {/if} +
+
+ {/if} + {/if} +
+ +
+ {#if registroDetalhes} + + {/if} + +
+
+ + +{/if} + diff --git a/apps/web/src/routes/(dashboard)/ti/cibersecurity/+page.svelte b/apps/web/src/routes/(dashboard)/ti/cibersecurity/+page.svelte index 4867b19..1a88d00 100644 --- a/apps/web/src/routes/(dashboard)/ti/cibersecurity/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/cibersecurity/+page.svelte @@ -1,10 +1,6 @@ @@ -27,11 +23,5 @@ Voltar para TI - {#if browser && Comp} - - {:else} -
- Carregando módulo de cibersegurança… -
- {/if} + diff --git a/apps/web/src/routes/call/+page.svelte b/apps/web/src/routes/call/+page.svelte new file mode 100644 index 0000000..9e278ab --- /dev/null +++ b/apps/web/src/routes/call/+page.svelte @@ -0,0 +1,90 @@ + + +
+ {#if erro} +
+
+
+ + + + {erro} +
+ +
+
+ {:else if dadosChamada} + + {:else} +
+ +
+ {/if} +
+ diff --git a/packages/backend/convex/actions/jitsiServer.ts b/packages/backend/convex/actions/jitsiServer.ts index 3159eb8..3c47439 100644 --- a/packages/backend/convex/actions/jitsiServer.ts +++ b/packages/backend/convex/actions/jitsiServer.ts @@ -242,28 +242,36 @@ JWT_APP_SECRET= detalhes.push(`✓ Arquivo .env atualizado: ${envPath}`); } - // 2. Atualizar configuração do Prosody + // 2. Atualizar configuração do Prosody (conforme documentação oficial) const prosodyConfigPath = `${basePath}/prosody/config/${host}.cfg.lua`; const prosodyContent = `-- Configuração Prosody para ${host} -- Gerada automaticamente pelo SGSE +-- Baseado na documentação oficial do Jitsi Meet VirtualHost "${host}" authentication = "anonymous" modules_enabled = { "bosh"; + "websocket"; "ping"; "speakerstats"; "turncredentials"; "presence"; "conference_duration"; + "stats"; } c2s_require_encryption = false allow_anonymous_s2s = false + bosh_max_inactivity = 60 + bosh_max_polling = 5 + bosh_max_stanzas = 5 Component "conference.${host}" "muc" storage = "memory" muc_room_locking = false muc_room_default_public_jids = true + muc_room_cache_size = 1000 + muc_log_presences = true Component "jitsi-videobridge.${host}" component_secret = "" diff --git a/packages/backend/convex/configuracaoRelogio.ts b/packages/backend/convex/configuracaoRelogio.ts index 41c9640..7b2a60e 100644 --- a/packages/backend/convex/configuracaoRelogio.ts +++ b/packages/backend/convex/configuracaoRelogio.ts @@ -4,12 +4,25 @@ import { getCurrentUserFunction } from './auth'; import type { Id } from './_generated/dataModel'; import { api, internal } from './_generated/api'; +/** + * Tipo de retorno da configuração do relógio + */ +type ConfiguracaoRelogioRetorno = { + servidorNTP?: string | undefined; + portaNTP?: number | undefined; + usarServidorExterno: boolean; + fallbackParaPC: boolean; + ultimaSincronizacao: number | null; + offsetSegundos: number | null; + gmtOffset: number; +}; + /** * Obtém a configuração do relógio */ export const obterConfiguracao = query({ args: {}, - handler: async (ctx) => { + handler: async (ctx): Promise => { // Buscar todas as configurações e pegar a mais recente (por atualizadoEm) const configs = await ctx.db .query('configuracaoRelogio') @@ -35,6 +48,46 @@ export const obterConfiguracao = query({ return { ...config, + ultimaSincronizacao: config.ultimaSincronizacao ?? null, // Converter undefined para null + offsetSegundos: config.offsetSegundos ?? null, // Converter undefined para null + gmtOffset: config.gmtOffset ?? -3, // Padrão GMT-3 para Brasília se não configurado + }; + }, +}); + +/** + * Obtém a configuração do relógio (internal) - usado por actions para evitar referência circular + */ +export const obterConfiguracaoInternal = internalQuery({ + args: {}, + handler: async (ctx): Promise => { + // Buscar todas as configurações e pegar a mais recente (por atualizadoEm) + const configs = await ctx.db + .query('configuracaoRelogio') + .collect(); + + // Pegar a configuração mais recente (ordenar por atualizadoEm desc) + const config = configs.length > 0 + ? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0] + : null; + + if (!config) { + // Retornar configuração padrão (GMT-3 para Brasília) + return { + servidorNTP: 'pool.ntp.org', + portaNTP: 123, + usarServidorExterno: false, + fallbackParaPC: true, + ultimaSincronizacao: null, + offsetSegundos: null, + gmtOffset: -3, // GMT-3 para Brasília + }; + } + + return { + ...config, + ultimaSincronizacao: config.ultimaSincronizacao ?? null, // Converter undefined para null + offsetSegundos: config.offsetSegundos ?? null, // Converter undefined para null gmtOffset: config.gmtOffset ?? -3, // Padrão GMT-3 para Brasília se não configurado }; }, @@ -119,15 +172,26 @@ export const obterTempoServidor = query({ }, }); +/** + * Tipo de retorno da sincronização + */ +type SincronizacaoRetorno = { + sucesso: boolean; + timestamp: number; + usandoServidorExterno: boolean; + offsetSegundos: number; + aviso?: string; +}; + /** * Sincroniza tempo com servidor NTP (via action) * Nota: NTP real requer biblioteca específica, aqui fazemos uma aproximação */ export const sincronizarTempo = action({ args: {}, - handler: async (ctx) => { - // Buscar configuração diretamente do banco usando query pública - const config = await ctx.runQuery(api.configuracaoRelogio.obterConfiguracao, {}); + handler: async (ctx): Promise => { + // Buscar configuração usando query interna para evitar referência circular + const config: ConfiguracaoRelogioRetorno = await ctx.runQuery(internal.configuracaoRelogio.obterConfiguracaoInternal, {}); if (!config.usarServidorExterno) { return { @@ -145,66 +209,42 @@ export const sincronizarTempo = action({ const servidorNTP = config.servidorNTP || 'pool.ntp.org'; let serverTime: number; - // Mapear servidores NTP conhecidos para APIs HTTP que retornam UTC - // Todos os servidores NTP retornam UTC, então usamos APIs que retornam UTC - if (servidorNTP.includes('pool.ntp.org') || servidorNTP.includes('ntp.org') || servidorNTP.includes('ntp.br')) { - // pool.ntp.org e servidores .org/.br - usar API que retorna UTC - const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC'); - if (!response.ok) { - throw new Error('Falha ao obter tempo do servidor'); - } - const data = (await response.json()) as { unixtime: number; datetime: string }; - // unixtime está em segundos, converter para milissegundos - serverTime = data.unixtime * 1000; - } else if (servidorNTP.includes('time.google.com') || servidorNTP.includes('google')) { - // Google NTP - usar API que retorna UTC + // Se o servidor configurado for uma URL HTTP/HTTPS, tentar usar diretamente + if (servidorNTP.startsWith('http://') || servidorNTP.startsWith('https://')) { try { - const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC'); + const response = await fetch(servidorNTP); if (!response.ok) { - throw new Error('Falha ao obter tempo'); + throw new Error('Falha ao obter tempo do servidor configurado'); } - const data = (await response.json()) as { unixtime: number }; - serverTime = data.unixtime * 1000; - } catch { - // Fallback para outra API UTC + const data = (await response.json()) as { unixtime?: number; unixTime?: number; unixtimestamp?: number }; + // Tentar diferentes formatos de resposta + if (data.unixtime) { + serverTime = data.unixtime * 1000; // Converter segundos para milissegundos + } else if (data.unixTime) { + serverTime = data.unixTime * 1000; + } else if (data.unixtimestamp) { + serverTime = data.unixtimestamp * 1000; + } else { + throw new Error('Formato de resposta não reconhecido'); + } + } catch (error) { + // Se falhar, tentar APIs genéricas como fallback + throw new Error(`Falha ao usar servidor configurado: ${error}`); + } + } else { + // Para servidores NTP tradicionais (sem HTTP), usar APIs genéricas que retornam UTC + // Não usar worldtimeapi.org hardcoded - usar timeapi.io como primeira opção + try { const response = await fetch('https://timeapi.io/api/Time/current/zone?timeZone=UTC'); if (!response.ok) { - throw new Error('Falha ao obter tempo do servidor'); + throw new Error('Falha ao obter tempo'); } const data = (await response.json()) as { unixTime: number }; serverTime = data.unixTime * 1000; - } - } else if (servidorNTP.includes('time.windows.com') || servidorNTP.includes('windows')) { - // Windows NTP - usar API que retorna UTC - const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC'); - if (!response.ok) { - throw new Error('Falha ao obter tempo do servidor'); - } - const data = (await response.json()) as { unixtime: number }; - serverTime = data.unixtime * 1000; - } else { - // Para outros servidores NTP, usar API genérica que retorna UTC - // Tentar worldtimeapi primeiro - try { - const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC'); - if (!response.ok) { - throw new Error('Falha ao obter tempo'); - } - const data = (await response.json()) as { unixtime: number }; - serverTime = data.unixtime * 1000; } catch { - // Fallback para timeapi.io - try { - const response = await fetch('https://timeapi.io/api/Time/current/zone?timeZone=UTC'); - if (!response.ok) { - throw new Error('Falha ao obter tempo'); - } - const data = (await response.json()) as { unixTime: number }; - serverTime = data.unixTime * 1000; - } catch { - // Último fallback: usar tempo do servidor Convex (já está em UTC) - serverTime = Date.now(); - } + // Fallback: usar tempo do servidor Convex (já está em UTC) + // Não usar worldtimeapi.org como fallback automático + serverTime = Date.now(); } } @@ -234,7 +274,7 @@ export const sincronizarTempo = action({ } catch (error) { // Sempre usar fallback como última opção, mesmo se desabilitado // Isso evita que o sistema trave completamente se o servidor externo não estiver disponível - const aviso = config.fallbackParaPC + const aviso: string = config.fallbackParaPC ? 'Falha ao sincronizar com servidor externo, usando relógio do PC' : 'Falha ao sincronizar com servidor externo. Fallback desabilitado, mas usando relógio do PC como última opção.'; diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 36299b6..0c79c80 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -223,6 +223,92 @@ async function validarLocalizacao( }; } +/** + * Valida dados de acelerômetro para detectar autenticidade do registro + * Retorna informações de validação sem bloquear o registro + */ +function validarAcelerometro( + isDesktop: boolean | undefined, + sensorDisponivel: boolean | undefined, + permissaoSensorNegada: boolean | undefined, + acelerometroX: number | undefined, + acelerometroY: number | undefined, + acelerometroZ: number | undefined, + movimentoDetectado: boolean | undefined, + magnitudeMovimento: number | undefined, + variacaoAcelerometro: number | undefined +): { + valida: boolean; + motivo?: string; + scoreConfianca: number; // 0-1 + avisos: string[]; +} { + const avisos: string[] = []; + let scoreConfianca = 1.0; + + // Se for desktop, ausência de sensor não é suspeito + if (isDesktop === true) { + if (sensorDisponivel === false || !acelerometroX) { + // Desktop não tem sensor - isso é normal, não reduzir confiança + return { + valida: true, + scoreConfianca: 1.0, + avisos: [] + }; + } + } + + // Se permissão foi negada, apenas reduzir score de confiança (não bloqueia registro) + if (permissaoSensorNegada === true) { + scoreConfianca *= 0.9; + avisos.push('Permissão de sensor negada pelo usuário (não bloqueia registro)'); + // Continuar validação normalmente + } + + // Se sensor não está disponível e não é desktop, pode ser suspeito (mas não bloqueia) + if (sensorDisponivel === false && isDesktop !== true) { + scoreConfianca *= 0.8; + avisos.push('Sensor de movimento não disponível no dispositivo móvel'); + } + + // Se sensor está disponível mas não há dados, pode ser suspeito + if (sensorDisponivel === true && (!acelerometroX || !acelerometroY || !acelerometroZ)) { + scoreConfianca *= 0.7; + avisos.push('Sensor disponível mas dados de acelerômetro não coletados'); + } + + // Se há dados de acelerômetro, validar + if (acelerometroX !== undefined && acelerometroY !== undefined && acelerometroZ !== undefined) { + // Verificar se valores são realistas (aceleração geralmente entre -20 e +20 m/s² em uso normal) + const magnitude = magnitudeMovimento || Math.sqrt(acelerometroX * acelerometroX + acelerometroY * acelerometroY + acelerometroZ * acelerometroZ); + + if (magnitude > 50) { + // Aceleração muito alta pode indicar leitura errada ou emulador + scoreConfianca *= 0.6; + avisos.push(`Magnitude de movimento muito alta (${magnitude.toFixed(2)} m/s²). Pode indicar leitura incorreta.`); + } + + // Se não há movimento detectado quando deveria haver (em móvel), pode ser suspeito + if (isDesktop !== true && movimentoDetectado === false && variacaoAcelerometro !== undefined && variacaoAcelerometro < 0.001) { + // Variância muito baixa pode indicar que o dispositivo está parado ou emulador + scoreConfianca *= 0.9; + avisos.push('Nenhum movimento detectado durante o registro. Pode ser normal se o dispositivo estava parado.'); + } + + // Se há movimento, aumenta confiança + if (movimentoDetectado === true) { + scoreConfianca = Math.min(scoreConfianca * 1.1, 1.0); + } + } + + return { + valida: true, // Sempre retorna true - não bloqueia registro, apenas informa através do score + motivo: avisos.length > 0 ? avisos[0] : undefined, + scoreConfianca: Math.max(0, Math.min(1, scoreConfianca)), + avisos + }; +} + /** * Gera URL para upload de imagem do ponto */ @@ -337,6 +423,27 @@ export const registrarPonto = mutation({ isDesktop: v.optional(v.boolean()), connectionType: v.optional(v.string()), memoryInfo: v.optional(v.string()), + // Campos de sensores (acelerômetro e giroscópio) + sensorDisponivel: v.optional(v.boolean()), + permissaoNegada: v.optional(v.boolean()), + acelerometro: v.optional( + v.object({ + x: v.number(), + y: v.number(), + z: v.number(), + movimentoDetectado: v.boolean(), + magnitude: v.number(), + variacao: v.number(), + timestamp: v.number(), + }) + ), + giroscopio: v.optional( + v.object({ + alpha: v.number(), + beta: v.number(), + gamma: v.number(), + }) + ), }) ), timestamp: v.number(), @@ -564,6 +671,39 @@ export const registrarPonto = mutation({ } } + // Validar dados de acelerômetro (não bloqueia registro - apenas informa) + const validacaoAcelerometro = validarAcelerometro( + args.informacoesDispositivo?.isDesktop, + args.informacoesDispositivo?.sensorDisponivel, + args.informacoesDispositivo?.permissaoNegada, + args.informacoesDispositivo?.acelerometro?.x, + args.informacoesDispositivo?.acelerometro?.y, + args.informacoesDispositivo?.acelerometro?.z, + args.informacoesDispositivo?.acelerometro?.movimentoDetectado, + args.informacoesDispositivo?.acelerometro?.magnitude, + args.informacoesDispositivo?.acelerometro?.variacao + ); + + // Nota: A validação de acelerômetro não bloqueia o registro - apenas reduz o score de confiança + // Apenas câmera e localização são obrigatórias para registrar ponto + + // Combinar avisos de validação de localização e acelerômetro + const todosAvisos = [ + ...(validacaoLocalizacao?.avisos || []), + ...(validacaoAcelerometro.avisos || []) + ]; + + // Combinar scores de confiança (média ponderada) + let scoreFinalConfianca = 1.0; + if (validacaoLocalizacao && validacaoAcelerometro) { + // GPS tem peso 0.7, acelerômetro tem peso 0.3 + scoreFinalConfianca = (validacaoLocalizacao.scoreConfianca * 0.7) + (validacaoAcelerometro.scoreConfianca * 0.3); + } else if (validacaoLocalizacao) { + scoreFinalConfianca = validacaoLocalizacao.scoreConfianca; + } else if (validacaoAcelerometro) { + scoreFinalConfianca = validacaoAcelerometro.scoreConfianca; + } + // Criar registro const registroId = await ctx.db.insert('registrosPonto', { funcionarioId: usuario.funcionarioId, @@ -597,11 +737,11 @@ export const registrarPonto = mutation({ heading: args.informacoesDispositivo?.heading, speed: args.informacoesDispositivo?.speed, confiabilidadeGPS: args.informacoesDispositivo?.confiabilidadeGPS, - scoreConfiancaBackend: validacaoLocalizacao?.scoreConfianca, - suspeitaSpoofing: args.informacoesDispositivo?.suspeitaSpoofing || (validacaoLocalizacao ? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida : undefined), - motivoSuspeita: args.informacoesDispositivo?.motivoSuspeita || validacaoLocalizacao?.motivo || (validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos.join('; ') : undefined), + scoreConfiancaBackend: scoreFinalConfianca, + suspeitaSpoofing: args.informacoesDispositivo?.suspeitaSpoofing || (validacaoLocalizacao ? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida : undefined) || (validacaoAcelerometro ? validacaoAcelerometro.scoreConfianca < 0.5 || !validacaoAcelerometro.valida : undefined), + motivoSuspeita: args.informacoesDispositivo?.motivoSuspeita || validacaoLocalizacao?.motivo || validacaoAcelerometro?.motivo || (todosAvisos.length > 0 ? todosAvisos.join('; ') : undefined), // Informações detalhadas de validação (sempre salvar quando houver validação) - avisosValidacao: validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos : undefined, + avisosValidacao: todosAvisos.length > 0 ? todosAvisos : undefined, // Informações de Geofencing enderecoMarcacaoEsperado: validacaoGeofencing?.enderecoMaisProximo, distanciaEnderecoEsperado: validacaoGeofencing?.distanciaMetros, @@ -623,6 +763,18 @@ export const registrarPonto = mutation({ isDesktop: args.informacoesDispositivo?.isDesktop, connectionType: args.informacoesDispositivo?.connectionType, memoryInfo: args.informacoesDispositivo?.memoryInfo, + // Dados de sensores (Acelerômetro e Giroscópio) + acelerometroX: args.informacoesDispositivo?.acelerometro?.x, + acelerometroY: args.informacoesDispositivo?.acelerometro?.y, + acelerometroZ: args.informacoesDispositivo?.acelerometro?.z, + movimentoDetectado: args.informacoesDispositivo?.acelerometro?.movimentoDetectado, + magnitudeMovimento: args.informacoesDispositivo?.acelerometro?.magnitude, + variacaoAcelerometro: args.informacoesDispositivo?.acelerometro?.variacao, + giroscopioAlpha: args.informacoesDispositivo?.giroscopio?.alpha, + giroscopioBeta: args.informacoesDispositivo?.giroscopio?.beta, + giroscopioGamma: args.informacoesDispositivo?.giroscopio?.gamma, + sensorDisponivel: args.informacoesDispositivo?.sensorDisponivel, + permissaoSensorNegada: args.informacoesDispositivo?.permissaoNegada, criadoEm: Date.now(), }); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 9e02da5..f1cfc70 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1517,6 +1517,19 @@ export default defineSchema({ connectionType: v.optional(v.string()), memoryInfo: v.optional(v.string()), + // Informações de Sensores (Acelerômetro e Giroscópio) + acelerometroX: v.optional(v.number()), + acelerometroY: v.optional(v.number()), + acelerometroZ: v.optional(v.number()), + movimentoDetectado: v.optional(v.boolean()), + magnitudeMovimento: v.optional(v.number()), + variacaoAcelerometro: v.optional(v.number()), + giroscopioAlpha: v.optional(v.number()), + giroscopioBeta: v.optional(v.number()), + giroscopioGamma: v.optional(v.number()), + sensorDisponivel: v.optional(v.boolean()), + permissaoSensorNegada: v.optional(v.boolean()), + // Justificativa opcional para o registro justificativa: v.optional(v.string()),