feat(rede): nome, porta, saída e TTL em tempo real

A sala acompanha a interface escolhida, o TCP deixa de travar a janela e a aba de um peer continua fechável quando o IP muda. O guia do colega usa os saltos do traceroute como TTL mínimo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gabriel Felipe Souza SantosandCursor committed 2026-11-03 22:50:23 -03:00
1 parent baaf7582d3
commit 22540eaffb
31 files changed
+1194 -110

No files matched your search

+2 -2
View File
@@ -17,14 +17,14 @@ Convenções editoriais: [documentation/00_convencoes.md](documentation/00_conve
| Leitor | Trilha | | Leitor | Trilha |
| --- | --- | | --- | --- |
| **Primeiro uso** | [00_quickstart](documentation/00_quickstart.md) → [09_sistema_de_plugins](documentation/09_sistema_de_plugins.md) → [10_tutorial_plugin](documentation/10_tutorial_plugin.md) → [11_plugins_referencia](documentation/11_plugins_referencia.md) | | **Primeiro uso** | [00_quickstart](documentation/00_quickstart.md) → [09_sistema_de_plugins](documentation/09_sistema_de_plugins.md) → [10_tutorial_plugin](documentation/10_tutorial_plugin.md) → [11_plugins_referencia](documentation/11_plugins_referencia.md) |
| **Redes (conceito + código)** | [16_fundamentacao_redes](documentation/16_fundamentacao_redes.md) → [05](documentation/05_udp_multicast.md)–[08](documentation/08_heartbeat_e_presenca.md) → [appendix_portas_e_multicast](documentation/appendix_portas_e_multicast.md) | | **Redes (conceito + código)** | [16_fundamentacao_redes](documentation/16_fundamentacao_redes.md) → [05](documentation/05_udp_multicast.md)–[08](documentation/08_heartbeat_e_presenca.md) → [appendix_portas_e_multicast](documentation/appendix_portas_e_multicast.md) → [17_teste_com_colega](documentation/17_teste_com_colega.md) |
| **Desenvolvedor núcleo** | [13_guia_desenvolvimento](documentation/13_guia_desenvolvimento.md) → [14_arquitetura_e_manutencao](documentation/14_arquitetura_e_manutencao.md) → [04_inicializacao_e_gui](documentation/04_inicializacao_e_gui.md) | | **Desenvolvedor núcleo** | [13_guia_desenvolvimento](documentation/13_guia_desenvolvimento.md) → [14_arquitetura_e_manutencao](documentation/14_arquitetura_e_manutencao.md) → [04_inicializacao_e_gui](documentation/04_inicializacao_e_gui.md) |
## Manutenção rápida ## Manutenção rápida
| Mudança | Onde documentar | | Mudança | Onde documentar |
| --- | --- | | --- | --- |
| UDP/TCP, multicast, portas | 05, 06, appendix | | UDP/TCP, multicast, portas | 05, 06, appendix, 17 |
| SPI / plugins | 09, 10 | | SPI / plugins | 09, 10 |
| Decisão de arquitetura | decision-register | | Decisão de arquitetura | decision-register |
| Débito técnico | engineering_backlog | | Débito técnico | engineering_backlog |
+5
View File
@@ -22,5 +22,10 @@
| D22 | `PeerIpv4` para IP de peer na UI | No Windows, `InetAddress.getHostAddress()` pode devolver IPv6-mapeado e sufixo de zona (`%n`); chat e roteamento usam IPv4 pontilhado | [appendix_portas_e_multicast.md](documentation/appendix_portas_e_multicast.md) | | D22 | `PeerIpv4` para IP de peer na UI | No Windows, `InetAddress.getHostAddress()` pode devolver IPv6-mapeado e sufixo de zona (`%n`); chat e roteamento usam IPv4 pontilhado | [appendix_portas_e_multicast.md](documentation/appendix_portas_e_multicast.md) |
| D23 | Foco de atividade e badges | **Adiado na v1** — núcleo (`PeerActivityFocus` / `PeerActivityInbox`) mantido; UI sem badges; recebimento passivo em sub-abas (`addTabIfAbsent`) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | | D23 | Foco de atividade e badges | **Adiado na v1** — núcleo (`PeerActivityFocus` / `PeerActivityInbox`) mantido; UI sem badges; recebimento passivo em sub-abas (`addTabIfAbsent`) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) |
| D24 | Avisos de sessão (`SessionNoticeMessage`) | Início/fim de atividades (ex. `screen_cast` END ao parar share); extensível por plugin/canal | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | | D24 | Avisos de sessão (`SessionNoticeMessage`) | Início/fim de atividades (ex. `screen_cast` END ao parar share); extensível por plugin/canal | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) |
| D25 | Nome novo no heartbeat já existente | A lista do colega atualiza o `User` quando o `userName` muda | [08_heartbeat_e_presenca.md](documentation/08_heartbeat_e_presenca.md) |
| D26 | Troca de porta sai da sala | Goodbye na porta antiga, lista local zerada, anúncio na porta nova | [appendix_portas_e_multicast.md](documentation/appendix_portas_e_multicast.md) |
| D27 | TTL padrão 1, ajustável de 1 a 32 | 1 permanece na sub-rede local; valor maior é opção de configuração | [appendix_portas_e_multicast.md](documentation/appendix_portas_e_multicast.md) |
| D28 | Saída Automático, Cabo ou Wi-Fi | Multicast, IP exibido e TCP de saída usam a interface escolhida | [appendix_portas_e_multicast.md](documentation/appendix_portas_e_multicast.md) |
| D29 | Connect TCP com prazo de 5 s fora da EDT | Aviso só enquanto não há socket aberto para aquele IP | [06_tcp_conexoes.md](documentation/06_tcp_conexoes.md) |
Novas decisões: adicionar linha nesta tabela e detalhar no arquivo técnico correspondente. Novas decisões: adicionar linha nesta tabela e detalhar no arquivo técnico correspondente.
+3 -3
View File
@@ -67,20 +67,20 @@ A interface gráfica é Swing, organizada em shell (`pitiupi.presentation.shell`
Plugins expõem `getName()` (técnico), `getIdentifier()` (SPI) e `getDisplayName()` (texto na galeria e nas abas). Plugins expõem `getName()` (técnico), `getIdentifier()` (SPI) e `getDisplayName()` (texto na galeria e nas abas).
A aba **Configurações** mostra em cima **Sua conexão** (nome, porta, IP local, ID da instância, contagem de peers; campos selecionáveis para copiar manualmente) e embaixo a tabela de JARs instalados (altura reduzida). A aba **Configurações** mostra em cima **Sua conexão**: nome e porta editáveis, saída (Automático, Cabo ou Wi-Fi), TTL, IP local, interface em uso, ID da instância e contagem de peers. **Aplicar** grava a preferência e, se porta, saída ou TTL mudaram, reabre a rede na hora. IP e ID continuam somente leitura. Embaixo fica a tabela de JARs instalados.
Durante a inicialização, cada plugin recebe `ConnectionManager` e `PluginWorkspace` Durante a inicialização, cada plugin recebe `ConnectionManager` e `PluginWorkspace`
no método `createPlugin()`. Menus multicast na barra superior não são mais usados; no método `createPlugin()`. Menus multicast na barra superior não são mais usados;
ações para todos os peers migram para **peer actions** (contexto na lista lateral). ações para todos os peers migram para **peer actions** (contexto na lista lateral).
Ao iniciar, só as abas **Plugins** e **Configurações** ficam visíveis. Cada plugin registra um painel com `bindShellWorkspaceContent`; a aba abre ao clicar na galeria ou via peer action. Referência: **Conversa na rede** (`ChatPanel`) e **Compartilhar tela** (`ScreenCastPanel`). Ao iniciar, só as abas **Plugins** e **Configurações** ficam visíveis. Cada plugin registra um painel com `bindShellWorkspaceContent`; a aba abre ao clicar na galeria ou via peer action. Referência: **Conversa na rede** (`ChatPanel`) e **Compartilhar tela** (`ScreenCastPanel`).
O **chat** usa campo de mensagem compacto (~2 linhas, quebra de linha; barra de rolagem vertical só se o texto não couber). O histórico ocupa a maior parte da altura da aba (divisor proporcional ao abrir a janela). O **chat** usa campo de mensagem compacto (~2 linhas, quebra de linha; barra de rolagem vertical só se o texto não couber). O histórico ocupa a maior parte da altura da aba (divisor proporcional ao abrir a janela). Cada pessoa tem uma sub-aba; o × fecha essa sub-aba mesmo com outras ao lado. Se a pessoa muda de cabo para Wi-Fi, a sub-aba já aberta passa a usar o IP novo em vez de aparecer uma segunda vez com o mesmo nome.
Canal TCP privado do chat fica no mesmo `ChatPanel` (histórico separado por conversa: sala geral, peer por IP, privado com toggle). O núcleo mantém `PeerActivityInbox` para atividade por peer; indicadores visuais completos na lista lateral ainda não fazem parte da v1 (**D23**). Canal TCP privado do chat fica no mesmo `ChatPanel` (histórico separado por conversa: sala geral, peer por IP, privado com toggle). O núcleo mantém `PeerActivityInbox` para atividade por peer; indicadores visuais completos na lista lateral ainda não fazem parte da v1 (**D23**).
Ao fechar a aba superior de **Compartilhar tela** (×), o núcleo chama `Plugin.onShellTabClosed()` — o plugin interrompe captura e envio (toggle desligado). Reabrir pela galeria deve mostrar compartilhamento inativo. Ao fechar a aba superior de **Compartilhar tela** (×), o núcleo chama `Plugin.onShellTabClosed()` — o plugin interrompe captura e envio (toggle desligado). Reabrir pela galeria deve mostrar compartilhamento inativo.
**TCP (transporte)** envia uma mensagem serializada pelo núcleo (`sendTcp`), na porta do login. **TCP privado** (toggle) abre conexão dedicada ao plugin entre dois peers; com o toggle ativo, o combo de transporte fica desabilitado e o envio usa esse canal. Na UI só se informa o **IP** do peer: UDP/TCP unicast do Pitiupi usa sempre a **mesma porta** que você escolheu no login (convenção de sala: todos com a mesma porta, ex. 5000 e 5001 em testes no mesmo PC). **TCP (transporte)** envia uma mensagem serializada pelo núcleo, na porta desta instância. A primeira conexão com um IP que ainda não tem socket aberto corre fora da thread da janela: o chat mostra “Conectando em …”, espera no máximo cerca de 5 s e, se falhar, avisa uma vez. Mensagens seguintes no mesmo socket não repetem esse aviso. **TCP privado** (toggle) abre conexão dedicada ao plugin entre dois peers, com o mesmo aviso só na abertura. Na UI só se informa o **IP** do peer: UDP/TCP unicast do Pitiupi usa sempre a **mesma porta** desta instância (convenção de sala: todos com a mesma porta, ex. 5000 e 5001 em testes no mesmo PC).
Textos da UI (destino, combo de forma de envio e linha **“Como esta mensagem vai”**) usam linguagem simples e o termo técnico entre parênteses (multicast UDP, UDP unicast, TCP). Na sala, o transporte não é só multicast: o combo pode escolher cópias unicast ou TCP para cada pessoa, conforme o plugin. Textos da UI (destino, combo de forma de envio e linha **“Como esta mensagem vai”**) usam linguagem simples e o termo técnico entre parênteses (multicast UDP, UDP unicast, TCP). Na sala, o transporte não é só multicast: o combo pode escolher cópias unicast ou TCP para cada pessoa, conforme o plugin.
+2 -1
View File
@@ -24,7 +24,8 @@ Durante sua inicialização, o `SocketUDP`:
2. cria um `MulticastSocket` na **porta escolhida no login** (mesma porta TCP da sala); 2. cria um `MulticastSocket` na **porta escolhida no login** (mesma porta TCP da sala);
3. configura os buffers de envio e recepção; 3. configura os buffers de envio e recepção;
4. habilita o reuso do endereço; 4. habilita o reuso do endereço;
5. ingressa no grupo multicast. 5. define o TTL (padrão **1**, alterável na aba Configurações) e a interface de saída;
6. ingressa no grupo multicast.
O tamanho dos buffers de comunicação é configurado para `256000` bytes. O tamanho dos buffers de comunicação é configurado para `256000` bytes.
+4
View File
@@ -10,6 +10,10 @@ múltiplas mensagens.
A arquitetura utiliza uma conexão TCP persistente por endereço de peer. A arquitetura utiliza uma conexão TCP persistente por endereço de peer.
**Teoria:** `connect` sem prazo pode ficar parado até o sistema operacional desistir. **No Pitiupi:** `ConnectionFactory` usa `Socket.connect` com prazo de **5 s** (`CONNECT_TIMEOUT_MS`). O chat dispara essa abertura numa thread do núcleo (`sendTcpAsync` / `connectPluginTcpAsync`) e só mostra “Conectando em …” quando ainda não existe socket aberto para aquele IP. **Limitação:** o prazo não é configurável na tela; o timeout de leitura depois do handshake continua longo (minutos).
Ao mudar porta, saída ou TTL, o `ConnectionManager` envia goodbye, fecha as TCP locais e reabre o `ServerSocket`. Se a porta nova não abre, a sala anterior é restaurada.
O `TcpConnectionManager` mantém um mapa `InetAddress` → `TcpConnection`, permitindo reutilizar a mesma conexão para vários envios ao mesmo peer. O `TcpConnectionManager` mantém um mapa `InetAddress` → `TcpConnection`, permitindo reutilizar a mesma conexão para vários envios ao mesmo peer.
### SocketTCP ### SocketTCP
@@ -72,7 +72,13 @@ Lista de peers ativos
| Timeout de ausência | ~15 s | Peer some após queda ou fechamento sem goodbye | | Timeout de ausência | ~15 s | Peer some após queda ou fechamento sem goodbye |
| Goodbye | multicast ao sair | Remove peer mais rápido que só timeout | | Goodbye | multicast ao sair | Remove peer mais rápido que só timeout |
Em **Wi‑Fi de laboratório**, heartbeat usa o mesmo multicast que o chat na sala geral — mesma porta no login, mesma sub-rede, sem isolamento AP→AP. Ver [appendix_portas_e_multicast.md](appendix_portas_e_multicast.md). Em **Wi‑Fi de laboratório**, heartbeat usa o mesmo multicast que o chat na sala geral — mesma porta, mesma sub-rede, sem isolamento AP→AP. Ver [appendix_portas_e_multicast.md](appendix_portas_e_multicast.md) e [17_teste_com_colega.md](17_teste_com_colega.md).
### Nome e troca de sala
O nome vai dentro do `HeartbeatMessage` que já existe. Se o nome mudar, o receptor atualiza o peer na lista e avisa a interface; não há outra mensagem só para isso.
Trocar a porta (ou a saída, ou o TTL) envia `GoodbyeMessage` na sala atual, esvazia a lista local e anuncia de imediato depois do novo socket. Quem ficou na porta antiga deixa de ver esta instância. Quem já está na porta nova aparece no anúncio seguinte. Se o goodbye se perder, o timeout de cerca de 15 s ainda remove o peer.
```mermaid ```mermaid
flowchart LR flowchart LR
+48
View File
@@ -0,0 +1,48 @@
# Teste com um colega
**Teoria:** dois processos só se descobrem se compartilham a mesma sub-rede, a mesma porta de aplicação e um caminho em que o multicast da LAN não seja filtrado. **No Pitiupi:** a lista **Usuários nesta rede** enche com heartbeats UDP. **Limitação:** o Pitiupi não executa `ping` nem `traceroute` do sistema; esses comandos servem para separar problema de rede de problema do aplicativo.
Os dois Pitiupi precisam usar a **mesma porta** (na sala, em geral **5000**). Portas diferentes são salas diferentes: quem está em 5001 não aparece para quem ficou em 5000.
## O que conferir na aplicação
Abra a aba **Configurações**, seção **Sua conexão**:
- **IP local** e o nome da **interface** (por exemplo `eth0 · 192.168.0.15`).
- **Porta** em que esta instância escuta UDP e TCP.
- **Saída:** Automático, Cabo ou Wi-Fi. Os dois colegas precisam estar na mesma rede dessa interface.
- **TTL:** o padrão é **1**. O número de saltos do `tracert` (ou `traceroute`) até o IP do colega é o mínimo a colocar aqui. Se a linha 1 já é o IP dele, use **1**. Cada roteador no meio soma 1, até o máximo **32**. O teste na mesma sub-rede usa 1.
O nome na rede também se altera aí. O colega passa a ver o nome novo no próximo anúncio (até cerca de 5 s), sem você sair e voltar da lista.
## O que conferir no sistema
Substitua `5000` e o IP do colega pelos valores da aba Configurações.
### Windows
- IP: `ipconfig` e o IPv4 do adaptador Ethernet ou Wi-Fi.
- Porta em escuta: `netstat -ano | findstr :5000`
- Alcance: `ping` no IP do colega.
- Caminho: `tracert` no IP do colega. Conte as linhas numeradas até o IP dele: esse número é o TTL mínimo na aba Configurações.
- Porta TCP do aplicativo: no PowerShell, `Test-NetConnection -ComputerName IP -Port 5000`
### Linux
- IP: `ip -4 addr`
- Porta em escuta: `ss -lntu | grep 5000`
- Alcance: `ping -c 3 IP`
- Caminho: `traceroute IP` ou `tracepath IP`. Conte os saltos até o IP do colega: esse número é o TTL mínimo.
- Porta TCP do aplicativo: `nc -vz -w 3 IP 5000` (ou `ncat`)
## Como ler o resultado
- **O ping falha.** O problema está na rede entre os PCs: sub-rede diferente, cabo, Wi-Fi ou isolamento de clientes no roteador. O Pitiupi não contorna isso.
- **O ping funciona e a porta TCP não abre.** Firewall, porta errada ou o outro Pitiupi não está escutando. Confira a porta na aba Configurações e libere UDP e TCP nessa porta.
- **O ping com TTL 1 responde e a lista só mostra Todos.** A descoberta é multicast, e um TTL baixo não é a causa quando o `tracert` tem um salto só. Confira porta igual, a mesma saída (os dois no cabo ou os dois no Wi-Fi; `169.254` e `192.168` ao mesmo tempo não se veem), firewall de UDP e isolamento de clientes. Um lado no WSL e o outro no Windows costuma impedir o multicast: os dois peers precisam estar no mesmo tipo de ambiente.
- **O `tracert` tem mais de um salto e a lista fica vazia.** Suba o TTL em Configurações até o número de saltos (máximo 32) e aplique nos dois. Um roteador no meio só entrega o multicast se estiver disposto a encaminhar o grupo.
- **O colega está na lista e o TCP avisa que não conectou.** A tentativa de abrir o socket espera no máximo cerca de **5 s** e a janela continua utilizável. Veja firewall de TCP ou se a porta mudou no meio do caminho.
A lista espera cerca de **5 s** entre anúncios e remove quem fica cerca de **15 s** sem heartbeat. Ao trocar de porta, esta instância envia goodbye na sala antiga e passa a anunciar na porta nova.
Detalhe de portas, interface e TTL: [appendix_portas_e_multicast.md](appendix_portas_e_multicast.md).
+2 -1
View File
@@ -8,7 +8,7 @@
| --- | --- | | --- | --- |
| **Início** | [00_quickstart](00_quickstart.md), [02_modulos](02_modulos_do_repositorio.md), [03_build](03_build_ant_netbeans.md) | | **Início** | [00_quickstart](00_quickstart.md), [02_modulos](02_modulos_do_repositorio.md), [03_build](03_build_ant_netbeans.md) |
| **Arquitetura e GUI** | [04_inicializacao_e_gui](04_inicializacao_e_gui.md), [13_guia_desenvolvimento](13_guia_desenvolvimento.md), [14_arquitetura_e_manutencao](14_arquitetura_e_manutencao.md) | | **Arquitetura e GUI** | [04_inicializacao_e_gui](04_inicializacao_e_gui.md), [13_guia_desenvolvimento](13_guia_desenvolvimento.md), [14_arquitetura_e_manutencao](14_arquitetura_e_manutencao.md) |
| **Rede** | [05_udp_multicast](05_udp_multicast.md), [06_tcp_conexoes](06_tcp_conexoes.md), [07_mensagens](07_mensagens_e_serializacao.md), [08_heartbeat](08_heartbeat_e_presenca.md), [16_fundamentacao_redes](16_fundamentacao_redes.md), [appendix_portas_e_multicast](appendix_portas_e_multicast.md) | | **Rede** | [05_udp_multicast](05_udp_multicast.md), [06_tcp_conexoes](06_tcp_conexoes.md), [07_mensagens](07_mensagens_e_serializacao.md), [08_heartbeat](08_heartbeat_e_presenca.md), [16_fundamentacao_redes](16_fundamentacao_redes.md), [17_teste_com_colega](17_teste_com_colega.md), [appendix_portas_e_multicast](appendix_portas_e_multicast.md) |
| **Plugins** | [09_sistema_de_plugins](09_sistema_de_plugins.md), [10_tutorial_plugin](10_tutorial_plugin.md), [11_plugins_referencia](11_plugins_referencia.md), [12_transporte_e_peer_actions](12_menus_unicast_multicast.md) | | **Plugins** | [09_sistema_de_plugins](09_sistema_de_plugins.md), [10_tutorial_plugin](10_tutorial_plugin.md), [11_plugins_referencia](11_plugins_referencia.md), [12_transporte_e_peer_actions](12_menus_unicast_multicast.md) |
| **Qualidade** | [15_testes_e_gate](15_testes_e_gate.md), [engineering_backlog](engineering_backlog.md) | | **Qualidade** | [15_testes_e_gate](15_testes_e_gate.md), [engineering_backlog](engineering_backlog.md) |
@@ -23,4 +23,5 @@
| 9–12 | 09–12 | Plugins, tutorial genérico, transporte na UI | | 9–12 | 09–12 | Plugins, tutorial genérico, transporte na UI |
| 13–15 | 13–15 | Dev, manutenção, testes | | 13–15 | 13–15 | Dev, manutenção, testes |
| 16 | [16_fundamentacao_redes.md](16_fundamentacao_redes.md) | Fundamentação redes ↔ código | | 16 | [16_fundamentacao_redes.md](16_fundamentacao_redes.md) | Fundamentação redes ↔ código |
| 17 | [17_teste_com_colega.md](17_teste_com_colega.md) | IP, porta, ping e lista entre dois PCs |
| — | [appendix_portas_e_multicast.md](appendix_portas_e_multicast.md) | Portas, Wi‑Fi, WSL | | — | [appendix_portas_e_multicast.md](appendix_portas_e_multicast.md) | Portas, Wi‑Fi, WSL |
@@ -2,9 +2,11 @@
## Configuração ## Configuração
- **Nome** e **porta** definidos no diálogo de login (`Login`). - **Nome** e **porta** começam no diálogo de login e podem ser alterados na aba **Configurações** sem reiniciar. A alteração vale na hora.
- A mesma porta é usada para UDP (multicast socket) e TCP (`ServerSocket`). - A mesma porta é usada para UDP (multicast socket) e TCP (`ServerSocket`). Trocar a porta sai da sala antiga (goodbye) e passa a procurar colegas na porta nova.
- Preferências persistidas localmente (`java.util.prefs`). - **Saída:** Automático (cabo antes de Wi-Fi), só Cabo ou só Wi-Fi. O multicast e o IP exibido seguem essa escolha. O TCP de saída também sai pelo IPv4 dessa interface.
- **TTL** do multicast: padrão **1** (só a rede local). Na Configurações, de 1 a 32.
- Preferências persistidas localmente (`java.util.prefs`): nome, porta, TTL e saída. `config.properties` não guarda esses campos.
## Multicast ## Multicast
@@ -31,7 +33,9 @@ Executar o Pitiupi no WSL e peers no Windows (ou vice-versa) pode falhar em mult
## Escolha de interface (multicast) ## Escolha de interface (multicast)
O núcleo associa o socket multicast à interface de rede preferida: **cabo (Ethernet) antes de Wi‑Fi**; em cabo com link-local APIPA, prioriza IPv4 **`169.254.*`**. Isso não substitui a exigência de **mesma sub-rede** entre os dois PCs — `169.254.x` e `192.168.x` ao mesmo tempo em hosts diferentes não compartilham heartbeat. No modo **Automático**, o núcleo associa o socket multicast à interface preferida: **cabo (Ethernet) antes de Wi‑Fi**; em cabo com link-local APIPA, prioriza IPv4 **`169.254.*`**. **Cabo** ou **Wi-Fi** na aba Configurações restringe a essa família (nome da interface ou rótulo `Ethernet` / `Wi-Fi`). Isso não substitui a exigência de **mesma sub-rede** entre os dois PCs — `169.254.x` e `192.168.x` ao mesmo tempo em hosts diferentes não compartilham heartbeat.
Roteiro para achar o próprio IP, a porta e o motivo de a lista ficar vazia: [17_teste_com_colega.md](17_teste_com_colega.md).
## IP do peer na UI (Windows) ## IP do peer na UI (Windows)
@@ -51,4 +55,4 @@ Ao abrir conversa pela lista de presença, o campo de destino usa **`PeerIpv4`**
| --- | --- | | --- | --- |
| Lista só com **Todos**, sem peers | Porta igual? Mesma sub-rede? Firewall UDP? AP isolation? WSL num dos PCs? | | Lista só com **Todos**, sem peers | Porta igual? Mesma sub-rede? Firewall UDP? AP isolation? WSL num dos PCs? |
| Plugin não recebe | Desserialização / SPI / JAR em `PITIUPI_HOME/plugins` | | Plugin não recebe | Desserialização / SPI / JAR em `PITIUPI_HOME/plugins` |
| TCP falha | Peer visível na lista? IP correto? | | TCP falha | Peer visível na lista? IP correto? A tentativa avisa em cerca de 5 s e não trava a janela |
@@ -11,10 +11,19 @@ import pitiupi.models.Messages.GoodbyeMessage;
import pitiupi.models.Messages.HeartbeatMessage; import pitiupi.models.Messages.HeartbeatMessage;
import pitiupi.models.Messages.Message; import pitiupi.models.Messages.Message;
import pitiupi.net.tcp.SocketTCP; import pitiupi.net.tcp.SocketTCP;
import pitiupi.net.tcp.TcpAttemptListener;
import pitiupi.net.tcp.TcpConnectNotice;
import pitiupi.net.tcp.TcpConnection; import pitiupi.net.tcp.TcpConnection;
import pitiupi.net.udp.SocketUDP; import pitiupi.net.udp.SocketUDP;
import pitiupi.presentation.shell.LocalNetworkInfo;
import pitiupi.presentation.shell.NetworkPreferences;
import pitiupi.presentation.shell.PeerIpv4;
import javax.swing.SwingUtilities;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer; import java.util.function.Consumer;
public class ConnectionManager { public class ConnectionManager {
@@ -29,10 +38,18 @@ public class ConnectionManager {
private final PeerActivityInbox peerActivityInbox; private final PeerActivityInbox peerActivityInbox;
private final PeerActivityFocus peerActivityFocus; private final PeerActivityFocus peerActivityFocus;
private final Object socketsLock = new Object();
private final ExecutorService tcpConnectPool = Executors.newFixedThreadPool(2, runnable -> {
Thread thread = new Thread(runnable, "pitiupi-tcp-connect");
thread.setDaemon(true);
return thread;
});
private SocketTCP socketTCP; private SocketTCP socketTCP;
private SocketUDP socketUDP; private SocketUDP socketUDP;
private String username; private String username;
private int port; private int port;
private int multicastTtl;
private volatile boolean shutdownComplete; private volatile boolean shutdownComplete;
private Consumer<String> inviteAcceptedUiListener = pluginId -> { private Consumer<String> inviteAcceptedUiListener = pluginId -> {
}; };
@@ -42,10 +59,15 @@ public class ConnectionManager {
this.username = username; this.username = username;
this.port = port; this.port = port;
NetworkPreferences.applyEgressBeforeSockets();
this.multicastTtl = NetworkPreferences.loadTtl();
pitiupiIdentifier = "Pitiupi.Alice.2026"; pitiupiIdentifier = "Pitiupi.Alice.2026";
reconnect(); String bindError = openSockets();
if (bindError != null) {
System.err.println(bindError);
}
heartbeatManager = new HeartbeatManager(this); heartbeatManager = new HeartbeatManager(this);
peerActionBroker = new PeerActionBroker(); peerActionBroker = new PeerActionBroker();
@@ -106,25 +128,123 @@ public class ConnectionManager {
} }
shutdownComplete = true; shutdownComplete = true;
heartbeatManager.sendGoodbyeAndStop(); heartbeatManager.sendGoodbyeAndStop();
if (socketUDP != null) { closeSockets();
socketUDP.close(); tcpConnectPool.shutdownNow();
}
public int getMulticastTtl() {
return multicastTtl;
}
/**
* Sai da sala atual, reabre UDP e TCP e anuncia de novo.
*
* @return {@code null} quando a sala nova ficou de pé; mensagem de erro quando a configuração anterior foi mantida.
*/
public String applyNetwork(int newPort, int newTtl, LocalNetworkInfo.EgressMode mode) {
if (newPort < 1 || newPort > 65535) {
return "Porta inválida.";
} }
if (socketTCP != null) { LocalNetworkInfo.EgressMode requested = mode == null ? LocalNetworkInfo.EgressMode.AUTO : mode;
socketTCP.close(); if (!LocalNetworkInfo.canUse(requested)) {
if (requested == LocalNetworkInfo.EgressMode.WIRED) {
return "Não há interface de cabo disponível.";
}
return "Não há interface de Wi-Fi disponível.";
}
int ttl = NetworkPreferences.clampTtl(newTtl);
synchronized (socketsLock) {
int oldPort = this.port;
int oldTtl = this.multicastTtl;
LocalNetworkInfo.EgressMode oldMode = LocalNetworkInfo.getEgressMode();
heartbeatManager.sendGoodbye();
heartbeatManager.clearPeers();
closeSockets();
this.port = newPort;
this.multicastTtl = ttl;
LocalNetworkInfo.setEgressMode(requested);
String error = openSockets();
if (error != null) {
this.port = oldPort;
this.multicastTtl = oldTtl;
LocalNetworkInfo.setEgressMode(oldMode);
String rollback = openSockets();
heartbeatManager.announceNow();
if (rollback != null) {
return error + " Também não foi possível restaurar a porta " + oldPort + ".";
}
return error;
}
heartbeatManager.announceNow();
return null;
} }
} }
public TcpConnection connectPluginTcp(InetAddress destinationAdress, String senderId, String receiverId) { public TcpConnection connectPluginTcp(InetAddress destinationAdress, String senderId, String receiverId) {
return socketTCP.createPrivateConnection(destinationAdress, senderId, receiverId); SocketTCP tcp = currentTcp();
if (tcp == null) {
return null;
}
return tcp.createPrivateConnection(destinationAdress, senderId, receiverId);
}
public void connectPluginTcpAsync(String ipText, String senderId, String receiverId, TcpAttemptListener listener) {
tcpConnectPool.execute(() -> {
InetAddress address = resolve(ipText);
String endpoint = endpointLabel(address, ipText);
if (address == null) {
deliverFailure(listener, endpoint);
return;
}
deliverConnecting(listener, endpoint);
TcpConnection connection = connectPluginTcp(address, senderId, receiverId);
if (connection == null) {
deliverFailure(listener, endpoint);
return;
}
deliverSuccess(listener, connection);
});
} }
public boolean sendTcp(Message message, InetAddress destinationAddress) { public boolean sendTcp(Message message, InetAddress destinationAddress) {
return socketTCP.send(message, destinationAddress); SocketTCP tcp = currentTcp();
if (tcp == null) {
return false;
}
return tcp.send(message, destinationAddress);
}
public void sendTcpAsync(Message message, String ipText, TcpAttemptListener listener) {
tcpConnectPool.execute(() -> {
InetAddress address = resolve(ipText);
String endpoint = endpointLabel(address, ipText);
if (address == null) {
deliverFailure(listener, endpoint);
return;
}
SocketTCP tcp = currentTcp();
boolean live = tcp != null && tcp.hasOpenPublicConnection(address);
if (TcpConnectNotice.shouldShowConnecting(live)) {
deliverConnecting(listener, endpoint);
}
boolean sent = tcp != null && tcp.send(message, address);
if (sent) {
deliverSuccess(listener, null);
return;
}
deliverFailure(listener, endpoint);
});
} }
public boolean sendUdpUnicast(Message message, InetAddress destinationAddress) { public boolean sendUdpUnicast(Message message, InetAddress destinationAddress) {
SocketUDP udp = currentUdp();
if (udp == null) {
return false;
}
try { try {
socketUDP.sendUnicast(message, destinationAddress); udp.sendUnicast(message, destinationAddress);
return true; return true;
} catch (Exception ex) { } catch (Exception ex) {
return false; return false;
@@ -132,8 +252,12 @@ public class ConnectionManager {
} }
public boolean sendUdpMulticast(Message message) { public boolean sendUdpMulticast(Message message) {
SocketUDP udp = currentUdp();
if (udp == null) {
return false;
}
try { try {
socketUDP.sendMulticast(message); udp.sendMulticast(message);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
System.err.println("Error sending UDP Multicast"); System.err.println("Error sending UDP Multicast");
@@ -157,9 +281,8 @@ public class ConnectionManager {
presenceService.onGoodbye(message); presenceService.onGoodbye(message);
} }
public void setPort(int port) { public boolean setPort(int port) {
this.port = port; return applyNetwork(port, multicastTtl, LocalNetworkInfo.getEgressMode()) == null;
reconnect();
} }
public int getPort() { public int getPort() {
@@ -170,19 +293,105 @@ public class ConnectionManager {
return this.pitiupiIdentifier; return this.pitiupiIdentifier;
} }
private void reconnect() { private SocketUDP currentUdp() {
if (this.socketUDP != null) { synchronized (socketsLock) {
this.socketUDP.close(); return socketUDP;
} }
if (this.socketTCP != null) { }
this.socketTCP.close();
private SocketTCP currentTcp() {
synchronized (socketsLock) {
return socketTCP;
} }
}
this.socketUDP = new SocketUDP(this, pluginManager); private void closeSockets() {
this.socketTCP = new SocketTCP(this, pluginManager); SocketUDP udp;
SocketTCP tcp;
synchronized (socketsLock) {
udp = socketUDP;
tcp = socketTCP;
socketUDP = null;
socketTCP = null;
}
if (udp != null) {
udp.close();
joinQuiet(udp);
}
if (tcp != null) {
tcp.close();
joinQuiet(tcp);
}
}
this.socketUDP.start(); private String openSockets() {
this.socketTCP.start(); SocketUDP udp = new SocketUDP(this, pluginManager);
SocketTCP tcp = new SocketTCP(this, pluginManager);
if (!udp.isJoined() || !tcp.isAccepting()) {
udp.close();
tcp.close();
return "Não foi possível abrir a porta " + port + ". Ela pode estar em uso.";
}
synchronized (socketsLock) {
socketUDP = udp;
socketTCP = tcp;
}
udp.start();
tcp.start();
return null;
}
private static void joinQuiet(Thread thread) {
try {
thread.join(1000);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
private static InetAddress resolve(String ipText) {
if (ipText == null || ipText.isBlank()) {
return null;
}
try {
return InetAddress.getByName(ipText.trim());
} catch (UnknownHostException ex) {
return null;
}
}
private String endpointLabel(InetAddress address, String ipText) {
String host = address != null ? PeerIpv4.fromInetAddress(address) : (ipText == null ? "" : ipText.trim());
return host + ":" + getPort();
}
private static void deliverConnecting(TcpAttemptListener listener, String endpoint) {
if (listener == null) {
return;
}
onEdt(() -> listener.onConnecting(endpoint));
}
private static void deliverSuccess(TcpAttemptListener listener, TcpConnection connection) {
if (listener == null) {
return;
}
onEdt(() -> listener.onSuccess(connection));
}
private static void deliverFailure(TcpAttemptListener listener, String endpoint) {
if (listener == null) {
return;
}
onEdt(() -> listener.onFailure(TcpConnectNotice.failureMessage(endpoint)));
}
private static void onEdt(Runnable action) {
if (SwingUtilities.isEventDispatchThread()) {
action.run();
} else {
SwingUtilities.invokeLater(action);
}
} }
public String getUserName() { public String getUserName() {
@@ -78,14 +78,32 @@ public class HeartbeatManager {
connectionManager.sendUdpMulticast(msg); connectionManager.sendUdpMulticast(msg);
} }
public void sendGoodbyeAndStop() { public void announceNow() {
if (connectionManager != null) { announce();
GoodbyeMessage msg = new GoodbyeMessage(connectionManager.getUserName(), instanceId); }
connectionManager.sendUdpMulticast(msg);
public void sendGoodbye() {
if (connectionManager == null) {
return;
} }
GoodbyeMessage msg = new GoodbyeMessage(connectionManager.getUserName(), instanceId);
connectionManager.sendUdpMulticast(msg);
}
public void sendGoodbyeAndStop() {
sendGoodbye();
stop(); stop();
} }
public void clearPeers() {
if (peers.isEmpty()) {
notifyListeners();
return;
}
peers.clear();
notifyListeners();
}
public void receiveGoodbye(GoodbyeMessage msg) { public void receiveGoodbye(GoodbyeMessage msg) {
if (msg.getInstanceId().equals(instanceId)) { if (msg.getInstanceId().equals(instanceId)) {
return; return;
@@ -115,13 +133,15 @@ public class HeartbeatManager {
return; return;
} }
String key = msg.getInstanceId(); String key = msg.getInstanceId();
boolean isNew = !peers.containsKey(key); User peer = peers.get(key);
if (peer == null) {
User peer = peers.computeIfAbsent(key, k -> new User(msg.getUserName(), from, msg.getTimestamp(), msg.getInstanceId())); peers.put(key, new User(msg.getUserName(), from, msg.getTimestamp(), msg.getInstanceId()));
notifyListeners();
return;
}
peer.touch(System.currentTimeMillis()); peer.touch(System.currentTimeMillis());
if (peer.updateIdentity(msg.getUserName(), from)) {
if (isNew) {
notifyListeners(); notifyListeners();
} }
} }
+20 -2
View File
@@ -16,8 +16,8 @@ public class User {
public static final String BROADCAST_INSTANCE_ID = "__broadcast__"; public static final String BROADCAST_INSTANCE_ID = "__broadcast__";
private final String userName; private String userName;
private final InetAddress address; private InetAddress address;
private volatile long lastSeen; private volatile long lastSeen;
private final String instanceId; private final String instanceId;
@@ -65,6 +65,24 @@ public class User {
this.lastSeen = timestamp; this.lastSeen = timestamp;
} }
/**
* Atualiza nome e endereço quando um heartbeat mais novo traz identidade diferente.
*
* @return {@code true} se nome ou endereço mudou.
*/
public boolean updateIdentity(String newName, InetAddress newAddress) {
boolean changed = false;
if (newName != null && !newName.equals(userName)) {
this.userName = newName;
changed = true;
}
if (newAddress != null && !newAddress.equals(address)) {
this.address = newAddress;
changed = true;
}
return changed;
}
@Override @Override
public String toString() { public String toString() {
if (isBroadcastTarget() || address == null) { if (isBroadcastTarget() || address == null) {
@@ -7,7 +7,10 @@ import pitiupi.models.Messages.InfoType;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.ObjectInputStream; import java.io.ObjectInputStream;
import pitiupi.presentation.shell.LocalNetworkInfo;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket; import java.net.Socket;
import java.net.SocketTimeoutException; import java.net.SocketTimeoutException;
import java.util.logging.Level; import java.util.logging.Level;
@@ -28,6 +31,7 @@ import java.util.logging.Logger;
public class ConnectionFactory { public class ConnectionFactory {
private static final int HANDSHAKE_TIMEOUT = 1000; private static final int HANDSHAKE_TIMEOUT = 1000;
private static final int STANDARD_TIMEOUT = 300_000; private static final int STANDARD_TIMEOUT = 300_000;
public static final int CONNECT_TIMEOUT_MS = 5000;
/** /**
* Cria uma conexão TCP de saída com o peer de destino e realiza o handshake * Cria uma conexão TCP de saída com o peer de destino e realiza o handshake
@@ -55,7 +59,12 @@ public class ConnectionFactory {
try { try {
TcpConnection connection; TcpConnection connection;
Socket socket = new Socket(address, port); Socket socket = new Socket();
InetAddress local = LocalNetworkInfo.bestLocalInet4Address();
if (local != null) {
socket.bind(new InetSocketAddress(local, 0));
}
socket.connect(new InetSocketAddress(address, port), CONNECT_TIMEOUT_MS);
connection = new TcpConnection(socket); connection = new TcpConnection(socket);
connection.setSender(sender); connection.setSender(sender);
connection.setReceiver(receiver); connection.setReceiver(receiver);
+16 -1
View File
@@ -64,7 +64,10 @@ public class SocketTCP extends Thread {
tcpConnectionManager = new TcpConnectionManager(); tcpConnectionManager = new TcpConnectionManager();
try { try {
serverSocket = new ServerSocket(connectionManager.getPort()); ServerSocket listening = new ServerSocket();
listening.setReuseAddress(true);
listening.bind(new InetSocketAddress(connectionManager.getPort()));
serverSocket = listening;
address = InetAddress.getByName(InetAddress.getLocalHost().getHostAddress()); address = InetAddress.getByName(InetAddress.getLocalHost().getHostAddress());
} catch (IOException ex) { } catch (IOException ex) {
System.out.println("There is no socket connection. Sorry."); System.out.println("There is no socket connection. Sorry.");
@@ -83,6 +86,18 @@ public class SocketTCP extends Thread {
* @param msg mensagem que será enviada. * @param msg mensagem que será enviada.
* @param destinationAddress endereço IP do peer que receberá a mensagem. * @param destinationAddress endereço IP do peer que receberá a mensagem.
*/ */
public boolean isAccepting() {
return serverSocket != null && serverSocket.isBound() && !serverSocket.isClosed();
}
public boolean hasOpenPublicConnection(InetAddress destinationAddress) {
if (destinationAddress == null) {
return false;
}
TcpConnection connection = tcpConnectionManager.getPublicConnection(destinationAddress);
return connection != null && !connection.isClosed();
}
public boolean send(Message msg, InetAddress destinationAddress) { public boolean send(Message msg, InetAddress destinationAddress) {
try { try {
TcpConnection connection = getPublicConnection( TcpConnection connection = getPublicConnection(
@@ -0,0 +1,14 @@
package pitiupi.net.tcp;
/**
* Resultado de um connect TCP disparado fora da thread da interface.
* Os métodos são chamados na EDT.
*/
public interface TcpAttemptListener {
void onConnecting(String endpoint);
void onSuccess(TcpConnection connection);
void onFailure(String message);
}
@@ -0,0 +1,19 @@
package pitiupi.net.tcp;
/**
* O aviso de conexão na tela só aparece quando ainda não há socket aberto para o IP.
*/
public final class TcpConnectNotice {
private TcpConnectNotice() {
}
public static boolean shouldShowConnecting(boolean liveSocketExists) {
return !liveSocketExists;
}
public static String failureMessage(String endpoint) {
return "Não foi possível conectar em " + endpoint
+ ". Verifique se o outro Pitiupi está aberto nessa porta e se o firewall libera TCP.";
}
}
+11 -3
View File
@@ -46,6 +46,7 @@ public class SocketUDP extends Thread {
private final PluginManager pluginManager; private final PluginManager pluginManager;
private volatile boolean running = true; private volatile boolean running = true;
private volatile boolean joined;
public final static String INET_ADDR = "224.0.0.3"; public final static String INET_ADDR = "224.0.0.3";
/** /**
@@ -65,11 +66,12 @@ public class SocketUDP extends Thread {
this.address = InetAddress.getByName(SocketUDP.INET_ADDR); this.address = InetAddress.getByName(SocketUDP.INET_ADDR);
int port = connectionManager.getPort(); int port = connectionManager.getPort();
multicastSocket = new MulticastSocket(port); multicastSocket = new MulticastSocket(null);
multicastSocket.setReuseAddress(true);
multicastSocket.bind(new InetSocketAddress(port));
multicastSocket.setSendBufferSize(256000); multicastSocket.setSendBufferSize(256000);
multicastSocket.setReceiveBufferSize(256000); multicastSocket.setReceiveBufferSize(256000);
multicastSocket.setReuseAddress(true); multicastSocket.setTimeToLive(connectionManager.getMulticastTtl());
multicastSocket.setTimeToLive(1);
multicastInterface = LocalNetworkInfo.preferredNetworkInterface(); multicastInterface = LocalNetworkInfo.preferredNetworkInterface();
if (multicastInterface != null) { if (multicastInterface != null) {
multicastSocket.setNetworkInterface(multicastInterface); multicastSocket.setNetworkInterface(multicastInterface);
@@ -77,6 +79,7 @@ public class SocketUDP extends Thread {
} else { } else {
multicastSocket.joinGroup(address); multicastSocket.joinGroup(address);
} }
joined = true;
} }
catch (UnknownHostException ex) { catch (UnknownHostException ex) {
System.getLogger(SocketUDP.class.getName()).log(System.Logger.Level.ERROR, (String) null, ex); System.getLogger(SocketUDP.class.getName()).log(System.Logger.Level.ERROR, (String) null, ex);
@@ -124,8 +127,13 @@ public class SocketUDP extends Thread {
/** /**
* Encerra a comunicação multicast e interrompe a recepção de mensagens. * Encerra a comunicação multicast e interrompe a recepção de mensagens.
*/ */
public boolean isJoined() {
return joined && multicastSocket != null && !multicastSocket.isClosed();
}
public void close() { public void close() {
running = false; running = false;
joined = false;
if (multicastSocket != null) { if (multicastSocket != null) {
try { try {
int port = connectionManager.getPort(); int port = connectionManager.getPort();
@@ -153,18 +153,44 @@ public final class ClosableTabbedPane {
closeTab(key, closeFallbackKey); closeTab(key, closeFallbackKey);
} }
/**
* Troca a chave de uma aba existente e recria o × com a chave nova.
*
* @return {@code false} se a aba antiga não existe ou a nova já está ocupada.
*/
public boolean replaceKey(String oldKey, String newKey, String title, String tooltip, boolean closable) {
if (oldKey == null || newKey == null || oldKey.equals(newKey)) {
return false;
}
reconcileKeyListWithTabPane();
int index = indexOfKey(oldKey);
if (index < 0 || index >= tabbedPane.getTabCount() || hasTab(newKey)) {
return false;
}
orderedKeys.set(index, newKey);
reindex();
setTabHeader(index, title, tooltip, closable, newKey);
return true;
}
public void closeTab(String key, String fallbackKey) { public void closeTab(String key, String fallbackKey) {
reconcileKeyListWithTabPane(); reconcileKeyListWithTabPane();
int index = indexOfKey(key); int index = indexOfKey(key);
if (index < 0 || index >= tabbedPane.getTabCount()) { if (index < 0 || index >= tabbedPane.getTabCount()) {
return; return;
} }
tabbedPane.removeTabAt(index); boolean prev = suppressSelectionEvents;
if (index < orderedKeys.size()) { suppressSelectionEvents = true;
orderedKeys.remove(index); try {
if (index < orderedKeys.size()) {
orderedKeys.remove(index);
}
keyToIndex.remove(key);
tabbedPane.removeTabAt(index);
reindex();
} finally {
suppressSelectionEvents = prev;
} }
keyToIndex.remove(key);
reindex();
onTabClosed.accept(key); onTabClosed.accept(key);
String next = null; String next = null;
if (fallbackKey != null && !fallbackKey.isBlank() && hasTab(fallbackKey)) { if (fallbackKey != null && !fallbackKey.isBlank() && hasTab(fallbackKey)) {
@@ -251,16 +277,22 @@ public final class ClosableTabbedPane {
if (indices.size() <= 1) { if (indices.size() <= 1) {
return; return;
} }
for (int i = indices.size() - 1; i >= 1; i--) { boolean prev = suppressSelectionEvents;
int idx = indices.get(i); suppressSelectionEvents = true;
if (idx >= 0 && idx < tabbedPane.getTabCount()) { try {
tabbedPane.removeTabAt(idx); for (int i = indices.size() - 1; i >= 1; i--) {
} int idx = indices.get(i);
if (idx < orderedKeys.size()) { if (idx < orderedKeys.size()) {
orderedKeys.remove(idx); orderedKeys.remove(idx);
}
if (idx >= 0 && idx < tabbedPane.getTabCount()) {
tabbedPane.removeTabAt(idx);
}
} }
reindex();
} finally {
suppressSelectionEvents = prev;
} }
reindex();
} }
private void setTabHeader(int tabIndex, String title, String tooltip, boolean closable, String key) { private void setTabHeader(int tabIndex, String title, String tooltip, boolean closable, String key) {
@@ -11,15 +11,45 @@ import java.util.Locale;
*/ */
public final class LocalNetworkInfo { public final class LocalNetworkInfo {
public enum EgressMode {
AUTO,
WIRED,
WIRELESS
}
private static final String APIPA_PREFIX = "169.254."; private static final String APIPA_PREFIX = "169.254.";
private static volatile EgressMode egressMode = EgressMode.AUTO;
private LocalNetworkInfo() { private LocalNetworkInfo() {
} }
public static void setEgressMode(EgressMode mode) {
egressMode = mode == null ? EgressMode.AUTO : mode;
}
public static EgressMode getEgressMode() {
return egressMode;
}
/** /**
* Interface preferida para LAN: cabo antes de Wi‑Fi; APIPA (169.254.x) antes de outro IPv4 no cabo. * Modo automático sempre pode ser aplicado. Cabo e Wi-Fi exigem uma interface dessa família.
*/
public static boolean canUse(EgressMode mode) {
if (mode == null || mode == EgressMode.AUTO) {
return true;
}
return selectInterface(mode) != null;
}
/**
* Interface preferida para LAN, respeitando o modo de saída configurado.
*/ */
public static NetworkInterface preferredNetworkInterface() { public static NetworkInterface preferredNetworkInterface() {
return selectInterface(egressMode);
}
public static NetworkInterface selectInterface(EgressMode mode) {
EgressMode effective = mode == null ? EgressMode.AUTO : mode;
try { try {
NetworkInterface wiredApipa = null; NetworkInterface wiredApipa = null;
NetworkInterface wiredOther = null; NetworkInterface wiredOther = null;
@@ -34,8 +64,8 @@ public final class LocalNetworkInfo {
if (fallback == null) { if (fallback == null) {
fallback = ni; fallback = ni;
} }
String name = ni.getName(); Kind kind = classify(ni);
if (isWiredLanInterfaceName(name)) { if (kind == Kind.WIRED) {
if (hasApipaIpv4(ni)) { if (hasApipaIpv4(ni)) {
if (wiredApipa == null) { if (wiredApipa == null) {
wiredApipa = ni; wiredApipa = ni;
@@ -43,10 +73,19 @@ public final class LocalNetworkInfo {
} else if (wiredOther == null) { } else if (wiredOther == null) {
wiredOther = ni; wiredOther = ni;
} }
} else if (isWirelessLanInterfaceName(name) && wireless == null) { } else if (kind == Kind.WIRELESS && wireless == null) {
wireless = ni; wireless = ni;
} }
} }
if (effective == EgressMode.WIRED) {
if (wiredApipa != null) {
return wiredApipa;
}
return wiredOther;
}
if (effective == EgressMode.WIRELESS) {
return wireless;
}
if (wiredApipa != null) { if (wiredApipa != null) {
return wiredApipa; return wiredApipa;
} }
@@ -62,6 +101,39 @@ public final class LocalNetworkInfo {
} }
} }
public static String describeActiveInterface() {
NetworkInterface ni = preferredNetworkInterface();
if (ni == null) {
return "nenhuma interface";
}
String ip = bestIpv4OnInterface(ni);
return ni.getName() + " · " + (ip == null ? "—" : ip);
}
public static InetAddress bestLocalInet4Address() {
try {
NetworkInterface preferred = preferredNetworkInterface();
if (preferred == null) {
return null;
}
String ip = bestIpv4OnInterface(preferred);
if (ip == null) {
return null;
}
return InetAddress.getByName(ip);
} catch (Exception ex) {
return null;
}
}
public static boolean isWiredInterface(String name, String displayName) {
return matchesWiredToken(name) || matchesWiredToken(displayName);
}
public static boolean isWirelessInterface(String name, String displayName) {
return matchesWirelessToken(name) || matchesWirelessToken(displayName);
}
public static String bestLocalIpv4() { public static String bestLocalIpv4() {
try { try {
NetworkInterface preferred = preferredNetworkInterface(); NetworkInterface preferred = preferredNetworkInterface();
@@ -120,25 +192,52 @@ public final class LocalNetworkInfo {
|| n.startsWith("tailscale"); || n.startsWith("tailscale");
} }
private static boolean isWiredLanInterfaceName(String name) { private enum Kind {
if (name == null) { WIRED,
WIRELESS,
OTHER
}
private static Kind classify(NetworkInterface ni) {
String name = ni.getName();
String display = ni.getDisplayName();
boolean wireless = isWirelessInterface(name, display);
boolean wired = isWiredInterface(name, display);
if (wireless && !wired) {
return Kind.WIRELESS;
}
if (wired && !wireless) {
return Kind.WIRED;
}
if (wireless) {
return Kind.WIRELESS;
}
return Kind.OTHER;
}
private static boolean matchesWiredToken(String value) {
if (value == null) {
return false; return false;
} }
String n = name.toLowerCase(Locale.ROOT); String n = value.toLowerCase(Locale.ROOT);
return n.startsWith("eth") return n.startsWith("eth")
|| n.startsWith("eno") || n.startsWith("eno")
|| n.startsWith("enp") || n.startsWith("enp")
|| n.startsWith("ens"); || n.startsWith("ens")
|| n.contains("ethernet");
} }
private static boolean isWirelessLanInterfaceName(String name) { private static boolean matchesWirelessToken(String value) {
if (name == null) { if (value == null) {
return false; return false;
} }
String n = name.toLowerCase(Locale.ROOT); String n = value.toLowerCase(Locale.ROOT);
return n.startsWith("wlan") return n.startsWith("wlan")
|| n.startsWith("wlp") || n.startsWith("wlp")
|| n.startsWith("wl"); || n.startsWith("wl")
|| n.contains("wi-fi")
|| n.contains("wifi")
|| n.contains("wireless");
} }
private static boolean hasApipaIpv4(NetworkInterface ni) { private static boolean hasApipaIpv4(NetworkInterface ni) {
@@ -13,17 +13,11 @@ import java.awt.FlowLayout;
import java.awt.GridBagConstraints; import java.awt.GridBagConstraints;
import java.awt.GridBagLayout; import java.awt.GridBagLayout;
import java.awt.Insets; import java.awt.Insets;
import java.util.prefs.Preferences;
/** /**
* Login modal centralizado (~30% da área útil do monitor). * Login modal centralizado (~30% da área útil do monitor).
*/ */
public final class LoginDialog extends JDialog { public final class LoginDialog extends JDialog {
private static final Preferences prefs = Preferences.userNodeForPackage(LoginDialog.class);
private static final String PREF_NOME = "lastUserName";
private static final String PREF_PORTA = "lastPort";
private final JTextField nameField = new JTextField(24); private final JTextField nameField = new JTextField(24);
private final JSpinner portField = new JSpinner(); private final JSpinner portField = new JSpinner();
private boolean accepted; private boolean accepted;
@@ -37,8 +31,8 @@ public final class LoginDialog extends JDialog {
} }
private void buildUi() { private void buildUi() {
String nomeSalvo = prefs.get(PREF_NOME, System.getProperty("user.name")); String nomeSalvo = NetworkPreferences.loadName();
int portaSalva = prefs.getInt(PREF_PORTA, 5000); int portaSalva = NetworkPreferences.loadPort();
portField.setModel(new javax.swing.SpinnerNumberModel(portaSalva, 1, 65535, 1)); portField.setModel(new javax.swing.SpinnerNumberModel(portaSalva, 1, 65535, 1));
nameField.setText(nomeSalvo); nameField.setText(nomeSalvo);
@@ -104,8 +98,8 @@ public final class LoginDialog extends JDialog {
if (port <= 0) { if (port <= 0) {
return; return;
} }
prefs.put(PREF_NOME, name); NetworkPreferences.saveName(name);
prefs.putInt(PREF_PORTA, port); NetworkPreferences.savePort(port);
accepted = true; accepted = true;
setVisible(false); setVisible(false);
dispose(); dispose();
@@ -38,6 +38,7 @@ public final class MainShellFrame extends JFrame implements ShellHost {
connectionManager = new ConnectionManager(userName, pluginManager, port); connectionManager = new ConnectionManager(userName, pluginManager, port);
settingsPanel = new SettingsPanel(connectionManager); settingsPanel = new SettingsPanel(connectionManager);
settingsPanel.setSettingsListener(this::onIdentityApplied);
PluginGalleryPanel gallery = new PluginGalleryPanel((pluginId, title) -> { }); PluginGalleryPanel gallery = new PluginGalleryPanel((pluginId, title) -> { });
tabWorkspace = new TabWorkspacePanel(gallery, settingsPanel); tabWorkspace = new TabWorkspacePanel(gallery, settingsPanel);
galleryPanel = gallery; galleryPanel = gallery;
@@ -131,6 +132,11 @@ public final class MainShellFrame extends JFrame implements ShellHost {
System.exit(0); System.exit(0);
} }
private void onIdentityApplied(String userName, int port) {
setUserName(userName);
updateStatus(userName, port);
}
@Override @Override
public void setUserName(String userName) { public void setUserName(String userName) {
connectionManager.setUserName(userName); connectionManager.setUserName(userName);
@@ -0,0 +1,94 @@
package pitiupi.presentation.shell;
import java.util.prefs.Preferences;
/**
* Nome, porta, TTL e saída de rede no mesmo nó de preferências do login.
*/
public final class NetworkPreferences {
public static final String PREF_NOME = "lastUserName";
public static final String PREF_PORTA = "lastPort";
public static final String PREF_TTL = "lastTtl";
public static final String PREF_EGRESS = "lastEgress";
public static final int DEFAULT_PORT = 5000;
public static final int DEFAULT_TTL = 1;
public static final int MIN_TTL = 1;
public static final int MAX_TTL = 32;
private static final Preferences PREFS = Preferences.userNodeForPackage(LoginDialog.class);
private NetworkPreferences() {
}
public static String loadName() {
String fallback = System.getProperty("user.name");
return PREFS.get(PREF_NOME, fallback == null ? "" : fallback);
}
public static int loadPort() {
return PREFS.getInt(PREF_PORTA, DEFAULT_PORT);
}
public static int loadTtl() {
return clampTtl(PREFS.getInt(PREF_TTL, DEFAULT_TTL));
}
public static int clampTtl(int ttl) {
if (ttl < MIN_TTL) {
return MIN_TTL;
}
if (ttl > MAX_TTL) {
return MAX_TTL;
}
return ttl;
}
public static LocalNetworkInfo.EgressMode loadEgress() {
return fromCode(PREFS.get(PREF_EGRESS, "auto"));
}
/**
* Aplica a saída gravada antes de os sockets abrirem.
*/
public static void applyEgressBeforeSockets() {
LocalNetworkInfo.setEgressMode(loadEgress());
}
public static void saveName(String name) {
PREFS.put(PREF_NOME, name);
}
public static void savePort(int port) {
PREFS.putInt(PREF_PORTA, port);
}
public static void saveTtl(int ttl) {
PREFS.putInt(PREF_TTL, clampTtl(ttl));
}
public static void saveEgress(LocalNetworkInfo.EgressMode mode) {
PREFS.put(PREF_EGRESS, toCode(mode));
}
public static String toCode(LocalNetworkInfo.EgressMode mode) {
if (mode == LocalNetworkInfo.EgressMode.WIRED) {
return "wired";
}
if (mode == LocalNetworkInfo.EgressMode.WIRELESS) {
return "wireless";
}
return "auto";
}
public static LocalNetworkInfo.EgressMode fromCode(String code) {
if ("wired".equals(code)) {
return LocalNetworkInfo.EgressMode.WIRED;
}
if ("wireless".equals(code)) {
return LocalNetworkInfo.EgressMode.WIRELESS;
}
return LocalNetworkInfo.EgressMode.AUTO;
}
}
@@ -131,6 +131,10 @@ public final class SessionTabBar {
tabs.setTabTitle(key, title, tooltip); tabs.setTabTitle(key, title, tooltip);
} }
public boolean replaceKey(String oldKey, String newKey, String title, String tooltip, boolean closable) {
return tabs.replaceKey(oldKey, newKey, title, tooltip, closable);
}
public String selectedKey() { public String selectedKey() {
return tabs.selectedKey(); return tabs.selectedKey();
} }
@@ -8,23 +8,28 @@ import pitiupi.plugins.store.PluginStore;
import pitiupi.presentation.shell.ui.ShellUi; import pitiupi.presentation.shell.ui.ShellUi;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JFileChooser; import javax.swing.JFileChooser;
import javax.swing.JLabel; import javax.swing.JLabel;
import javax.swing.JOptionPane; import javax.swing.JOptionPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTable; import javax.swing.JTable;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.Timer; import javax.swing.Timer;
import javax.swing.border.EmptyBorder; import javax.swing.border.EmptyBorder;
import javax.swing.filechooser.FileNameExtensionFilter; import javax.swing.filechooser.FileNameExtensionFilter;
import javax.swing.table.DefaultTableModel; import javax.swing.table.DefaultTableModel;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension; import java.awt.Dimension;
import java.awt.FlowLayout; import java.awt.FlowLayout;
import java.awt.GridBagConstraints; import java.awt.GridBagConstraints;
import java.awt.GridBagLayout; import java.awt.GridBagLayout;
import java.awt.Insets; import java.awt.Insets;
import java.awt.KeyboardFocusManager;
import java.io.File; import java.io.File;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap; import java.util.HashMap;
@@ -36,17 +41,28 @@ import java.util.Map;
*/ */
public final class SettingsPanel extends JPanel { public final class SettingsPanel extends JPanel {
public interface NetworkSettingsListener {
void onIdentityApplied(String userName, int port);
}
private final PluginStore store; private final PluginStore store;
private final ConnectionManager connectionManager; private final ConnectionManager connectionManager;
private final DefaultTableModel tableModel = private final DefaultTableModel tableModel =
new DefaultTableModel(new String[]{"Nome", "ID", "Arquivo", "Versão"}, 0); new DefaultTableModel(new String[]{"Nome", "ID", "Arquivo", "Versão"}, 0);
private final JTable table = new JTable(tableModel); private final JTable table = new JTable(tableModel);
private final JTextField nameField = new JTextField(); private final JTextField nameField = new JTextField();
private final JTextField portField = new JTextField(); private final JSpinner portSpinner = new JSpinner(new javax.swing.SpinnerNumberModel(5000, 1, 65535, 1));
private final JComboBox<String> egressCombo = new JComboBox<>(new String[]{"Automático", "Cabo", "Wi-Fi"});
private final JSpinner ttlSpinner = new JSpinner(
new javax.swing.SpinnerNumberModel(NetworkPreferences.DEFAULT_TTL, NetworkPreferences.MIN_TTL, NetworkPreferences.MAX_TTL, 1));
private final JLabel interfaceLabel = new JLabel(" ");
private final JTextField ipField = new JTextField(); private final JTextField ipField = new JTextField();
private final JTextField instanceField = new JTextField(); private final JTextField instanceField = new JTextField();
private final JLabel peersLabel = new JLabel(); private final JLabel peersLabel = new JLabel();
private final Timer refreshTimer; private final Timer refreshTimer;
private boolean applying;
private NetworkSettingsListener settingsListener = (name, port) -> {
};
private Map<String, Plugin> loadedByIdentifier = Map.of(); private Map<String, Plugin> loadedByIdentifier = Map.of();
public SettingsPanel(ConnectionManager connectionManager) { public SettingsPanel(ConnectionManager connectionManager) {
@@ -55,7 +71,7 @@ public final class SettingsPanel extends JPanel {
setLayout(new BorderLayout(10, 10)); setLayout(new BorderLayout(10, 10));
setBorder(new EmptyBorder(8, 8, 8, 8)); setBorder(new EmptyBorder(8, 8, 8, 8));
for (JTextField field : List.of(nameField, portField, ipField, instanceField)) { for (JTextField field : List.of(ipField, instanceField)) {
field.setEditable(false); field.setEditable(false);
} }
@@ -94,6 +110,11 @@ public final class SettingsPanel extends JPanel {
refreshTable(); refreshTable();
} }
public void setSettingsListener(NetworkSettingsListener listener) {
this.settingsListener = listener != null ? listener : (name, port) -> {
};
}
public void onTabSelected() { public void onTabSelected() {
refreshNetworkInfo(); refreshNetworkInfo();
} }
@@ -107,27 +128,47 @@ public final class SettingsPanel extends JPanel {
gbc.fill = GridBagConstraints.HORIZONTAL; gbc.fill = GridBagConstraints.HORIZONTAL;
addRow(panel, gbc, 0, "Nome na rede", nameField); addRow(panel, gbc, 0, "Nome na rede", nameField);
addRow(panel, gbc, 1, "Porta", portField); addRow(panel, gbc, 1, "Porta", portSpinner);
addRow(panel, gbc, 2, "IP local", ipField); addRow(panel, gbc, 2, "Saída", egressCombo);
addRow(panel, gbc, 3, "ID desta instância", instanceField); addRow(panel, gbc, 3, "Interface", interfaceLabel);
addRow(panel, gbc, 4, "TTL", ttlSpinner);
gbc.gridx = 1;
gbc.gridy = 5;
panel.add(ShellUi.mutedLabel("1 alcança só a rede local."), gbc);
addRow(panel, gbc, 6, "IP local", ipField);
addRow(panel, gbc, 7, "ID desta instância", instanceField);
gbc.gridx = 0; gbc.gridx = 0;
gbc.gridy = 4; gbc.gridy = 8;
gbc.gridwidth = 1;
gbc.weightx = 0;
panel.add(new JLabel("Peers visíveis:"), gbc); panel.add(new JLabel("Peers visíveis:"), gbc);
gbc.gridx = 1; gbc.gridx = 1;
gbc.weightx = 1;
panel.add(peersLabel, gbc); panel.add(peersLabel, gbc);
JButton apply = new JButton("Aplicar");
apply.setFocusPainted(false);
apply.addActionListener(e -> applyNetworkSettings());
gbc.gridx = 1;
gbc.gridy = 9;
gbc.fill = GridBagConstraints.NONE;
gbc.anchor = GridBagConstraints.WEST;
panel.add(apply, gbc);
return panel; return panel;
} }
private void addRow(JPanel panel, GridBagConstraints gbc, int row, String label, JTextField field) { private void addRow(JPanel panel, GridBagConstraints gbc, int row, String label, Component field) {
gbc.gridx = 0; gbc.gridx = 0;
gbc.gridy = row; gbc.gridy = row;
gbc.gridwidth = 1; gbc.gridwidth = 1;
gbc.weightx = 0; gbc.weightx = 0;
gbc.fill = GridBagConstraints.NONE;
panel.add(new JLabel(label + ":"), gbc); panel.add(new JLabel(label + ":"), gbc);
gbc.gridx = 1; gbc.gridx = 1;
gbc.weightx = 1; gbc.weightx = 1;
gbc.fill = GridBagConstraints.HORIZONTAL;
panel.add(field, gbc); panel.add(field, gbc);
} }
@@ -158,15 +199,123 @@ public final class SettingsPanel extends JPanel {
return wrapper; return wrapper;
} }
private void applyNetworkSettings() {
applying = true;
try {
commitSpinner(portSpinner);
commitSpinner(ttlSpinner);
String name = nameField.getText().trim();
if (name.isEmpty()) {
JOptionPane.showMessageDialog(this, "Informe um nome na rede.");
return;
}
int port = (Integer) portSpinner.getValue();
int ttl = NetworkPreferences.clampTtl((Integer) ttlSpinner.getValue());
LocalNetworkInfo.EgressMode mode = egressFromCombo();
boolean nameChanged = !name.equals(connectionManager.getUserName());
boolean portChanged = port != connectionManager.getPort();
boolean egressChanged = mode != LocalNetworkInfo.getEgressMode();
boolean ttlChanged = ttl != connectionManager.getMulticastTtl();
if (!nameChanged && !portChanged && !egressChanged && !ttlChanged) {
return;
}
if (portChanged || egressChanged || ttlChanged) {
String error = connectionManager.applyNetwork(port, ttl, mode);
if (error != null) {
JOptionPane.showMessageDialog(this, error, "Configuração de rede", JOptionPane.WARNING_MESSAGE);
refreshNetworkInfo();
return;
}
}
NetworkPreferences.saveName(name);
NetworkPreferences.savePort(connectionManager.getPort());
NetworkPreferences.saveTtl(connectionManager.getMulticastTtl());
NetworkPreferences.saveEgress(LocalNetworkInfo.getEgressMode());
settingsListener.onIdentityApplied(name, connectionManager.getPort());
JOptionPane.showMessageDialog(
this,
confirmation(nameChanged, portChanged, connectionManager.getPort(), egressChanged || ttlChanged)
);
} finally {
applying = false;
refreshNetworkInfo();
}
}
private static void commitSpinner(JSpinner spinner) {
try {
spinner.commitEdit();
} catch (java.text.ParseException ignored) {
// mantém o último valor válido do modelo
}
}
private LocalNetworkInfo.EgressMode egressFromCombo() {
Object selected = egressCombo.getSelectedItem();
if ("Cabo".equals(selected)) {
return LocalNetworkInfo.EgressMode.WIRED;
}
if ("Wi-Fi".equals(selected)) {
return LocalNetworkInfo.EgressMode.WIRELESS;
}
return LocalNetworkInfo.EgressMode.AUTO;
}
private static String confirmation(boolean nameChanged, boolean portChanged, int port, boolean egressOrTtlChanged) {
StringBuilder text = new StringBuilder();
if (portChanged) {
text.append("Porta alterada para ")
.append(port)
.append(". Você saiu da sala anterior e passou a procurar colegas na porta ")
.append(port)
.append('.');
} else if (egressOrTtlChanged) {
text.append("Saída de rede atualizada. A descoberta usa esta interface.");
}
if (nameChanged) {
if (text.length() > 0) {
text.append(' ');
}
text.append("Nome atualizado. Os colegas veem o novo nome no próximo anúncio (até 5 s).");
}
return text.toString();
}
private void refreshNetworkInfo() { private void refreshNetworkInfo() {
nameField.setText(connectionManager.getUserName()); if (!applying && !isEditingNetworkFields()) {
portField.setText(String.valueOf(connectionManager.getPort())); nameField.setText(connectionManager.getUserName());
portSpinner.setValue(connectionManager.getPort());
ttlSpinner.setValue(connectionManager.getMulticastTtl());
egressCombo.setSelectedItem(comboLabel(LocalNetworkInfo.getEgressMode()));
}
interfaceLabel.setText(LocalNetworkInfo.describeActiveInterface());
ipField.setText(LocalNetworkInfo.bestLocalIpv4()); ipField.setText(LocalNetworkInfo.bestLocalIpv4());
instanceField.setText(connectionManager.getHeartbeatManager().getInstanceId()); instanceField.setText(connectionManager.getHeartbeatManager().getInstanceId());
int peers = Math.max(0, connectionManager.getPresenceService().listPeerViews().size() - 1); int peers = Math.max(0, connectionManager.getPresenceService().listPeerViews().size() - 1);
peersLabel.setText(peers + " (além de você)"); peersLabel.setText(peers + " (além de você)");
} }
private static String comboLabel(LocalNetworkInfo.EgressMode mode) {
if (mode == LocalNetworkInfo.EgressMode.WIRED) {
return "Cabo";
}
if (mode == LocalNetworkInfo.EgressMode.WIRELESS) {
return "Wi-Fi";
}
return "Automático";
}
private boolean isEditingNetworkFields() {
Component focus = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner();
if (focus == null) {
return false;
}
return SwingUtilities.isDescendingFrom(focus, nameField)
|| SwingUtilities.isDescendingFrom(focus, portSpinner)
|| SwingUtilities.isDescendingFrom(focus, ttlSpinner)
|| SwingUtilities.isDescendingFrom(focus, egressCombo);
}
private void refreshTable() { private void refreshTable() {
tableModel.setRowCount(0); tableModel.setRowCount(0);
try { try {
@@ -22,4 +22,16 @@ class HeartbeatManagerGoodbyeTest {
manager.receiveGoodbye(new GoodbyeMessage("alice", "peer-1")); manager.receiveGoodbye(new GoodbyeMessage("alice", "peer-1"));
assertTrue(manager.getPeers().isEmpty()); assertTrue(manager.getPeers().isEmpty());
} }
@Test
void receiveHeartbeatUpdatesName() throws Exception {
HeartbeatManager manager = HeartbeatManager.createForTesting();
InetAddress addr = InetAddress.getByName("127.0.0.1");
manager.receiveHeartbeat(new HeartbeatMessage("alice", "peer-1"), addr);
manager.receiveHeartbeat(new HeartbeatMessage("alice-novo", "peer-1"), addr);
assertEquals(1, manager.getPeers().size());
assertEquals("alice-novo", manager.getPeers().get(0).getUserName());
}
} }
@@ -0,0 +1,15 @@
package pitiupi.net.tcp;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TcpConnectNoticeTest {
@Test
void existingSocketDoesNotAnnounceConnecting() {
assertFalse(TcpConnectNotice.shouldShowConnecting(true));
assertTrue(TcpConnectNotice.shouldShowConnecting(false));
}
}
@@ -15,6 +15,18 @@ class LocalNetworkInfoTest {
assertFalse(ip.isBlank()); assertFalse(ip.isBlank());
} }
@Test
void classifiesWiredAndWirelessNames() {
assertTrue(LocalNetworkInfo.isWiredInterface("eth0", null));
assertTrue(LocalNetworkInfo.isWiredInterface("enp3s0", null));
assertTrue(LocalNetworkInfo.isWiredInterface("net0", "Ethernet"));
assertTrue(LocalNetworkInfo.isWirelessInterface("wlan0", null));
assertTrue(LocalNetworkInfo.isWirelessInterface("wlp2s0", null));
assertTrue(LocalNetworkInfo.isWirelessInterface("wlan2", "Wi-Fi"));
assertFalse(LocalNetworkInfo.isWiredInterface("wlan0", "Wi-Fi"));
assertFalse(LocalNetworkInfo.isWirelessInterface("eth0", "Ethernet"));
}
@Test @Test
void isLocalIpv4DetectsLoopback() { void isLocalIpv4DetectsLoopback() {
assertTrue(LocalNetworkInfo.isLocalIpv4("127.0.0.1")); assertTrue(LocalNetworkInfo.isLocalIpv4("127.0.0.1"));
@@ -35,4 +35,47 @@ class SessionTabBarStripTest {
assertEquals(PluginSessionKeys.ROOM, bar.selectedKey()); assertEquals(PluginSessionKeys.ROOM, bar.selectedKey());
assertEquals(2, bar.getTabCount()); assertEquals(2, bar.getTabCount());
} }
@Test
void closingMiddleTabKeepsTheTabOnItsRightClosable() {
SessionTabBar bar = new SessionTabBar();
bar.addOrSelectTab(PluginSessionKeys.ROOM, "Sala geral", new JPanel(), false);
String firstPeer = PluginSessionKeys.peer("169.254.165.248");
String secondPeer = PluginSessionKeys.peer("192.168.3.131");
bar.addOrSelectTab(firstPeer, "Afrodite", "169.254.165.248", new JPanel(), true);
bar.addOrSelectTab(secondPeer, "Afrodite", "192.168.3.131", new JPanel(), true);
bar.selectTab(PluginSessionKeys.ROOM);
bar.closeTab(firstPeer);
assertEquals(2, bar.getTabCount());
assertEquals(false, bar.hasTab(firstPeer));
assertEquals(true, bar.hasTab(secondPeer));
assertEquals(PluginSessionKeys.ROOM, bar.selectedKey());
bar.closeTab(secondPeer);
assertEquals(1, bar.getTabCount());
assertEquals(false, bar.hasTab(secondPeer));
assertEquals(PluginSessionKeys.ROOM, bar.selectedKey());
}
@Test
void replaceKeyKeepsTheSameTabClosableUnderTheNewAddress() {
SessionTabBar bar = new SessionTabBar();
bar.addOrSelectTab(PluginSessionKeys.ROOM, "Sala geral", new JPanel(), false);
String cable = PluginSessionKeys.peer("169.254.165.248");
String wifi = PluginSessionKeys.peer("192.168.3.131");
bar.addOrSelectTab(cable, "Afrodite", "169.254.165.248", new JPanel(), true);
assertEquals(true, bar.replaceKey(cable, wifi, "Afrodite", "192.168.3.131", true));
assertEquals(false, bar.hasTab(cable));
assertEquals(true, bar.hasTab(wifi));
assertEquals(2, bar.getTabCount());
bar.closeTab(wifi);
assertEquals(1, bar.getTabCount());
assertEquals(false, bar.hasTab(wifi));
}
} }
@@ -10,6 +10,7 @@ import pitiupi.chat.control.PrivateChatUiSink;
import pitiupi.chat.net.ChatMessage; import pitiupi.chat.net.ChatMessage;
import pitiupi.chat.net.LegacyMpChatMessage; import pitiupi.chat.net.LegacyMpChatMessage;
import pitiupi.control.ConnectionManager; import pitiupi.control.ConnectionManager;
import pitiupi.net.tcp.TcpAttemptListener;
import pitiupi.models.User; import pitiupi.models.User;
import pitiupi.presentation.shell.LocalNetworkInfo; import pitiupi.presentation.shell.LocalNetworkInfo;
import pitiupi.presentation.shell.PeerIpv4; import pitiupi.presentation.shell.PeerIpv4;
@@ -25,6 +26,7 @@ import pitiupi.presentation.shell.ui.ShellUi;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JEditorPane; import javax.swing.JEditorPane;
import javax.swing.JLabel;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JSplitPane; import javax.swing.JSplitPane;
@@ -43,8 +45,10 @@ import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent; import java.awt.event.KeyEvent;
import java.net.InetAddress; import java.net.InetAddress;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
public class ChatPanel extends JPanel implements PrivateChatUiSink { public class ChatPanel extends JPanel implements PrivateChatUiSink {
@@ -55,6 +59,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
private final JEditorPane transcript = new JEditorPane(); private final JEditorPane transcript = new JEditorPane();
private final JTextArea input = new JTextArea(); private final JTextArea input = new JTextArea();
private final JLabel tcpAttemptLabel = ShellUi.mutedLabel(" ");
private final ConnectionManager connectionManager; private final ConnectionManager connectionManager;
private final PluginSessionWorkspace workspace; private final PluginSessionWorkspace workspace;
private final PluginRoutingPanel routingPanel; private final PluginRoutingPanel routingPanel;
@@ -87,6 +92,8 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
buildUi(); buildUi();
initSessionTabs(); initSessionTabs();
routingPanel.addRoutingListener(s -> applyConversationKeyFromRouting()); routingPanel.addRoutingListener(s -> applyConversationKeyFromRouting());
connectionManager.addPeerListener(peers ->
SwingUtilities.invokeLater(() -> adoptCurrentPeerAddresses(peers)));
routingPanel.setPrivateTcpListener(selected -> { routingPanel.setPrivateTcpListener(selected -> {
if (selected) { if (selected) {
User user = resolveUserForSingle(); User user = resolveUserForSingle();
@@ -119,7 +126,91 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
draftPanel.clearIpField(); draftPanel.clearIpField();
} }
SwingUtilities.invokeLater(this::restoreSessionAfterDraftClosed); SwingUtilities.invokeLater(this::restoreSessionAfterDraftClosed);
return;
} }
if (key != null && key.startsWith("peer:")) {
peerDisplayNames.remove(canonicalPeerIp(key.substring("peer:".length())));
}
}
/**
* Uma pessoa que troca de cabo para Wi-Fi muda de IP e manteria duas abas com o mesmo nome.
* A aba aberta no endereço antigo passa a ser a do endereço atual.
*/
private void adoptCurrentPeerAddresses(List<User> peers) {
if (peers == null) {
return;
}
Map<String, User> byIp = new HashMap<>();
for (User peer : peers) {
if (peer == null || peer.isBroadcastTarget() || peer.getAddress() == null) {
continue;
}
byIp.put(canonicalPeerIp(PeerIpv4.fromInetAddress(peer.getAddress())), peer);
}
List<String> openIps = new ArrayList<>(peerDisplayNames.keySet());
for (User peer : byIp.values()) {
String newIp = canonicalPeerIp(PeerIpv4.fromInetAddress(peer.getAddress()));
long sameName = byIp.values().stream()
.filter(other -> peer.getUserName().equals(other.getUserName()))
.count();
if (sameName != 1) {
continue;
}
for (String oldIp : openIps) {
if (oldIp.equals(newIp) || byIp.containsKey(oldIp)) {
continue;
}
String title = peerDisplayNames.get(oldIp);
if (title != null && title.equals(peer.getUserName())
&& sessionTabs.hasTab(PluginSessionKeys.peer(oldIp))) {
retargetPeerSession(oldIp, newIp, peer.getUserName());
}
}
}
}
private void retargetPeerSession(String oldIp, String newIp, String name) {
String oldTab = PluginSessionKeys.peer(oldIp);
String newTab = PluginSessionKeys.peer(newIp);
mergeTranscript(peerKey(oldIp), peerKey(newIp));
mergeTranscript(privateKey(oldIp), privateKey(newIp));
String selectedBefore = sessionTabs.selectedKey();
boolean wasSelected = oldTab.equals(selectedBefore);
if (sessionTabs.hasTab(newTab)) {
sessionTabs.setTabTitle(newTab, name, newIp);
sessionTabs.closeTab(oldTab);
String restore = wasSelected ? newTab : selectedBefore;
if (restore != null && sessionTabs.hasTab(restore)) {
sessionTabs.selectTab(restore);
}
} else {
sessionTabs.replaceKey(oldTab, newTab, name, newIp, true);
}
peerDisplayNames.remove(oldIp);
peerDisplayNames.put(newIp, name);
if (oldTab.equals(lastSessionKey)) {
lastSessionKey = newTab;
}
if (peerKey(oldIp).equals(activeConversationKey)) {
activeConversationKey = peerKey(newIp);
flushTranscriptToPane();
} else if (privateKey(oldIp).equals(activeConversationKey)) {
activeConversationKey = privateKey(newIp);
flushTranscriptToPane();
}
if (wasSelected) {
routingPanel.bindPeerSession(newIp, name);
}
}
private void mergeTranscript(String oldBucket, String newBucket) {
StringBuilder oldBuf = transcripts.remove(oldBucket);
if (oldBuf == null || oldBuf.length() == 0) {
return;
}
StringBuilder current = transcripts.computeIfAbsent(newBucket, key -> new StringBuilder());
current.append(oldBuf);
} }
private void applyConversationKeyFromRouting() { private void applyConversationKeyFromRouting() {
@@ -364,8 +455,10 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
inputScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); inputScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
ShellUi.borderlessScroll(inputScroll); ShellUi.borderlessScroll(inputScroll);
tcpAttemptLabel.setVisible(false);
JPanel compose = new JPanel(new BorderLayout(0, 4)); JPanel compose = new JPanel(new BorderLayout(0, 4));
compose.setPreferredSize(new Dimension(0, 72)); compose.setPreferredSize(new Dimension(0, 88));
compose.add(tcpAttemptLabel, BorderLayout.NORTH);
compose.add(inputScroll, BorderLayout.CENTER); compose.add(inputScroll, BorderLayout.CENTER);
JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0));
actions.add(send); actions.add(send);
@@ -420,19 +513,26 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
appendSystemLine(activeConversationKey, "Informe o IP do peer para o canal TCP privado."); appendSystemLine(activeConversationKey, "Informe o IP do peer para o canal TCP privado.");
return; return;
} }
if (privateChatController != null && privateChatController.sendText(user, text)) { if (privateChatController != null && privateChatController.hasOpenChannel(user)) {
input.setText(""); if (privateChatController.sendText(user, text)) {
input.setText("");
}
return; return;
} }
if (privateChatController != null) { if (privateChatController != null) {
privateChatController.openChat(user); privateChatController.openChat(user);
if (privateChatController.sendText(user, text)) {
input.setText("");
}
} }
appendSystemLine(peerKey(canonicalPeerIp(PeerIpv4.fromInetAddress(user.getAddress()))),
"Canal TCP privado conectando…");
return; return;
} }
TransportMode mode = routing.transport(); TransportMode mode = routing.transport();
if (mode == TransportMode.TCP) {
sendTcpOffEdt(text, routing);
return;
}
SendTarget target = resolveSendTarget(routing); SendTarget target = resolveSendTarget(routing);
if (target == null || mode == null) { if (target == null || mode == null) {
appendSystemLine(activeConversationKey, "Informe um IP válido para enviar a um peer."); appendSystemLine(activeConversationKey, "Informe um IP válido para enviar a um peer.");
@@ -469,6 +569,125 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
} }
} }
public void showTcpAttempt(String text) {
tcpAttemptLabel.setText(text);
tcpAttemptLabel.setVisible(true);
}
public void clearTcpAttempt() {
tcpAttemptLabel.setText(" ");
tcpAttemptLabel.setVisible(false);
}
public void failTcpAttempt(String message) {
clearTcpAttempt();
appendSystemLine(activeConversationKey, message);
flushTranscriptToPane();
}
private void sendTcpOffEdt(String text, PluginRoutingPanel.RoutingSnapshot routing) {
String localIp = PeerIpv4.normalize(LocalNetworkInfo.bestLocalIpv4());
ChatMessage message = new ChatMessage(
connectionManager.getUserName(),
text,
TransportMode.TCP.getLabel(),
localIp
);
if (routing.everyoneInRoom()) {
sendTcpToRoom(message);
return;
}
String peerIp = canonicalPeerIp(routing.peerIp());
if (peerIp == null || peerIp.isEmpty()) {
appendSystemLine(activeConversationKey, "Informe um IP válido para enviar a um peer.");
return;
}
if (LocalNetworkInfo.isLocalIpv4(peerIp)) {
appendSystemLine(activeConversationKey, "TCP para o próprio host não é suportado; use sala geral ou UDP unicast.");
return;
}
connectionManager.sendTcpAsync(message, peerIp, new TcpAttemptListener() {
@Override
public void onConnecting(String endpoint) {
showTcpAttempt("Conectando em " + endpoint + "…");
}
@Override
public void onSuccess(pitiupi.net.tcp.TcpConnection connection) {
clearTcpAttempt();
appendChatLine(activeConversationKey, message);
input.setText("");
flushTranscriptToPane();
}
@Override
public void onFailure(String error) {
failTcpAttempt(error);
}
});
}
private void sendTcpToRoom(ChatMessage message) {
java.util.List<String> peers = new java.util.ArrayList<>();
for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) {
if (view.isBroadcastTarget() || view.isSelf()) {
continue;
}
view.getInetAddress().ifPresent(address -> peers.add(PeerIpv4.fromInetAddress(address)));
}
if (peers.isEmpty()) {
appendSystemLine(activeConversationKey, "Nenhum colega na sala para receber por TCP.");
return;
}
int total = peers.size();
int[] remaining = {total};
int[] succeeded = {0};
boolean[] announced = {false};
for (String ip : peers) {
connectionManager.sendTcpAsync(message, ip, new TcpAttemptListener() {
@Override
public void onConnecting(String endpoint) {
if (!announced[0]) {
announced[0] = true;
showTcpAttempt("Conectando por TCP…");
}
}
@Override
public void onSuccess(pitiupi.net.tcp.TcpConnection connection) {
finishRoomTcp(message, total, remaining, succeeded, true);
}
@Override
public void onFailure(String error) {
finishRoomTcp(message, total, remaining, succeeded, false);
}
});
}
}
private void finishRoomTcp(ChatMessage message, int total, int[] remaining, int[] succeeded, boolean thisOk) {
if (thisOk) {
succeeded[0]++;
}
remaining[0]--;
if (remaining[0] > 0) {
return;
}
clearTcpAttempt();
if (succeeded[0] == 0) {
appendSystemLine(activeConversationKey, "Não foi possível conectar nos colegas por TCP.");
flushTranscriptToPane();
return;
}
if (succeeded[0] < total) {
appendSystemLine(activeConversationKey, "Não foi possível conectar em um ou mais colegas por TCP.");
}
appendChatLine(activeConversationKey, message);
input.setText("");
flushTranscriptToPane();
}
private SendTarget resolveSendTarget(PluginRoutingPanel.RoutingSnapshot routing) throws Exception { private SendTarget resolveSendTarget(PluginRoutingPanel.RoutingSnapshot routing) throws Exception {
if (routing.everyoneInRoom()) { if (routing.everyoneInRoom()) {
return SendTarget.broadcastAll(); return SendTarget.broadcastAll();
@@ -86,6 +86,15 @@ public class PrivateChatController {
} }
} }
public void abortOutgoing(String address) {
if (address == null) {
return;
}
String canonical = PeerIpv4.normalize(address);
pendingConnections.remove(canonical);
pendingTextByAddress.remove(canonical);
}
public void closeChat(TcpConnection connection) { public void closeChat(TcpConnection connection) {
String address = addressKey(connection); String address = addressKey(connection);
connections.remove(address); connections.remove(address);
@@ -15,6 +15,7 @@ import pitiupi.control.ConnectionManager;
import pitiupi.models.Messages.Message; import pitiupi.models.Messages.Message;
import pitiupi.models.Plugin; import pitiupi.models.Plugin;
import pitiupi.models.User; import pitiupi.models.User;
import pitiupi.net.tcp.TcpAttemptListener;
import pitiupi.net.tcp.TcpConnection; import pitiupi.net.tcp.TcpConnection;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
@@ -133,16 +134,30 @@ public class ChatPlugin extends Plugin implements PrivateChatHost {
@Override @Override
public void openConnection(String ip) { public void openConnection(String ip) {
try { connectionManager.connectPluginTcpAsync(ip, getIdentifier(), getIdentifier(), new TcpAttemptListener() {
TcpConnection connection = connectionManager.connectPluginTcp( @Override
InetAddress.getByName(ip), public void onConnecting(String endpoint) {
getIdentifier(), if (chatPanel != null) {
getIdentifier() chatPanel.showTcpAttempt("Conectando em " + endpoint + "…");
); }
privateChatController.registerOutgoingConnection(ip, connection); }
} catch (Exception e) {
System.out.println("Error opening connection: " + e.getMessage()); @Override
} public void onSuccess(TcpConnection connection) {
if (chatPanel != null) {
chatPanel.clearTcpAttempt();
}
privateChatController.registerOutgoingConnection(ip, connection);
}
@Override
public void onFailure(String message) {
privateChatController.abortOutgoing(ip);
if (chatPanel != null) {
chatPanel.failTcpAttempt(message);
}
}
});
} }
@Override @Override