diff --git a/docs/README.md b/docs/README.md index e370be8..f262c51 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,14 +17,14 @@ Convenções editoriais: [documentation/00_convencoes.md](documentation/00_conve | 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) | -| **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) | ## Manutenção rápida | Mudança | Onde documentar | | --- | --- | -| UDP/TCP, multicast, portas | 05, 06, appendix | +| UDP/TCP, multicast, portas | 05, 06, appendix, 17 | | SPI / plugins | 09, 10 | | Decisão de arquitetura | decision-register | | Débito técnico | engineering_backlog | diff --git a/docs/decision-register.md b/docs/decision-register.md index 2a0bd3a..9b34804 100644 --- a/docs/decision-register.md +++ b/docs/decision-register.md @@ -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) | | 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) | +| 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. diff --git a/docs/documentation/04_inicializacao_e_gui.md b/docs/documentation/04_inicializacao_e_gui.md index 710d4bb..aaf6279 100644 --- a/docs/documentation/04_inicializacao_e_gui.md +++ b/docs/documentation/04_inicializacao_e_gui.md @@ -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). -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` 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). 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**). 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. diff --git a/docs/documentation/05_udp_multicast.md b/docs/documentation/05_udp_multicast.md index 61d16e7..3615bcd 100644 --- a/docs/documentation/05_udp_multicast.md +++ b/docs/documentation/05_udp_multicast.md @@ -24,7 +24,8 @@ Durante sua inicialização, o `SocketUDP`: 2. cria um `MulticastSocket` na **porta escolhida no login** (mesma porta TCP da sala); 3. configura os buffers de envio e recepçã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. diff --git a/docs/documentation/06_tcp_conexoes.md b/docs/documentation/06_tcp_conexoes.md index beb01cb..bdb4eed 100644 --- a/docs/documentation/06_tcp_conexoes.md +++ b/docs/documentation/06_tcp_conexoes.md @@ -10,6 +10,10 @@ múltiplas mensagens. 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. ### SocketTCP diff --git a/docs/documentation/08_heartbeat_e_presenca.md b/docs/documentation/08_heartbeat_e_presenca.md index d178d8d..c26a47d 100644 --- a/docs/documentation/08_heartbeat_e_presenca.md +++ b/docs/documentation/08_heartbeat_e_presenca.md @@ -72,7 +72,13 @@ Lista de peers ativos | 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 | -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 flowchart LR diff --git a/docs/documentation/17_teste_com_colega.md b/docs/documentation/17_teste_com_colega.md new file mode 100644 index 0000000..3a9c69e --- /dev/null +++ b/docs/documentation/17_teste_com_colega.md @@ -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). diff --git a/docs/documentation/README.md b/docs/documentation/README.md index c44086c..e86ad61 100644 --- a/docs/documentation/README.md +++ b/docs/documentation/README.md @@ -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) | | **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) | | **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 | | 13–15 | 13–15 | Dev, manutenção, testes | | 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 | diff --git a/docs/documentation/appendix_portas_e_multicast.md b/docs/documentation/appendix_portas_e_multicast.md index cd895b9..10a6dc9 100644 --- a/docs/documentation/appendix_portas_e_multicast.md +++ b/docs/documentation/appendix_portas_e_multicast.md @@ -2,9 +2,11 @@ ## Configuração -- **Nome** e **porta** definidos no diálogo de login (`Login`). -- A mesma porta é usada para UDP (multicast socket) e TCP (`ServerSocket`). -- Preferências persistidas localmente (`java.util.prefs`). +- **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`). Trocar a porta sai da sala antiga (goodbye) e passa a procurar colegas na porta nova. +- **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 @@ -31,7 +33,9 @@ Executar o Pitiupi no WSL e peers no Windows (ou vice-versa) pode falhar em mult ## 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) @@ -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? | | 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 | diff --git a/pitiupi/src/pitiupi/control/ConnectionManager.java b/pitiupi/src/pitiupi/control/ConnectionManager.java index 7a7e8a2..64a518d 100644 --- a/pitiupi/src/pitiupi/control/ConnectionManager.java +++ b/pitiupi/src/pitiupi/control/ConnectionManager.java @@ -11,10 +11,19 @@ import pitiupi.models.Messages.GoodbyeMessage; import pitiupi.models.Messages.HeartbeatMessage; import pitiupi.models.Messages.Message; import pitiupi.net.tcp.SocketTCP; +import pitiupi.net.tcp.TcpAttemptListener; +import pitiupi.net.tcp.TcpConnectNotice; import pitiupi.net.tcp.TcpConnection; 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.UnknownHostException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Consumer; public class ConnectionManager { @@ -29,10 +38,18 @@ public class ConnectionManager { private final PeerActivityInbox peerActivityInbox; 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 SocketUDP socketUDP; private String username; private int port; + private int multicastTtl; private volatile boolean shutdownComplete; private Consumer inviteAcceptedUiListener = pluginId -> { }; @@ -42,10 +59,15 @@ public class ConnectionManager { this.username = username; this.port = port; + NetworkPreferences.applyEgressBeforeSockets(); + this.multicastTtl = NetworkPreferences.loadTtl(); pitiupiIdentifier = "Pitiupi.Alice.2026"; - reconnect(); + String bindError = openSockets(); + if (bindError != null) { + System.err.println(bindError); + } heartbeatManager = new HeartbeatManager(this); peerActionBroker = new PeerActionBroker(); @@ -106,25 +128,123 @@ public class ConnectionManager { } shutdownComplete = true; heartbeatManager.sendGoodbyeAndStop(); - if (socketUDP != null) { - socketUDP.close(); + closeSockets(); + 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) { - socketTCP.close(); + LocalNetworkInfo.EgressMode requested = mode == null ? LocalNetworkInfo.EgressMode.AUTO : mode; + 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) { - 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) { - 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) { + SocketUDP udp = currentUdp(); + if (udp == null) { + return false; + } try { - socketUDP.sendUnicast(message, destinationAddress); + udp.sendUnicast(message, destinationAddress); return true; } catch (Exception ex) { return false; @@ -132,8 +252,12 @@ public class ConnectionManager { } public boolean sendUdpMulticast(Message message) { + SocketUDP udp = currentUdp(); + if (udp == null) { + return false; + } try { - socketUDP.sendMulticast(message); + udp.sendMulticast(message); return true; } catch (Exception e) { System.err.println("Error sending UDP Multicast"); @@ -157,9 +281,8 @@ public class ConnectionManager { presenceService.onGoodbye(message); } - public void setPort(int port) { - this.port = port; - reconnect(); + public boolean setPort(int port) { + return applyNetwork(port, multicastTtl, LocalNetworkInfo.getEgressMode()) == null; } public int getPort() { @@ -170,19 +293,105 @@ public class ConnectionManager { return this.pitiupiIdentifier; } - private void reconnect() { - if (this.socketUDP != null) { - this.socketUDP.close(); + private SocketUDP currentUdp() { + synchronized (socketsLock) { + return socketUDP; } - if (this.socketTCP != null) { - this.socketTCP.close(); + } + + private SocketTCP currentTcp() { + synchronized (socketsLock) { + return socketTCP; } + } - this.socketUDP = new SocketUDP(this, pluginManager); - this.socketTCP = new SocketTCP(this, pluginManager); + private void closeSockets() { + 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(); - this.socketTCP.start(); + private String openSockets() { + 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() { diff --git a/pitiupi/src/pitiupi/control/HeartbeatManager.java b/pitiupi/src/pitiupi/control/HeartbeatManager.java index 7b295f1..0e4a718 100644 --- a/pitiupi/src/pitiupi/control/HeartbeatManager.java +++ b/pitiupi/src/pitiupi/control/HeartbeatManager.java @@ -78,14 +78,32 @@ public class HeartbeatManager { connectionManager.sendUdpMulticast(msg); } - public void sendGoodbyeAndStop() { - if (connectionManager != null) { - GoodbyeMessage msg = new GoodbyeMessage(connectionManager.getUserName(), instanceId); - connectionManager.sendUdpMulticast(msg); + public void announceNow() { + announce(); + } + + public void sendGoodbye() { + if (connectionManager == null) { + return; } + GoodbyeMessage msg = new GoodbyeMessage(connectionManager.getUserName(), instanceId); + connectionManager.sendUdpMulticast(msg); + } + + public void sendGoodbyeAndStop() { + sendGoodbye(); stop(); } + public void clearPeers() { + if (peers.isEmpty()) { + notifyListeners(); + return; + } + peers.clear(); + notifyListeners(); + } + public void receiveGoodbye(GoodbyeMessage msg) { if (msg.getInstanceId().equals(instanceId)) { return; @@ -115,13 +133,15 @@ public class HeartbeatManager { return; } - String key = msg.getInstanceId(); - boolean isNew = !peers.containsKey(key); - - User peer = peers.computeIfAbsent(key, k -> new User(msg.getUserName(), from, msg.getTimestamp(), msg.getInstanceId())); + String key = msg.getInstanceId(); + User peer = peers.get(key); + if (peer == null) { + peers.put(key, new User(msg.getUserName(), from, msg.getTimestamp(), msg.getInstanceId())); + notifyListeners(); + return; + } peer.touch(System.currentTimeMillis()); - - if (isNew) { + if (peer.updateIdentity(msg.getUserName(), from)) { notifyListeners(); } } diff --git a/pitiupi/src/pitiupi/models/User.java b/pitiupi/src/pitiupi/models/User.java index 520b5fe..383d9d4 100644 --- a/pitiupi/src/pitiupi/models/User.java +++ b/pitiupi/src/pitiupi/models/User.java @@ -16,8 +16,8 @@ public class User { public static final String BROADCAST_INSTANCE_ID = "__broadcast__"; - private final String userName; - private final InetAddress address; + private String userName; + private InetAddress address; private volatile long lastSeen; private final String instanceId; @@ -65,6 +65,24 @@ public class User { 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 public String toString() { if (isBroadcastTarget() || address == null) { diff --git a/pitiupi/src/pitiupi/net/tcp/ConnectionFactory.java b/pitiupi/src/pitiupi/net/tcp/ConnectionFactory.java index 07f1482..b6443f8 100644 --- a/pitiupi/src/pitiupi/net/tcp/ConnectionFactory.java +++ b/pitiupi/src/pitiupi/net/tcp/ConnectionFactory.java @@ -7,7 +7,10 @@ import pitiupi.models.Messages.InfoType; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.ObjectInputStream; +import pitiupi.presentation.shell.LocalNetworkInfo; + import java.net.InetAddress; +import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketTimeoutException; import java.util.logging.Level; @@ -28,6 +31,7 @@ import java.util.logging.Logger; public class ConnectionFactory { private static final int HANDSHAKE_TIMEOUT = 1000; 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 @@ -55,7 +59,12 @@ public class ConnectionFactory { try { 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.setSender(sender); connection.setReceiver(receiver); diff --git a/pitiupi/src/pitiupi/net/tcp/SocketTCP.java b/pitiupi/src/pitiupi/net/tcp/SocketTCP.java index 09c7d46..872945d 100644 --- a/pitiupi/src/pitiupi/net/tcp/SocketTCP.java +++ b/pitiupi/src/pitiupi/net/tcp/SocketTCP.java @@ -64,7 +64,10 @@ public class SocketTCP extends Thread { tcpConnectionManager = new TcpConnectionManager(); 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()); } catch (IOException ex) { 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 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) { try { TcpConnection connection = getPublicConnection( diff --git a/pitiupi/src/pitiupi/net/tcp/TcpAttemptListener.java b/pitiupi/src/pitiupi/net/tcp/TcpAttemptListener.java new file mode 100644 index 0000000..6532419 --- /dev/null +++ b/pitiupi/src/pitiupi/net/tcp/TcpAttemptListener.java @@ -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); +} diff --git a/pitiupi/src/pitiupi/net/tcp/TcpConnectNotice.java b/pitiupi/src/pitiupi/net/tcp/TcpConnectNotice.java new file mode 100644 index 0000000..243bdd5 --- /dev/null +++ b/pitiupi/src/pitiupi/net/tcp/TcpConnectNotice.java @@ -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."; + } +} diff --git a/pitiupi/src/pitiupi/net/udp/SocketUDP.java b/pitiupi/src/pitiupi/net/udp/SocketUDP.java index 0c150af..2750efd 100644 --- a/pitiupi/src/pitiupi/net/udp/SocketUDP.java +++ b/pitiupi/src/pitiupi/net/udp/SocketUDP.java @@ -46,6 +46,7 @@ public class SocketUDP extends Thread { private final PluginManager pluginManager; private volatile boolean running = true; + private volatile boolean joined; 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); int port = connectionManager.getPort(); - multicastSocket = new MulticastSocket(port); + multicastSocket = new MulticastSocket(null); + multicastSocket.setReuseAddress(true); + multicastSocket.bind(new InetSocketAddress(port)); multicastSocket.setSendBufferSize(256000); multicastSocket.setReceiveBufferSize(256000); - multicastSocket.setReuseAddress(true); - multicastSocket.setTimeToLive(1); + multicastSocket.setTimeToLive(connectionManager.getMulticastTtl()); multicastInterface = LocalNetworkInfo.preferredNetworkInterface(); if (multicastInterface != null) { multicastSocket.setNetworkInterface(multicastInterface); @@ -77,6 +79,7 @@ public class SocketUDP extends Thread { } else { multicastSocket.joinGroup(address); } + joined = true; } catch (UnknownHostException 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. */ + public boolean isJoined() { + return joined && multicastSocket != null && !multicastSocket.isClosed(); + } + public void close() { running = false; + joined = false; if (multicastSocket != null) { try { int port = connectionManager.getPort(); diff --git a/pitiupi/src/pitiupi/presentation/shell/ClosableTabbedPane.java b/pitiupi/src/pitiupi/presentation/shell/ClosableTabbedPane.java index 0d9d68d..0fef29b 100644 --- a/pitiupi/src/pitiupi/presentation/shell/ClosableTabbedPane.java +++ b/pitiupi/src/pitiupi/presentation/shell/ClosableTabbedPane.java @@ -153,18 +153,44 @@ public final class ClosableTabbedPane { 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) { reconcileKeyListWithTabPane(); int index = indexOfKey(key); if (index < 0 || index >= tabbedPane.getTabCount()) { return; } - tabbedPane.removeTabAt(index); - if (index < orderedKeys.size()) { - orderedKeys.remove(index); + boolean prev = suppressSelectionEvents; + suppressSelectionEvents = true; + 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); String next = null; if (fallbackKey != null && !fallbackKey.isBlank() && hasTab(fallbackKey)) { @@ -251,16 +277,22 @@ public final class ClosableTabbedPane { if (indices.size() <= 1) { return; } - for (int i = indices.size() - 1; i >= 1; i--) { - int idx = indices.get(i); - if (idx >= 0 && idx < tabbedPane.getTabCount()) { - tabbedPane.removeTabAt(idx); - } - if (idx < orderedKeys.size()) { - orderedKeys.remove(idx); + boolean prev = suppressSelectionEvents; + suppressSelectionEvents = true; + try { + for (int i = indices.size() - 1; i >= 1; i--) { + int idx = indices.get(i); + if (idx < orderedKeys.size()) { + 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) { diff --git a/pitiupi/src/pitiupi/presentation/shell/LocalNetworkInfo.java b/pitiupi/src/pitiupi/presentation/shell/LocalNetworkInfo.java index 7e1afcc..c8e963d 100644 --- a/pitiupi/src/pitiupi/presentation/shell/LocalNetworkInfo.java +++ b/pitiupi/src/pitiupi/presentation/shell/LocalNetworkInfo.java @@ -11,15 +11,45 @@ import java.util.Locale; */ public final class LocalNetworkInfo { + public enum EgressMode { + AUTO, + WIRED, + WIRELESS + } + private static final String APIPA_PREFIX = "169.254."; + private static volatile EgressMode egressMode = EgressMode.AUTO; 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() { + return selectInterface(egressMode); + } + + public static NetworkInterface selectInterface(EgressMode mode) { + EgressMode effective = mode == null ? EgressMode.AUTO : mode; try { NetworkInterface wiredApipa = null; NetworkInterface wiredOther = null; @@ -34,8 +64,8 @@ public final class LocalNetworkInfo { if (fallback == null) { fallback = ni; } - String name = ni.getName(); - if (isWiredLanInterfaceName(name)) { + Kind kind = classify(ni); + if (kind == Kind.WIRED) { if (hasApipaIpv4(ni)) { if (wiredApipa == null) { wiredApipa = ni; @@ -43,10 +73,19 @@ public final class LocalNetworkInfo { } else if (wiredOther == null) { wiredOther = ni; } - } else if (isWirelessLanInterfaceName(name) && wireless == null) { + } else if (kind == Kind.WIRELESS && wireless == null) { wireless = ni; } } + if (effective == EgressMode.WIRED) { + if (wiredApipa != null) { + return wiredApipa; + } + return wiredOther; + } + if (effective == EgressMode.WIRELESS) { + return wireless; + } if (wiredApipa != null) { 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() { try { NetworkInterface preferred = preferredNetworkInterface(); @@ -120,25 +192,52 @@ public final class LocalNetworkInfo { || n.startsWith("tailscale"); } - private static boolean isWiredLanInterfaceName(String name) { - if (name == null) { + private enum Kind { + 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; } - String n = name.toLowerCase(Locale.ROOT); + String n = value.toLowerCase(Locale.ROOT); return n.startsWith("eth") || n.startsWith("eno") || n.startsWith("enp") - || n.startsWith("ens"); + || n.startsWith("ens") + || n.contains("ethernet"); } - private static boolean isWirelessLanInterfaceName(String name) { - if (name == null) { + private static boolean matchesWirelessToken(String value) { + if (value == null) { return false; } - String n = name.toLowerCase(Locale.ROOT); + String n = value.toLowerCase(Locale.ROOT); return n.startsWith("wlan") || 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) { diff --git a/pitiupi/src/pitiupi/presentation/shell/LoginDialog.java b/pitiupi/src/pitiupi/presentation/shell/LoginDialog.java index d2f47ab..778a5a7 100644 --- a/pitiupi/src/pitiupi/presentation/shell/LoginDialog.java +++ b/pitiupi/src/pitiupi/presentation/shell/LoginDialog.java @@ -13,17 +13,11 @@ import java.awt.FlowLayout; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; -import java.util.prefs.Preferences; - /** * Login modal centralizado (~30% da área útil do monitor). */ 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 JSpinner portField = new JSpinner(); private boolean accepted; @@ -37,8 +31,8 @@ public final class LoginDialog extends JDialog { } private void buildUi() { - String nomeSalvo = prefs.get(PREF_NOME, System.getProperty("user.name")); - int portaSalva = prefs.getInt(PREF_PORTA, 5000); + String nomeSalvo = NetworkPreferences.loadName(); + int portaSalva = NetworkPreferences.loadPort(); portField.setModel(new javax.swing.SpinnerNumberModel(portaSalva, 1, 65535, 1)); nameField.setText(nomeSalvo); @@ -104,8 +98,8 @@ public final class LoginDialog extends JDialog { if (port <= 0) { return; } - prefs.put(PREF_NOME, name); - prefs.putInt(PREF_PORTA, port); + NetworkPreferences.saveName(name); + NetworkPreferences.savePort(port); accepted = true; setVisible(false); dispose(); diff --git a/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java b/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java index 69ee4f3..7016cd5 100644 --- a/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java +++ b/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java @@ -38,6 +38,7 @@ public final class MainShellFrame extends JFrame implements ShellHost { connectionManager = new ConnectionManager(userName, pluginManager, port); settingsPanel = new SettingsPanel(connectionManager); + settingsPanel.setSettingsListener(this::onIdentityApplied); PluginGalleryPanel gallery = new PluginGalleryPanel((pluginId, title) -> { }); tabWorkspace = new TabWorkspacePanel(gallery, settingsPanel); galleryPanel = gallery; @@ -131,6 +132,11 @@ public final class MainShellFrame extends JFrame implements ShellHost { System.exit(0); } + private void onIdentityApplied(String userName, int port) { + setUserName(userName); + updateStatus(userName, port); + } + @Override public void setUserName(String userName) { connectionManager.setUserName(userName); diff --git a/pitiupi/src/pitiupi/presentation/shell/NetworkPreferences.java b/pitiupi/src/pitiupi/presentation/shell/NetworkPreferences.java new file mode 100644 index 0000000..067097b --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/NetworkPreferences.java @@ -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; + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/SessionTabBar.java b/pitiupi/src/pitiupi/presentation/shell/SessionTabBar.java index 68f61ea..2aeb682 100644 --- a/pitiupi/src/pitiupi/presentation/shell/SessionTabBar.java +++ b/pitiupi/src/pitiupi/presentation/shell/SessionTabBar.java @@ -131,6 +131,10 @@ public final class SessionTabBar { 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() { return tabs.selectedKey(); } diff --git a/pitiupi/src/pitiupi/presentation/shell/SettingsPanel.java b/pitiupi/src/pitiupi/presentation/shell/SettingsPanel.java index abab9b0..8dd7c7b 100644 --- a/pitiupi/src/pitiupi/presentation/shell/SettingsPanel.java +++ b/pitiupi/src/pitiupi/presentation/shell/SettingsPanel.java @@ -8,23 +8,28 @@ import pitiupi.plugins.store.PluginStore; import pitiupi.presentation.shell.ui.ShellUi; import javax.swing.JButton; +import javax.swing.JComboBox; import javax.swing.JFileChooser; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; +import javax.swing.JSpinner; import javax.swing.JTable; import javax.swing.JTextField; +import javax.swing.SwingUtilities; import javax.swing.Timer; import javax.swing.border.EmptyBorder; import javax.swing.filechooser.FileNameExtensionFilter; import javax.swing.table.DefaultTableModel; import java.awt.BorderLayout; +import java.awt.Component; import java.awt.Dimension; import java.awt.FlowLayout; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; +import java.awt.KeyboardFocusManager; import java.io.File; import java.nio.file.Path; import java.util.HashMap; @@ -36,17 +41,28 @@ import java.util.Map; */ public final class SettingsPanel extends JPanel { + public interface NetworkSettingsListener { + void onIdentityApplied(String userName, int port); + } + private final PluginStore store; private final ConnectionManager connectionManager; private final DefaultTableModel tableModel = new DefaultTableModel(new String[]{"Nome", "ID", "Arquivo", "Versão"}, 0); private final JTable table = new JTable(tableModel); 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 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 instanceField = new JTextField(); private final JLabel peersLabel = new JLabel(); private final Timer refreshTimer; + private boolean applying; + private NetworkSettingsListener settingsListener = (name, port) -> { + }; private Map loadedByIdentifier = Map.of(); public SettingsPanel(ConnectionManager connectionManager) { @@ -55,7 +71,7 @@ public final class SettingsPanel extends JPanel { setLayout(new BorderLayout(10, 10)); 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); } @@ -94,6 +110,11 @@ public final class SettingsPanel extends JPanel { refreshTable(); } + public void setSettingsListener(NetworkSettingsListener listener) { + this.settingsListener = listener != null ? listener : (name, port) -> { + }; + } + public void onTabSelected() { refreshNetworkInfo(); } @@ -107,27 +128,47 @@ public final class SettingsPanel extends JPanel { gbc.fill = GridBagConstraints.HORIZONTAL; addRow(panel, gbc, 0, "Nome na rede", nameField); - addRow(panel, gbc, 1, "Porta", portField); - addRow(panel, gbc, 2, "IP local", ipField); - addRow(panel, gbc, 3, "ID desta instância", instanceField); + addRow(panel, gbc, 1, "Porta", portSpinner); + addRow(panel, gbc, 2, "Saída", egressCombo); + 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.gridy = 4; + gbc.gridy = 8; + gbc.gridwidth = 1; + gbc.weightx = 0; panel.add(new JLabel("Peers visíveis:"), gbc); gbc.gridx = 1; + gbc.weightx = 1; 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; } - 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.gridy = row; gbc.gridwidth = 1; gbc.weightx = 0; + gbc.fill = GridBagConstraints.NONE; panel.add(new JLabel(label + ":"), gbc); gbc.gridx = 1; gbc.weightx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; panel.add(field, gbc); } @@ -158,15 +199,123 @@ public final class SettingsPanel extends JPanel { 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() { - nameField.setText(connectionManager.getUserName()); - portField.setText(String.valueOf(connectionManager.getPort())); + if (!applying && !isEditingNetworkFields()) { + 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()); instanceField.setText(connectionManager.getHeartbeatManager().getInstanceId()); int peers = Math.max(0, connectionManager.getPresenceService().listPeerViews().size() - 1); 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() { tableModel.setRowCount(0); try { diff --git a/pitiupi/test/pitiupi/control/HeartbeatManagerGoodbyeTest.java b/pitiupi/test/pitiupi/control/HeartbeatManagerGoodbyeTest.java index 68ec29b..4e3411f 100644 --- a/pitiupi/test/pitiupi/control/HeartbeatManagerGoodbyeTest.java +++ b/pitiupi/test/pitiupi/control/HeartbeatManagerGoodbyeTest.java @@ -22,4 +22,16 @@ class HeartbeatManagerGoodbyeTest { manager.receiveGoodbye(new GoodbyeMessage("alice", "peer-1")); 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()); + } } diff --git a/pitiupi/test/pitiupi/net/tcp/TcpConnectNoticeTest.java b/pitiupi/test/pitiupi/net/tcp/TcpConnectNoticeTest.java new file mode 100644 index 0000000..c4cb0d6 --- /dev/null +++ b/pitiupi/test/pitiupi/net/tcp/TcpConnectNoticeTest.java @@ -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)); + } +} diff --git a/pitiupi/test/pitiupi/presentation/shell/LocalNetworkInfoTest.java b/pitiupi/test/pitiupi/presentation/shell/LocalNetworkInfoTest.java index 2df765a..500ff53 100644 --- a/pitiupi/test/pitiupi/presentation/shell/LocalNetworkInfoTest.java +++ b/pitiupi/test/pitiupi/presentation/shell/LocalNetworkInfoTest.java @@ -15,6 +15,18 @@ class LocalNetworkInfoTest { 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 void isLocalIpv4DetectsLoopback() { assertTrue(LocalNetworkInfo.isLocalIpv4("127.0.0.1")); diff --git a/pitiupi/test/pitiupi/presentation/shell/SessionTabBarStripTest.java b/pitiupi/test/pitiupi/presentation/shell/SessionTabBarStripTest.java index 44fc16a..281f983 100644 --- a/pitiupi/test/pitiupi/presentation/shell/SessionTabBarStripTest.java +++ b/pitiupi/test/pitiupi/presentation/shell/SessionTabBarStripTest.java @@ -35,4 +35,47 @@ class SessionTabBarStripTest { assertEquals(PluginSessionKeys.ROOM, bar.selectedKey()); 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)); + } } diff --git a/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java b/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java index 1e27ce6..d5b65d0 100644 --- a/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java +++ b/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java @@ -10,6 +10,7 @@ import pitiupi.chat.control.PrivateChatUiSink; import pitiupi.chat.net.ChatMessage; import pitiupi.chat.net.LegacyMpChatMessage; import pitiupi.control.ConnectionManager; +import pitiupi.net.tcp.TcpAttemptListener; import pitiupi.models.User; import pitiupi.presentation.shell.LocalNetworkInfo; import pitiupi.presentation.shell.PeerIpv4; @@ -25,6 +26,7 @@ import pitiupi.presentation.shell.ui.ShellUi; import javax.swing.BorderFactory; import javax.swing.JButton; import javax.swing.JEditorPane; +import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSplitPane; @@ -43,8 +45,10 @@ import java.awt.event.ActionEvent; import java.awt.event.KeyEvent; import java.net.InetAddress; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; 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 JTextArea input = new JTextArea(); + private final JLabel tcpAttemptLabel = ShellUi.mutedLabel(" "); private final ConnectionManager connectionManager; private final PluginSessionWorkspace workspace; private final PluginRoutingPanel routingPanel; @@ -87,6 +92,8 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { buildUi(); initSessionTabs(); routingPanel.addRoutingListener(s -> applyConversationKeyFromRouting()); + connectionManager.addPeerListener(peers -> + SwingUtilities.invokeLater(() -> adoptCurrentPeerAddresses(peers))); routingPanel.setPrivateTcpListener(selected -> { if (selected) { User user = resolveUserForSingle(); @@ -119,7 +126,91 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { draftPanel.clearIpField(); } 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 peers) { + if (peers == null) { + return; + } + Map 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 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() { @@ -364,8 +455,10 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { inputScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); ShellUi.borderlessScroll(inputScroll); + tcpAttemptLabel.setVisible(false); 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); JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); 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."); return; } - if (privateChatController != null && privateChatController.sendText(user, text)) { - input.setText(""); + if (privateChatController != null && privateChatController.hasOpenChannel(user)) { + if (privateChatController.sendText(user, text)) { + input.setText(""); + } return; } if (privateChatController != null) { privateChatController.openChat(user); + if (privateChatController.sendText(user, text)) { + input.setText(""); + } } - appendSystemLine(peerKey(canonicalPeerIp(PeerIpv4.fromInetAddress(user.getAddress()))), - "Canal TCP privado conectando…"); return; } TransportMode mode = routing.transport(); + if (mode == TransportMode.TCP) { + sendTcpOffEdt(text, routing); + return; + } SendTarget target = resolveSendTarget(routing); if (target == null || mode == null) { 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 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 { if (routing.everyoneInRoom()) { return SendTarget.broadcastAll(); diff --git a/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java b/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java index b8c89d1..d4ffcbc 100644 --- a/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java +++ b/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java @@ -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) { String address = addressKey(connection); connections.remove(address); diff --git a/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java b/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java index aae5a41..c16d7da 100644 --- a/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java +++ b/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java @@ -15,6 +15,7 @@ import pitiupi.control.ConnectionManager; import pitiupi.models.Messages.Message; import pitiupi.models.Plugin; import pitiupi.models.User; +import pitiupi.net.tcp.TcpAttemptListener; import pitiupi.net.tcp.TcpConnection; import javax.swing.SwingUtilities; @@ -133,16 +134,30 @@ public class ChatPlugin extends Plugin implements PrivateChatHost { @Override public void openConnection(String ip) { - try { - TcpConnection connection = connectionManager.connectPluginTcp( - InetAddress.getByName(ip), - getIdentifier(), - getIdentifier() - ); - privateChatController.registerOutgoingConnection(ip, connection); - } catch (Exception e) { - System.out.println("Error opening connection: " + e.getMessage()); - } + connectionManager.connectPluginTcpAsync(ip, getIdentifier(), getIdentifier(), new TcpAttemptListener() { + @Override + public void onConnecting(String endpoint) { + if (chatPanel != null) { + chatPanel.showTcpAttempt("Conectando em " + endpoint + "…"); + } + } + + @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