diff --git a/.gitignore b/.gitignore index cdddd14..835a099 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,11 @@ /pitiupi/build/ /pitiupi/dist/ -/pitiupi/plugins/*.jar -/pitiupi.chat/build/ -/pitiupi.chat/dist/ -/pitiupi.chat/nbproject/private/ -/pitiupi.screenCast/build/ -/pitiupi.screenCast/dist/ -/pitiupi.mpChat/out/ -/pitiupi.mpChat/dist/ -**/built-jar.properties - -.idea/ +/pitiupi/nbproject/private/ +/plugins/chat/build/ +/plugins/chat/dist/ +/plugins/chat/nbproject/private/ +/plugins/screencast/build/ +/plugins/screencast/dist/ +/plugins/screencast/nbproject/private/ +/plugins/mpchat/out/ +/plugins/mpchat/dist/ diff --git a/README.md b/README.md index e638553..56405c6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pitiupi -Ferramenta **P2P** para práticas de Redes de Computadores: núcleo em Java com arquitetura de **plugins** (chat, compartilhamento de tela, chat multi-protocolo). +Ferramenta **P2P** para práticas de Redes de Computadores: núcleo em Java com arquitetura de **plugins** (conversa na rede e compartilhamento de tela). **Documentação completa:** [docs/README.md](docs/README.md) · **Quickstart:** [docs/documentation/00_quickstart.md](docs/documentation/00_quickstart.md) @@ -15,10 +15,10 @@ chmod +x scripts/pitiupi.sh # só na primeira vez ./scripts/pitiupi.sh run --all ``` -Compila o núcleo, os plugins do [catálogo](plugins.registry.json), instala JARs em `pitiupi/plugins/` e abre a aplicação. +Compila o núcleo, os plugins do [catálogo](plugins.registry.json), cria symlinks em **`PITIUPI_HOME/plugins`** (padrão `~/pitiupi/plugins`) e abre a aplicação. 1. Login: **nome** e **porta** (ex.: `5000`). -2. Menus dos plugins: barra **Plugin → Social | Jogos | Streaming** (cada plugin registra a categoria no código). +2. Abra plugins na aba **Plugins** (galeria) ou pelas ações na lista **Usuários nesta rede** (peer actions). 3. P2P no mesmo PC: segunda instância com **porta diferente** (ex.: `5001`). ```bash @@ -35,38 +35,37 @@ Novos módulos entram no repositório adicionando uma entrada no JSON (não é p | Campo | Significado | | --- | --- | | `id` | Usado em `--plugins chat,meuJogo` | -| `dir` | Pasta do módulo (ex.: `pitiupi.xadrez`) | +| `dir` | Pasta do módulo (ex.: `plugins/xadrez`) | | `build` | `ant` ou `make` | | `jar` | Caminho do JAR relativo a `dir` | | `menuCategory` | `Social`, `Jogos` ou `Streaming` — filtro CLI e documentação | Plugins de referência hoje: -| id | Categoria (menu) | Pasta | +| id | Uso didático | Pasta | | --- | --- | --- | -| `chat` | Social | `pitiupi.chat` | -| `mpchat` | Social | `pitiupi.mpChat` | -| `screencast` | Streaming | `pitiupi.screenCast` | +| `chat` | Sala geral, peer por IP, TCP privado | `plugins/chat` | +| `screencast` | Multicast ou unicast de tela | `plugins/screencast` | -A **categoria na interface** vem do `MenuCategory` no Java (`guiManager.createMenu(...)`), não do nome do JAR. O campo `menuCategory` no JSON alinha build/link com a doc. +Na interface, o texto das abas vem de `getDisplayName()` em cada plugin. O campo `menuCategory` no JSON alinha build/link com a documentação. ```bash ./scripts/pitiupi.sh run --all ./scripts/pitiupi.sh run --category Social ./scripts/pitiupi.sh run --plugins chat,screencast -./scripts/pitiupi.sh discover # pastas pitiupi.* com SPI ainda não no catálogo +./scripts/pitiupi.sh discover # pastas em plugins/ com SPI ainda não no catálogo ``` --- ## Novo plugin (resumo) -1. Copiar estrutura de `pitiupi.chat` → `pitiupi.meuPlugin` ([tutorial](docs/documentation/10_tutorial_plugin.md)). -2. Em `createPlugin`, registrar menu: `guiManager.createMenu(MenuCategory.Jogos, ...)` (ou Social / Streaming). +1. Copiar estrutura de `plugins/chat` → `plugins/meuPlugin` ([tutorial](docs/documentation/10_tutorial_plugin.md)). +2. Em `createPlugin`, registrar painel com `PluginWorkspace` / `bindShellWorkspaceContent` e, se fizer sentido, **peer actions** na lista lateral. 3. Adicionar entrada em [`plugins.registry.json`](plugins.registry.json). -4. `./scripts/pitiupi.sh run --plugins meuId`. +4. `./scripts/pitiupi.sh link --all` e reiniciar o app. -O núcleo carrega **todos os `*.jar`** em `pitiupi/plugins/` (pasta única). Detalhes: [pitiupi/plugins/README.md](pitiupi/plugins/README.md). +O núcleo carrega **todos os `*.jar`** em `PITIUPI_HOME/plugins`. Detalhes: [pitiupi/plugins/README.md](pitiupi/plugins/README.md). --- @@ -74,14 +73,14 @@ O núcleo carrega **todos os `*.jar`** em `pitiupi/plugins/` (pasta única). Det | Ferramenta | Uso | | --- | --- | -| **JDK** com `javac` | Compilação (OpenJDK **25** recomendado) | +| **JDK** com `javac` | Compilação (OpenJDK **25** recomendado; núcleo Java 17) | | **Apache Ant** | Módulos NetBeans | -| **GNU Make** | `pitiupi.mpChat` | | **python3** | Script `scripts/pitiupi.sh` (ler o catálogo) | ```bash -sudo apt install openjdk-25-jdk ant make +sudo apt install openjdk-25-jdk ant ./scripts/pitiupi.sh check +cd pitiupi && ant test # testes JUnit do núcleo ``` **GUI:** Swing — WSLg / `DISPLAY=:0`. @@ -92,11 +91,11 @@ sudo apt install openjdk-25-jdk ant make | Comando | Ação | | --- | --- | -| `run` | Build + `plugins/` + app | -| `build` | Build + `plugins/` | +| `run` | Build + link + app | +| `build` | Build + link | | `link` | Só symlinks (JARs já existem) | | `list` | Catálogo e status do JAR | -| `discover` | Sugestão de módulos `pitiupi.*` | +| `discover` | Sugestão de módulos com SPI | | `check` | PATH + JSON válido | Opções: `--all` (padrão), `--plugins ids`, `--category Social|Jogos|Streaming`. @@ -105,7 +104,10 @@ Opções: `--all` (padrão), `--plugins ids`, `--category Social|Jogos|Streaming ## Teste em rede -Duas instâncias, portas distintas, mesma LAN. Ver [docs/documentation/15_testes_e_gate.md](docs/documentation/15_testes_e_gate.md) e [appendix_portas_e_multicast.md](docs/documentation/appendix_portas_e_multicast.md). +- **Mesmo PC:** duas instâncias, portas distintas (ex. 5000 e 5001). +- **Dois PCs na mesma Wi‑Fi:** mesma porta (ex. 5000), nomes diferentes, mesmos JARs em `~/pitiupi/plugins` em cada máquina. + +Ver [docs/documentation/15_testes_e_gate.md](docs/documentation/15_testes_e_gate.md) e [appendix_portas_e_multicast.md](docs/documentation/appendix_portas_e_multicast.md). --- diff --git a/docs/decision-register.md b/docs/decision-register.md index 1f74759..c17f29c 100644 --- a/docs/decision-register.md +++ b/docs/decision-register.md @@ -10,5 +10,14 @@ | D6 | Mensagens `Serializable` em `byte[]` | Independência do transporte; plugins definem tipos | [07_mensagens_e_serializacao.md](documentation/07_mensagens_e_serializacao.md) | | D7 | Plugins via JAR + Java SPI | Carregamento dinâmico sem recompilar o núcleo | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | | D8 | Classloader por JAR de plugin | Isolar dependências; desserialização com `PluginObjectInputStream` | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | +| D9 | Plugins em `PITIUPI_HOME/plugins` | Dados do usuário fora do clone Git; `PITIUPI_HOME` ou `~/pitiupi` | [00_quickstart.md](documentation/00_quickstart.md) | +| D10 | Erros com `ErrorCode` | Mensagens PT na UI; `PitiupiException` no domínio | [13_guia_dev_e_agente.md](documentation/13_guia_dev_e_agente.md) | +| D11 | Reinício após instalar/remover JAR | Mutar `plugins/` exige reiniciar o app para recarregar SPI | [04_inicializacao_e_gui.md](documentation/04_inicializacao_e_gui.md) | +| D16 | UI em abas; sem `JFrame` de plugin | Conteúdo na `MainShellFrame`; galeria e peer actions abrem abas | [04_inicializacao_e_gui.md](documentation/04_inicializacao_e_gui.md) | +| D17 | Goodbye + timeout de presença | `GoodbyeMessage` em multicast ao fechar; timeout ~15 s mantido | [08_heartbeat_e_presenca.md](documentation/08_heartbeat_e_presenca.md) | +| D18 | Peer actions + convites | `PeerActionBroker`, `SessionInviteMessage`, badge (UI completa na shell) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | +| D19 | Chat unificado | Um JAR `plugins/chat` com sala geral, peer/IP e TCP privado; mpChat removido do catálogo | [11_plugins_referencia.md](documentation/11_plugins_referencia.md) | +| D20 | Peer actions por plugin no popup | Lista lateral agrupa ações de todos os plugins (submenus), não só a aba ativa | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | +| D21 | `onShellTabClosed` | Fechar aba do plugin na shell notifica o plugin (ex.: parar screencast) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | Novas decisões: adicionar linha nesta tabela e detalhar no arquivo técnico correspondente. diff --git a/docs/documentation/00_quickstart.md b/docs/documentation/00_quickstart.md index c9cdb04..30c1700 100644 --- a/docs/documentation/00_quickstart.md +++ b/docs/documentation/00_quickstart.md @@ -3,61 +3,45 @@ ## Pré-requisitos - **JDK completo** (`java` e `javac`) — OpenJDK 25 recomendado; o núcleo usa `javac.source/target=17`, plugins Ant usam 25. -- Apache Ant (`pitiupi`, `pitiupi.chat`, `pitiupi.screenCast`) -- GNU Make (`pitiupi.mpChat`) +- Apache Ant (`pitiupi`, `plugins/chat`, `plugins/screencast`) Ubuntu / WSL: ```bash -sudo apt install openjdk-25-jdk ant make -which javac ant make +sudo apt install openjdk-25-jdk ant +which javac ant ``` -Se `ant jar` falhar com *Cannot run program "javac"*, você instalou apenas JRE — instale `openjdk-*-jdk`. - ## Build Na raiz do clone: ```bash -cd pitiupi && ant jar -cd ../pitiupi.chat && ant jar -cd ../pitiupi.screenCast && ant jar -cd ../pitiupi.mpChat && make +./scripts/pitiupi.sh build --all ``` -O núcleo gera `pitiupi/dist/pitiupi.jar`. Cada plugin gera JAR em seu `dist/`. +Ou manualmente: + +```bash +cd pitiupi && ant jar +cd plugins/chat && ant jar +cd plugins/screencast && ant jar +``` ## Instalar plugins -A aplicação lê JARs de `pitiupi/plugins/` (relativo ao diretório de trabalho ao executar o núcleo). Detalhes: [pitiupi/plugins/README.md](../../pitiupi/plugins/README.md). - -Symlinks em desenvolvimento (Linux): +Runtime: JARs em **`PITIUPI_HOME/plugins`** (padrão `~/pitiupi/plugins`). ```bash -cd pitiupi/plugins -ln -sf ../../pitiupi.chat/dist/pitiupi.chat.jar pitiupi.chat.jar -ln -sf ../../pitiupi.screenCast/dist/pitiupi.screenCast.jar pitiupi.screenCast.jar -ln -sf ../../pitiupi.mpChat/dist/mpChat.jar mpChat.jar +./scripts/pitiupi.sh link --all ``` ## Executar ```bash -cd pitiupi -java -jar dist/pitiupi.jar +./scripts/pitiupi.sh run --all ``` -No diálogo inicial informe **nome** e **porta** (UDP/TCP). Para duas instâncias no mesmo computador, use **portas diferentes** (ex.: 5000 e 5001). +Login com nome e porta; abra plugins pela aba **Plugins**. Clique em **Todos** ou em um peer na lista lateral para ver ações de cada plugin. -## Verificar P2P - -1. Abra duas instâncias (mesma rede ou mesmo host com portas distintas). -2. Confirme que os nomes aparecem no painel de usuários online. -3. Teste um plugin de referência (ex.: chat em `MenuCategory.Social`). - -Problemas de multicast: [appendix_portas_e_multicast.md](appendix_portas_e_multicast.md). - -Gate de build (quatro módulos): [15_testes_e_gate.md](15_testes_e_gate.md). - -Próximo passo para o TP: [10_tutorial_plugin.md](10_tutorial_plugin.md). +Validação rápida do núcleo: `cd pitiupi && ant test`. diff --git a/docs/documentation/01_visao_disciplina_e_tp.md b/docs/documentation/01_visao_disciplina_e_tp.md index 279ff8c..a45e896 100644 --- a/docs/documentation/01_visao_disciplina_e_tp.md +++ b/docs/documentation/01_visao_disciplina_e_tp.md @@ -25,9 +25,9 @@ Exemplos de tema: chat temático, tabuleiro (xadrez/damas), compartilhamento de | Módulo | Ideia pedagógica | | --- | --- | -| `pitiupi.chat` | Mensagens UDP + UI simples | -| `pitiupi.screenCast` | Payload maior / streaming de imagem | -| `pitiupi.mpChat` | Multicast + TCP privado no mesmo plugin | +| `plugins/chat` | Mensagens UDP + UI simples | +| `plugins/screencast` | Payload maior / streaming de imagem | +| `plugins/chat` | Multicast, unicast, TCP e canal TCP privado | Detalhes: [11_plugins_referencia.md](11_plugins_referencia.md). diff --git a/docs/documentation/02_modulos_do_repositorio.md b/docs/documentation/02_modulos_do_repositorio.md index d3682e1..79ec58c 100644 --- a/docs/documentation/02_modulos_do_repositorio.md +++ b/docs/documentation/02_modulos_do_repositorio.md @@ -1,36 +1,28 @@ # Módulos do repositório ``` -pitiupi/ # clone (este repositório) -├── plugins.registry.json # catálogo de módulos (build/link via scripts/pitiupi.sh) +pitiupi/ # clone (este repositório) +├── plugins.registry.json ├── scripts/pitiupi.sh -├── pitiupi/ # núcleo P2P (executável) -├── pitiupi.chat/ # plugin referência — chat -├── pitiupi.screenCast/ # plugin referência — captura de tela -├── pitiupi.mpChat/ # plugin referência — chat multi-protocolo -└── docs/ # documentação canônica +├── pitiupi/ # núcleo P2P (executável) +├── plugins/ +│ ├── chat/ # chat unificado (UDP/multicast/TCP + TCP privado) +│ ├── screencast/ # compartilhamento de tela +│ └── ping/ # exercício de aula (só doc) +└── docs/ ``` ## Núcleo (`pitiupi/`) -- **Build:** Apache Ant (`build.xml`, `nbproject/`). -- **Entrada:** `pitiupi.Main` → login → `MainWindow`. -- **Pacotes principais:** `pitiupi.net`, `pitiupi.control`, `pitiupi.GUI`, `pitiupi.models`. +- **Build:** Apache Ant. +- **Entrada:** `pitiupi.Main` → login → `MainShellFrame`. +- **Messaging:** `pitiupi.application.messaging.MessageDispatcher`. ## Plugins de referência -| Pasta | Build | JAR típico | Classe SPI | +| Pasta | Build | JAR típico | SPI | | --- | --- | --- | --- | -| `pitiupi.chat` | `ant jar` | `dist/pitiupi.chat.jar` | `pitiupi.chat.plugin.ChatPlugin` | -| `pitiupi.screenCast` | `ant jar` | `dist/pitiupi.screenCast.jar` | `pitiupi.screenCast.plugin.ScreenCastPlugin` | -| `pitiupi.mpChat` | `make` | `dist/mpChat.jar` | `plugin.MultiProtocolChat` | +| `plugins/chat` | `ant jar` | `dist/pitiupi.chat.jar` | `pitiupi.chat.plugin.ChatPlugin` | +| `plugins/screencast` | `ant jar` | `dist/pitiupi.screenCast.jar` | `pitiupi.screenCast.plugin.ScreenCastPlugin` | -Todos dependem do JAR do núcleo no classpath de compilação. - -## O que o aluno cria - -Novo diretório irmão, por exemplo `pitiupi.xadrez/`, copiando a estrutura de `pitiupi.chat` (Ant + `META-INF/services`). Registre o módulo em `plugins.registry.json` e use `MenuCategory` adequado no código. - -Não é obrigatório commitar o plugin do aluno neste repositório base; o professor pode definir fork ou entrega em zip. - -Ver [03_build_ant_netbeans.md](03_build_ant_netbeans.md) e [10_tutorial_plugin.md](10_tutorial_plugin.md). +Ver [10_tutorial_plugin.md](10_tutorial_plugin.md) e [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md). diff --git a/docs/documentation/03_build_ant_netbeans.md b/docs/documentation/03_build_ant_netbeans.md index 6de92c8..07c301a 100644 --- a/docs/documentation/03_build_ant_netbeans.md +++ b/docs/documentation/03_build_ant_netbeans.md @@ -1,49 +1,17 @@ -# Build — Ant, NetBeans e Make +# Build — Ant e NetBeans -## Pré-requisitos e versões Java - -| Módulo | Build | `javac.source` / `target` (NetBeans) | +| Módulo | Build | Java | | --- | --- | --- | | `pitiupi` | Ant | 17 | -| `pitiupi.chat` | Ant | 25 | -| `pitiupi.screenCast` | Ant | 25 | -| `pitiupi.mpChat` | Make | (sem `project.properties`; compila contra `pitiupi.jar`) | - -Use um JDK que forneça `javac` (ex.: OpenJDK 25). Ant 1.10+ e Make no PATH. - -## Núcleo e plugins NetBeans - -Projetos com `nbproject/` e `build.xml`: +| `plugins/chat` | Ant | 25 | +| `plugins/screencast` | Ant | 25 | ```bash cd pitiupi && ant jar -cd pitiupi.chat && ant jar -cd pitiupi.screenCast && ant jar +cd plugins/chat && ant jar +cd plugins/screencast && ant jar ``` -Abrir no NetBeans: **File → Open Project** em cada pasta. +Classpath dos plugins: `../../pitiupi/dist/pitiupi.jar`. -O target `jar` produz o executável do núcleo e os JARs dos plugins. O classpath de compilação dos plugins aponta para `../pitiupi/dist/pitiupi.jar` (ver `project.properties` de cada módulo). - -## pitiupi.mpChat (Makefile) - -```bash -cd pitiupi.mpChat -make -``` - -O Makefile compila com `javac -cp ../pitiupi/dist/pitiupi.jar`, copia `META-INF` e gera `dist/mpChat.jar`. **Compile o núcleo antes.** - -## Diretório `plugins/` - -A pasta `pitiupi/plugins/` contém os JARs carregados em runtime. Apenas arquivos `.jar` são lidos. Ver [pitiupi/plugins/README.md](../../pitiupi/plugins/README.md). - -## Ordem recomendada de build - -1. `pitiupi` (núcleo) -2. Plugins em qualquer ordem -3. Symlink ou cópia para `pitiupi/plugins/` - -## Gate automatizado (docentes) - -Quem mantém o meta-workspace Cursor pode usar o script de gate que executa `ant jar` nos três módulos Ant e `make` no mpChat (requer Ant/Make no PATH). Detalhes em [15_testes_e_gate.md](15_testes_e_gate.md). +Catálogo: [`plugins.registry.json`](../../plugins.registry.json). diff --git a/docs/documentation/04_inicializacao_e_gui.md b/docs/documentation/04_inicializacao_e_gui.md index e21c2a5..7204c18 100644 --- a/docs/documentation/04_inicializacao_e_gui.md +++ b/docs/documentation/04_inicializacao_e_gui.md @@ -20,38 +20,78 @@ distribuídas e desenvolvimento de projetos acadêmicos. A aplicação é dividida em três grupos principais: -- **Núcleo da aplicação**: a `MainWindow` coordena GUI, rede e plugins. -- **Sistema de plugins**: extensões em JAR carregadas de `pitiupi/plugins/`. +- **Núcleo da aplicação**: a `MainShellFrame` coordena GUI, rede e plugins. +- **Sistema de plugins**: extensões em JAR carregadas de `PITIUPI_HOME/plugins` (dev: `./scripts/pitiupi.sh link --all`). - **Comunicação de rede**: UDP (`SocketUDP`) e TCP (`SocketTCP`, `TcpConnection`). ## Inicialização da aplicação -1. A `MainWindow` é criada após o diálogo de login (nome e porta). -2. O `SocketUDP` associa-se à porta configurada e ingressa no grupo multicast. -3. O `SocketTCP` cria um `ServerSocket` na mesma porta para conexões TCP. -4. Os mecanismos de comunicação iniciam threads de recepção. -5. O `HeartbeatManager` envia heartbeats periodicamente. -6. O painel de usuários online registra-se como listener do heartbeat. -7. O `PluginManager` carrega os JARs em `plugins/` via SPI. +1. `Main` garante o layout em `PITIUPI_HOME` e abre o `LoginDialog` (nome e porta, PT-BR, ~30% da área útil do monitor, centralizado). +2. Após confirmar o login, a `MainShellFrame` é exibida (1024×768, centralizada). +3. O `SocketUDP` associa-se à porta configurada e ingressa no grupo multicast. +4. O `SocketTCP` cria um `ServerSocket` na mesma porta para conexões TCP. +5. Os mecanismos de comunicação iniciam threads de recepção. +6. O `HeartbeatManager` envia heartbeats periodicamente; o `NetworkUsersPanel` lista peers via `PresenceService`. +7. O `PluginManager` carrega os JARs em `plugins/` via SPI, com `ShellPluginWorkspace` ligado às abas centrais. Ver também: [05_udp_multicast.md](05_udp_multicast.md), [09_sistema_de_plugins.md](09_sistema_de_plugins.md). +### Teste manual T-LOGIN-01 (login centralizado) + +1. Compilar e executar: `./scripts/pitiupi.sh run --all` na raiz do repositório. +2. O `LoginDialog` deve aparecer **no centro** do monitor principal (WSL/KDE), não no canto superior esquerdo. +3. Após entrar, a `MainShellFrame` (1024×768) também deve abrir centralizada. + +A posição exata depende do gerenciador de janelas; o núcleo recentra após exibir a janela. + +### Atualizar plugin Chat (Onda E) + +O JAR do módulo é `plugins/chat/dist/pitiupi.chat.jar` (catálogo `plugins.registry.json`). Não use `dist/chat.jar`. + +```bash +cd plugins/chat && ant clean jar +./scripts/pitiupi.sh link --all +``` + +Isso recria o symlink `~/pitiupi/plugins/chat.jar` → o JAR buildado. O comando `link` lista o destino real de cada `.jar`. + # Interface gráfica -A interface gráfica da aplicação é baseada em Swing e é centralizada na -classe `MainWindow`. +A interface gráfica é Swing, organizada em shell (`pitiupi.presentation.shell`): -Durante a inicialização, cada plugin recebe `ConnectionManager` e `GuiManager` -no método `createPlugin()`. +| Região | Componente | +|--------|------------| +| Centro | `TabWorkspacePanel` — aba **Plugins** (galeria; abre plugin sob demanda), abas de plugin **fecháveis** (×), aba **Configurações** | +| Leste | `NetworkUsersPanel` — “Usuários nesta rede”, badges de convite, peer actions | +| Sul | Barra de status (porta e nome local) | +| Menu | `ShellMenuBar` — apenas **Ajuda → Sobre** (sair pelo X da janela) | -A extensão usual é registrar menus por categoria (`MenuCategory`). Ver -[12_menus_unicast_multicast.md](12_menus_unicast_multicast.md). +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). + +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). + +Canal TCP privado do chat fica no mesmo `ChatPanel` (histórico separado por conversa: sala geral, peer por IP, privado com toggle). A lista lateral usa `PeerActivityInbox` (bolinha vermelha e contagem; no menu, ● na ação correspondente). + +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). + +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. + +Ver [12_menus_unicast_multicast.md](12_menus_unicast_multicast.md). ## Diagrama de comunicação ``` +------------------+ - | MainWindow | + | MainShellFrame | +--------+---------+ | +--------------------+--------------------+ diff --git a/docs/documentation/09_sistema_de_plugins.md b/docs/documentation/09_sistema_de_plugins.md index c209a3b..9c1a71e 100644 --- a/docs/documentation/09_sistema_de_plugins.md +++ b/docs/documentation/09_sistema_de_plugins.md @@ -10,14 +10,17 @@ Métodos obrigatórios: | Método | Função | | --- | --- | | `getName()`, `getAuthor()`, `getVersion()`, `getIdentifier()` | Identificação (o `identifier` distingue plugins em conexões TCP dedicadas) | -| `createPlugin(ConnectionManager, GuiManager)` | Inicialização: menus, janelas, uso da rede | +| `getDisplayName()` | Texto na galeria e título de aba (padrão: `getName()`) | +| `createPlugin(ConnectionManager, PluginWorkspace)` | Inicialização na shell: painel na aba, peer actions, rede | +| `createPlugin(ConnectionManager, GuiManager)` | Legado — adapter na carga; não usar em plugins novos | | `getMessages()` | Lista de classes `Message` que o plugin desserializa | | `receiveMessage(Message)` | Processamento após o `PluginManager` identificar o tipo | -Métodos opcionais (TCP avançado): +Métodos opcionais (ciclo de vida e TCP avançado): | Método | Função | | --- | --- | +| `onShellTabClosed()` | Usuário fechou a aba do plugin no topo; liberar timers, captura, envio contínuo | | `receiveAllTypes()` | Receber todas as mensagens públicas | | `receivePrivateConnection(TcpConnection)` | Aceitar conexão TCP direcionada ao plugin | | `receiveBytes(TcpConnection, byte[])` | Ler payload bruto na conexão privada | @@ -50,11 +53,27 @@ e colocados no diretório plugins da aplicação. ### Carregamento de plugins -O `PluginManager.loadPlugins()` (chamado pela `MainWindow`): +O `PluginManager.loadPlugins()` (chamado pela `MainShellFrame`): -1. Lista arquivos `.jar` em `pitiupi/plugins/` (diretório de trabalho = pasta do núcleo). +1. Lista arquivos `.jar` em `PITIUPI_HOME/plugins` (ver [00_quickstart.md](00_quickstart.md); symlinks de dev via `./scripts/pitiupi.sh link`). 2. Para cada JAR, cria um `URLClassLoader` e usa `ServiceLoader.load(Plugin.class, classLoader)`. -3. Chama `createPlugin(connectionManager, guiManager)` em cada instância. +3. Chama `createPlugin(connectionManager, pluginWorkspace)` em cada instância (`ShellPluginWorkspace`). 4. Na recepção UDP/TCP, tenta desserializar com `PluginObjectInputStream` e despacha para plugins que declararam o tipo em `getMessages()`. +Ao fechar uma aba dinâmica de plugin, `TabWorkspacePanel` notifica `PluginManager.notifyShellTabClosed(pluginId)` **antes** de remover o conteúdo, para o plugin encerrar trabalho em segundo plano. + Implementação: `pitiupi/src/pitiupi/control/plugins/PluginManager.java`. + +## Shell e abas (PluginWorkspace) + +Na `MainShellFrame`, `createPlugin(ConnectionManager, PluginWorkspace)` registra o painel com `bindShellWorkspaceContent(JPanel)` **sem** abrir aba no boot. A galeria **Plugins** e as peer actions abrem a aba sob demanda. + +## Peer actions na lista lateral + +`PeerActionBroker.getAllActionsFor(target)` agrupa ações de **todos** os plugins (submenu por `getDisplayName()`). Destinos: `SINGLE_PEER` (um usuário) ou `ALL_PEERS` (linha **Todos**). + +## Envio didático (núcleo) + +`PluginRoutingPanel` (núcleo) padroniza destino, forma de envio e dica embaixo para chat, screencast e futuros plugins. + +`MessageDispatcher` envia com `TransportMode` e `SendTarget`. `PluginTransportCatalog` define quais transportes cada plugin mostra na UI (sala geral vs um IP). Os combos usam textos didáticos (`describeForUi`) com o termo técnico entre parênteses (multicast UDP, UDP unicast, TCP). Na sala geral o campo IP fica desativado; um resumo **“Como esta mensagem vai”** (chat) deixa explícito o transporte escolhido — na sala pode ser multicast ou cópias unicast/TCP para cada pessoa, conforme o plugin. Para um peer use o IP manualmente ou a peer action na lista lateral. diff --git a/docs/documentation/10_tutorial_plugin.md b/docs/documentation/10_tutorial_plugin.md index 1a05f3a..5228c86 100644 --- a/docs/documentation/10_tutorial_plugin.md +++ b/docs/documentation/10_tutorial_plugin.md @@ -1,10 +1,10 @@ # Tutorial — criar um plugin -Este guia complementa [09_sistema_de_plugins.md](09_sistema_de_plugins.md). Use `pitiupi.chat` como modelo. +Este guia complementa [09_sistema_de_plugins.md](09_sistema_de_plugins.md). Use `plugins/chat` como modelo. ## 1. Novo projeto -- Copie a pasta `pitiupi.chat` para `pitiupi.meuPlugin` (nome à escolha). +- Copie a pasta `plugins/chat` para `plugins/meuPlugin` (nome à escolha). - Ajuste `project.properties` / `build.xml` com o nome do JAR de saída. - Mantenha `javac.classpath` apontando para `../pitiupi/dist/pitiupi.jar`. @@ -98,7 +98,7 @@ Duas instâncias, mesma rede, portas distintas se no mesmo host. Ver [00_quickst ## TCP dedicado (avançado) -Se o plugin precisa de canal TCP com handshake por `getIdentifier()`, estude `pitiupi.mpChat` e os métodos opcionais em `Plugin` (`receivePrivateConnection`, `receiveBytes`). Ver [11_plugins_referencia.md](11_plugins_referencia.md). +Se o plugin precisa de canal TCP com handshake por `getIdentifier()`, estude `plugins/chat` (`receivePrivateConnection`, `receiveBytes`). Use `MessageDispatcher` para envios didáticos. Ver [11_plugins_referencia.md](11_plugins_referencia.md). ## Erros frequentes diff --git a/docs/documentation/11_plugins_referencia.md b/docs/documentation/11_plugins_referencia.md index b39173e..12b5661 100644 --- a/docs/documentation/11_plugins_referencia.md +++ b/docs/documentation/11_plugins_referencia.md @@ -1,60 +1,31 @@ # Plugins de referência -Três exemplos no repositório para comparar estilos de integração. Todos devem ser empacotados em `pitiupi/plugins/`. +Dois módulos buildáveis no catálogo; o chat unifica o antigo mpChat (multicast, unicast, TCP e canal TCP privado). ## Comparativo -| | pitiupi.chat | pitiupi.screenCast | pitiupi.mpChat | -| --- | --- | --- | --- | -| Build | Ant | Ant | Make | -| id (`plugins.registry.json`) | `chat` | `screencast` | `mpchat` | -| Classe | `ChatPlugin` | `ScreenCastPlugin` | `MultiProtocolChat` | -| Categoria menu | `Social` | `Streaming` | `Social` (multicast + unicast) | -| Mensagem | `ChatMessage` | `ScreenCastMessage` | `MpChatMessage` | -| Transporte principal | UDP (via `ConnectionManager`) | UDP / payloads maiores | UDP público + **TCP privado** | -| TCP avançado | Não | Depende da versão do módulo | `receivePrivateConnection`, `receiveBytes` | +| | plugins/chat | plugins/screencast | +| --- | --- | --- | +| Build | Ant | Ant | +| id | `chat` | `screencast` | +| Classe | `ChatPlugin` | `ScreenCastPlugin` | +| Mensagem | `ChatMessage`, `LegacyMpChatMessage` | `ScreenCastMessage` | +| UI | `ChatPanel` (destino + transporte) | `ScreenCastPanel` (destino + transporte) | +| TCP privado | Toggle no `ChatPanel` + hooks `receiveBytes` (histórico `private:`) | — | -## pitiupi.chat +## plugins/chat -- **Referência canônica** alinhada ao contrato atual `Plugin` + `ConnectionManager` / `GuiManager`. -- `createPlugin` registra `ChatMenu` em `MenuCategory.Social`. -- `getMessages()` retorna `ChatMessage.class`; `receiveMessage` atualiza `ChatWindow`. +- Sala geral ou um peer (lista + IP). +- Transportes: multicast UDP, unicast UDP, TCP via `MessageDispatcher`. +- Peer actions: abrir sala geral; conversar com peer; canal TCP privado. +- Caminho: `plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java`. -Caminhos: `pitiupi.chat/src/pitiupi/chat/plugin/ChatPlugin.java`. +## plugins/screencast -## pitiupi.screenCast +- Compartilhamento contínuo com destino geral (multicast recomendado) ou um peer (unicast/TCP). +- Peer actions: compartilhar na rede; compartilhar com este peer. +- Caminho: `plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java`. -- Demonstra compartilhamento de tela / imagens (`ScreenCastWindow`, `ScreenCastMessage`). -- Útil para discutir **tamanho de datagramas**, compressão (`ImageTransformation`) e limites UDP. +## Exercício Ping -Caminhos: `pitiupi.screenCast/src/pitiupi/screenCast/`. Alinhado ao contrato `Plugin` + `ConnectionManager` / `GuiManager` (como `ChatPlugin`). - -## pitiupi.mpChat - -- Chat **público** (multicast / unicast UDP) e **privado** (TCP). -- `createPlugin(ConnectionManager, GuiManager)` como nos demais plugins; usa `ConnectionManager.connectPluginTcp` para abrir canal privado. -- Sobrescreve hooks TCP do `Plugin` (`receivePrivateConnection`, `receiveBytes`, `onSocketClosed`). - -Caminhos: `pitiupi.mpChat/src/plugin/MultiProtocolChat.java`. - -## Fluxo UDP (todos os plugins) - -```mermaid -sequenceDiagram - participant App as MainWindow - participant UDP as SocketUDP - participant HB as HeartbeatManager - participant PM as PluginManager - participant Peer as OutroPeer - App->>UDP: join multicast - UDP->>HB: HeartbeatMessage - Peer->>UDP: Message plugin - UDP->>PM: handleMessage bytes - PM->>PM: deserialize + dispatch -``` - -Heartbeat não passa pelos plugins — ver [08_heartbeat_e_presenca.md](08_heartbeat_e_presenca.md). - -## Próximo passo - -Criar plugin próprio: [10_tutorial_plugin.md](10_tutorial_plugin.md). +Material em [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md) e pasta `plugins/ping/` (sem JAR no repositório). diff --git a/docs/documentation/12_menus_unicast_multicast.md b/docs/documentation/12_menus_unicast_multicast.md index f2321c3..cfdf3b2 100644 --- a/docs/documentation/12_menus_unicast_multicast.md +++ b/docs/documentation/12_menus_unicast_multicast.md @@ -1,35 +1,34 @@ -# Menus — unicast e multicast +# Menus, peer actions e transportes -## MenuCategory +## Interface atual (shell v1) -Enum em `pitiupi.models.Menus.MenuCategory`: +Plugins de referência **não** dependem da barra de menus superior para o fluxo principal: -| Valor | Uso sugerido | -| --- | --- | -| `Jogos` | Plugins de jogos entre peers | -| `Social` | Chat, presença, mensagens sociais | -| `Streaming` | Screen cast, mídia | +- **Galeria** na aba **Plugins** abre cada extensão em aba própria (fechável com ×). +- **Peer actions** na lista **Usuários nesta rede** (clique em um peer ou em **Todos**) agrupam ações de todos os plugins instalados. +- Dentro da aba do plugin, **`PluginRoutingPanel`** padroniza destino (sala geral vs IP), forma de envio (multicast UDP, UDP unicast, TCP) e texto de ajuda. -## Registrar menu (padrão atual) +Ver [04_inicializacao_e_gui.md](04_inicializacao_e_gui.md) e [09_sistema_de_plugins.md](09_sistema_de_plugins.md). -Via `GuiManager.createMenu(MenuCategory category, Menu menu)` — ver `ChatPlugin`: +## MenuCategory (legado) -```java -guiManager.createMenu(MenuCategory.Social, new ChatMenu(chatWindow)); -``` +Enum em `pitiupi.models.Menus.MenuCategory` (`Jogos`, `Social`, `Streaming`) ainda aparece no **catálogo** (`menuCategory` em `plugins.registry.json`) e em plugins antigos que chamam `GuiManager.createMenu(...)`. -A hierarquia de menus unicast/multicast do núcleo (`UnicastMenu`, `MulticastMenu`, etc.) permite ações por peer ou broadcast; plugins de referência encapsulam isso em classes `*Menu` próprias. +Novos plugins devem preferir `PluginWorkspace` + peer actions. O menu da shell expõe apenas **Ajuda → Sobre**. ## Unicast vs multicast na aplicação | Modo | API típica | Semântica | | --- | --- | --- | -| Multicast | `ConnectionManager` → `SocketUDP.sendMulticast` | Todos no grupo na porta configurada | +| Multicast | `MessageDispatcher` / `SocketUDP.sendMulticast` | Todos no grupo na porta configurada | | Unicast UDP | envio para IP de um peer | Um destinatário, sem estabelecer TCP | -| TCP | `ConnectionManager` / `SocketTCP` | Canal persistente, framing 4+N bytes | +| TCP | `ConnectionManager` / `SocketTCP` | Canal com framing 4+N bytes | Detalhes de rede: [05_udp_multicast.md](05_udp_multicast.md), [06_tcp_conexoes.md](06_tcp_conexoes.md). -## mpChat +## Referência de código -`MulticastMpChatMenu` e `UnicastMpChatMenu` mostram como expor as duas modalidades no mesmo plugin — [11_plugins_referencia.md](11_plugins_referencia.md). +- Chat e screencast: peer actions em `ChatPlugin` e `ScreenCastPlugin`. +- Envio unificado: `pitiupi.application.messaging.MessageDispatcher` e `TransportMode`. + +O antigo plugin **mpChat** foi unificado no módulo `plugins/chat` (mensagens `LegacyMpChatMessage` para compatibilidade TCP privado). Ver [11_plugins_referencia.md](11_plugins_referencia.md) e decisão D19 no [decision-register.md](../decision-register.md). diff --git a/docs/documentation/13_guia_dev_e_agente.md b/docs/documentation/13_guia_dev_e_agente.md index 61c48f1..7cc35a6 100644 --- a/docs/documentation/13_guia_dev_e_agente.md +++ b/docs/documentation/13_guia_dev_e_agente.md @@ -1,5 +1,7 @@ # Guia para desenvolvedor e agente +Esta documentação descreve **apenas** a ferramenta Pitiupi neste repositório: build, rede, plugins e convenções de código. Quem clona o projeto não precisa de pastas ou ferramentas de meta-desenvolvimento externas; mantenha referências técnicas e tutoriais limitados ao que está em `docs/` e no código do próprio repo. + ## Leitura mínima antes de mudar código 1. [00_quickstart.md](00_quickstart.md) — build e execução @@ -19,16 +21,17 @@ ## Gate de build -Compilar núcleo e os três plugins de referência: +Compilar núcleo e os dois plugins do catálogo: ```bash -cd pitiupi && ant jar -cd ../pitiupi.chat && ant jar -cd ../pitiupi.screenCast && ant jar -cd ../pitiupi.mpChat && make +cd pitiupi && ant jar && ant test +cd plugins/chat && ant jar +cd plugins/screencast && ant jar ``` -Requer JDK, Ant e Make no PATH. Teste manual em rede: [15_testes_e_gate.md](15_testes_e_gate.md). +Ou na raiz do clone: `./scripts/pitiupi.sh build --all`. + +Requer JDK e Ant no PATH. Teste manual em rede: [15_testes_e_gate.md](15_testes_e_gate.md). ## Convenções diff --git a/docs/documentation/14_arquitetura_e_manutencao.md b/docs/documentation/14_arquitetura_e_manutencao.md index 1a3bf59..c5dd748 100644 --- a/docs/documentation/14_arquitetura_e_manutencao.md +++ b/docs/documentation/14_arquitetura_e_manutencao.md @@ -4,12 +4,13 @@ | Comportamento | Pacote / classe | | --- | --- | -| Login, porta, nome | `pitiupi.GUI.Login` | -| Orquestração GUI + plugins | `pitiupi.GUI.MainWindow` | -| Barra de menus base | `pitiupi.GUI.MenuBar` | -| Usuários online | `pitiupi.GUI.OnlineUsersPanel`, `HeartbeatManager` | +| Login, porta, nome | `pitiupi.presentation.shell.LoginDialog` | +| Shell principal (abas, galeria) | `pitiupi.presentation.shell.MainShellFrame`, `TabWorkspacePanel` | +| Menu Ajuda | `pitiupi.presentation.shell.ShellMenuBar` | +| Usuários online, peer actions | `pitiupi.presentation.shell.NetworkUsersPanel`, `PeerActionBroker` | | Facade rede para plugins | `pitiupi.control.ConnectionManager` | -| Registro de menus | `pitiupi.control.GuiManager` | +| Workspace na aba (SPI) | `pitiupi.api.plugin.PluginWorkspace`, `ShellPluginWorkspace` | +| Menus legados (adapter) | `pitiupi.control.GuiManager` — deprecado; preferir peer actions | | UDP | `pitiupi.net.udp.SocketUDP` | | TCP servidor e mapa de conexões | `pitiupi.net.tcp.SocketTCP`, `TcpConnectionManager` | | Conexão TCP por peer | `pitiupi.net.tcp.TcpConnection` | @@ -24,7 +25,7 @@ ``` pitiupi (núcleo) ↑ classpath -pitiupi.chat | pitiupi.screenCast | pitiupi.mpChat +plugins/chat | plugins/screencast ``` Plugins **não** devem ser dependência do núcleo. diff --git a/docs/documentation/15_testes_e_gate.md b/docs/documentation/15_testes_e_gate.md index 24caeda..88634d5 100644 --- a/docs/documentation/15_testes_e_gate.md +++ b/docs/documentation/15_testes_e_gate.md @@ -1,15 +1,18 @@ # Testes e gate -## Estado atual +## Gate automatizado (núcleo) -Não há suíte JUnit integrada ao repositório. A validação principal é: +O módulo `pitiupi/` inclui testes JUnit: -1. **Build** de todos os módulos. -2. **Teste manual** com duas instâncias na rede. +```bash +cd pitiupi && ant test +``` -## Gate de build (recomendado) +Falha se `java`, `javac` ou Ant estiverem ausentes. -Na raiz do clone (todos os módulos do catálogo): +## Gate de build (produto) + +Na raiz do clone: ```bash ./scripts/pitiupi.sh build --all @@ -19,26 +22,41 @@ Equivalente manual: ```bash cd pitiupi && ant jar -cd ../pitiupi.chat && ant jar -cd ../pitiupi.screenCast && ant jar -cd ../pitiupi.mpChat && make +cd plugins/chat && ant jar +cd plugins/screencast && ant jar ``` -Falha se `java`, **`javac`**, Ant, Make ou `python3` estiverem ausentes (instale `openjdk-*-jdk`, não só JRE). +Depois, para runtime local: -## Checklist manual (rede) +```bash +./scripts/pitiupi.sh link --all +``` -- [ ] Duas instâncias com portas distintas (mesmo host) ou dois hosts na mesma LAN -- [ ] Nomes visíveis no painel de usuários em até ~15 s -- [ ] Plugin de referência troca mensagens entre os dois -- [ ] Encerrar uma instância remove o peer da lista após timeout de heartbeat +## Checklist manual — uma máquina (5000 / 5001) + +- [ ] Duas instâncias com portas distintas; nomes diferentes +- [ ] Peers visíveis na lista em até ~15 s +- [ ] Chat: sala geral e conversa por IP; tags de transporte coloridas no histórico +- [ ] ScreenCast: abrir aba, destino sala ou peer; fechar aba com × interrompe compartilhamento +- [ ] Compose do chat sem barra de rolagem vazio em janela ~1024×768 +- [ ] Encerrar uma instância remove o peer após timeout de heartbeat + +## Checklist manual — dois PCs (mesma Wi‑Fi) + +Mesma **porta** em ambos (ex. 5000), **mesmos JARs** em `~/pitiupi/plugins` em cada máquina: + +- [ ] Presença e linha **Todos** +- [ ] Troca de mensagens no chat (multicast e, se possível, unicast/TCP) +- [ ] Compartilhamento de tela entre os dois (ambiente com captura de tela funcional — no WSL a captura nativa pode falhar) +- [ ] Plugin Ping do aluno (tutorial [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md)) medindo RTT — recomendado antes da entrega final ## Checklist plugin de aluno -- [ ] JAR em `plugins/` + SPI correto +- [ ] JAR em `PITIUPI_HOME/plugins` + SPI correto - [ ] README do módulo com build e formato das mensagens +- [ ] UI na aba (`PluginWorkspace`), sem `JFrame` principal - [ ] Demonstração mínima professor ↔ aluno ou aluno ↔ aluno ## Melhorias futuras -Ver [engineering_backlog.md](engineering_backlog.md) (testes automatizados, CI). +Ver [engineering_backlog.md](engineering_backlog.md) (CI contínuo, mais testes de rede sem sockets reais). diff --git a/docs/documentation/17_tutorial_ping_aula.md b/docs/documentation/17_tutorial_ping_aula.md new file mode 100644 index 0000000..5be2728 --- /dev/null +++ b/docs/documentation/17_tutorial_ping_aula.md @@ -0,0 +1,134 @@ +# Tutorial de aula — plugin Ping + +Material para o aluno implementar um plugin **Ping/Pong** com medição de RTT (round-trip time). A solução em Java **não** entra no repositório oficial; apenas este tutorial e a pasta [`plugins/ping/`](../../plugins/ping/) (README + `.gitignore`). + +Índice geral: [README.md](README.md). + +## Objetivos + +- Estender `Plugin` e registrar SPI. +- Mensagens `Serializable` que estendem `Message`. +- `ConnectionManager`: multicast UDP, unicast UDP e TCP. +- UI na aba central: `JPanel` + `bindShellWorkspaceContent` (sem `JFrame`); abrir pela galeria **Plugins** ou peer actions. +- Instalar JAR em `PITIUPI_HOME/plugins` e testar com um colega. + +## Pré-requisitos + +- Pitiupi com login (nome + porta). +- [09_sistema_de_plugins.md](09_sistema_de_plugins.md). +- JDK, Ant; núcleo em `pitiupi/dist/pitiupi.jar`. + +## Estrutura sugerida (projeto do aluno) + +``` +plugins/ping/ # ou projeto fora do repo oficial +├── build.xml +├── nbproject/ +├── src/ +│ ├── META-INF/services/pitiupi.models.Plugin +│ └── br/ufsj/redes/ping/ +│ ├── PingPlugin.java +│ ├── PingMessage.java +│ ├── PongMessage.java +│ └── PingPanel.java +└── dist/pitiupi.ping.jar +``` + +Modelo Ant: copiar [`plugins/chat`](../chat) e ajustar classpath para `../../pitiupi/dist/pitiupi.jar`. + +## Passo 1 — Projeto Ant + +1. Copiar `plugins/chat`. +2. JAR de saída: `dist/pitiupi.ping.jar`. +3. Classpath de compilação: JAR do núcleo. + +**Verificação:** `ant jar` sem erros. + +## Passo 2 — `PingPlugin` + +| Método | Exemplo | +| --- | --- | +| `getName()` | `"Ping"` | +| `getDisplayName()` | `"Ping na rede"` | +| `getIdentifier()` | `"pitiupi.ping"` (único na sala) | + +Em `createPlugin(ConnectionManager cm, PluginWorkspace workspace)`: + +```java +pingPanel = new PingPanel(cm); +bindShellWorkspaceContent(pingPanel); +installPeerActions(workspace.peerActions()); +``` + +**Não** chame `openOrFocusTab` no boot — a aba abre quando o usuário clicar na galeria ou usar peer action. + +Peer actions sugeridas: “Enviar ping” (peer único), “Ping para todos”. + +**Verificação:** após instalar o JAR e reiniciar, só **Plugins** e **Configurações** no boot; botão na galeria abre a aba Ping. + +## Passo 3 — Mensagens + +`PingMessage` e `PongMessage` com `pingId` (UUID) e timestamps para RTT. + +Registrar em `getMessages()`: + +```java +return List.of(PingMessage.class, PongMessage.class); +``` + +Em `receiveMessage`: ao receber ping, responder pong; ao receber pong, calcular RTT e atualizar a UI com `SwingUtilities.invokeLater`. + +## Passo 4 — `PingPanel` + +Botões para multicast, unicast UDP e TCP; área de log ou label com último RTT em ms. Para um peer, use campo IP como no `ChatPanel` ou peer action na lista lateral. + +## Passo 5 — Transportes + +Use `pitiupi.application.messaging.MessageDispatcher` no núcleo: + +```java +MessageDispatcher.send(connectionManager, pingMessage, SendTarget.broadcastAll(), TransportMode.MULTICAST_UDP); +MessageDispatcher.send(connectionManager, pingMessage, SendTarget.singlePeer(addr), TransportMode.UNICAST_UDP); +MessageDispatcher.send(connectionManager, pingMessage, SendTarget.singlePeer(addr), TransportMode.TCP); +``` + +| Destino | Transporte didático | Observação | +| --- | --- | --- | +| Todos | Multicast UDP | Padrão em sala | +| Todos | Unicast UDP | Loop em cada peer online (demo) | +| Um peer | Unicast UDP / TCP | IP da lista ou manual | + +Não alterar o endereço multicast do núcleo (`224.0.0.3`). + +## Passo 6 — SPI e instalação + +`META-INF/services/pitiupi.models.Plugin`: + +``` +br.ufsj.redes.ping.PingPlugin +``` + +Copiar JAR para `~/pitiupi/plugins/` (ou instalar pela aba Configurações) e **reiniciar**. + +## Critérios de sucesso + +| # | Critério | +| --- | --- | +| C1 | JAR carrega sem exceção | +| C2 | Pelo menos um transporte funciona entre duas instâncias | +| C3 | RTT em ms após o pong | +| C4 | README do aluno com build e transporte usado | + +## Anti-padrões + +- Commitar solução no repo oficial. +- Busy-wait na EDT. +- Objetos não `Serializable`. +- Mesmo `getIdentifier()` que outro plugin. + +## Referências + +- [10_tutorial_plugin.md](10_tutorial_plugin.md) +- [05_udp_multicast.md](05_udp_multicast.md) +- [06_tcp_conexoes.md](06_tcp_conexoes.md) +- [04_inicializacao_e_gui.md](04_inicializacao_e_gui.md) diff --git a/docs/documentation/README.md b/docs/documentation/README.md index 75751b2..cf4fa21 100644 --- a/docs/documentation/README.md +++ b/docs/documentation/README.md @@ -9,7 +9,7 @@ Referência para **código Java**, **rede P2P** e **plugins**. Índice geral: [d | 0 | [00_quickstart.md](00_quickstart.md) | Build, plugins, duas instâncias | | 1 | [01_visao_disciplina_e_tp.md](01_visao_disciplina_e_tp.md) | Uso na disciplina e trabalho prático | | 2 | [02_modulos_do_repositorio.md](02_modulos_do_repositorio.md) | Núcleo e três plugins de referência | -| 3 | [03_build_ant_netbeans.md](03_build_ant_netbeans.md) | Ant, NetBeans, Makefile mpChat | +| 3 | [03_build_ant_netbeans.md](03_build_ant_netbeans.md) | Ant e NetBeans (núcleo + plugins) | | 4 | [04_inicializacao_e_gui.md](04_inicializacao_e_gui.md) | Boot, MainWindow, diagrama | | 5 | [05_udp_multicast.md](05_udp_multicast.md) | SocketUDP | | 6 | [06_tcp_conexoes.md](06_tcp_conexoes.md) | SocketTCP, TcpConnection, framing | @@ -17,7 +17,8 @@ Referência para **código Java**, **rede P2P** e **plugins**. Índice geral: [d | 8 | [08_heartbeat_e_presenca.md](08_heartbeat_e_presenca.md) | HeartbeatManager | | 9 | [09_sistema_de_plugins.md](09_sistema_de_plugins.md) | Plugin, PluginManager, SPI | | 10 | [10_tutorial_plugin.md](10_tutorial_plugin.md) | Passo a passo novo plugin | -| 11 | [11_plugins_referencia.md](11_plugins_referencia.md) | chat, screenCast, mpChat | +| 11 | [11_plugins_referencia.md](11_plugins_referencia.md) | chat e screencast | +| 17 | [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md) | Exercício Ping (aula) | | 12 | [12_menus_unicast_multicast.md](12_menus_unicast_multicast.md) | MenuCategory e ações | | 13 | [13_guia_dev_e_agente.md](13_guia_dev_e_agente.md) | Leitura para dev/agente | | 14 | [14_arquitetura_e_manutencao.md](14_arquitetura_e_manutencao.md) | Onde editar cada comportamento | diff --git a/docs/documentation/engineering_backlog.md b/docs/documentation/engineering_backlog.md index 7317edd..a2c62b3 100644 --- a/docs/documentation/engineering_backlog.md +++ b/docs/documentation/engineering_backlog.md @@ -2,10 +2,10 @@ | Item | Prioridade | Notas | | --- | --- | --- | -| Completar tutorial legado em `pitiupi/docs/PluginTutorial.md` | Média | Substituído por [10_tutorial_plugin.md](10_tutorial_plugin.md) | | Testes JUnit para desserialização e framing TCP | Média | Sem rede real | -| CI (GitHub Actions) com `ant jar` + `make` | Baixa | Requer imagem com Ant | +| CI (GitHub Actions) com `ant jar` + `ant test` | Baixa | Requer imagem com Ant/JDK | +| Captura de tela nativa no WSL | Baixa | Limitação de ambiente; validar em Windows/Linux com display | -Itens concluídos recentemente: alinhamento de `pitiupi.mpChat` a `ConnectionManager` / `GuiManager`; documentação de JDK mínimo no [README](../../README.md) e [00_quickstart.md](00_quickstart.md). +Itens concluídos recentemente: shell com abas e peer actions (D16–D18); chat unificado (D19); docs alinhadas à shell v1 (Onda G); suíte JUnit no núcleo (`ant test`); tutorial Ping em [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md). Ao fechar um item, atualizar esta tabela e o doc técnico relacionado. diff --git a/pitiupi.chat/nbproject/private/config.properties b/pitiupi.chat/nbproject/private/config.properties deleted file mode 100644 index e69de29..0000000 diff --git a/pitiupi.chat/nbproject/private/private.properties b/pitiupi.chat/nbproject/private/private.properties deleted file mode 100644 index 2a5dca8..0000000 --- a/pitiupi.chat/nbproject/private/private.properties +++ /dev/null @@ -1,8 +0,0 @@ -compile.on.save=true -do.depend=false -do.jar=true -do.jlink=false -javac.debug=true -javadoc.preview=true -jlink.strip=false -user.properties.file=/home/flavio/snap/netbeans/145/build.properties diff --git a/pitiupi.chat/src/pitiupi/chat/net/ChatMessage.java b/pitiupi.chat/src/pitiupi/chat/net/ChatMessage.java deleted file mode 100644 index db283bc..0000000 --- a/pitiupi.chat/src/pitiupi/chat/net/ChatMessage.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package pitiupi.chat.net; - - -import pitiupi.models.Messages.Message; - -/** - * - * @author flavio - */ -public class ChatMessage extends Message { - - private String text; - private String name; - - - public ChatMessage(){ - - } - - public ChatMessage(String name, String text){ - this.name = name; - this.text = text; - } - /** - * @return the name - */ - public String getName() { - return name; - } - - /** - * @param name the name to set - */ - public void setName(String name) { - this.name = name; - } - - /** - * @return the text - */ - public String getText() { - return text; - } - - /** - * @param text the text to set - */ - public void setText(String text) { - this.text = text; - } - -} diff --git a/pitiupi.chat/src/pitiupi/chat/plugin/ChatPlugin.java b/pitiupi.chat/src/pitiupi/chat/plugin/ChatPlugin.java deleted file mode 100644 index cb9b16f..0000000 --- a/pitiupi.chat/src/pitiupi/chat/plugin/ChatPlugin.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/Classes/Class.java to edit this template - */ -package pitiupi.chat.plugin; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -import pitiupi.chat.GUI.ChatMenu; -import pitiupi.chat.GUI.ChatWindow; -import pitiupi.chat.net.ChatMessage; -import pitiupi.control.ConnectionManager; -import pitiupi.control.GuiManager; -import pitiupi.models.Menus.MenuCategory; -import pitiupi.models.Messages.Message; -import pitiupi.models.Plugin; - -/** - * - * @author flavio - */ -public class ChatPlugin extends Plugin { - - private ChatWindow chatWindow; - - @Override - public void createPlugin(ConnectionManager connectionManager, GuiManager guiManager) { - this.chatWindow = new ChatWindow(connectionManager); - guiManager.createMenu(MenuCategory.Social, new ChatMenu(chatWindow)); - } - - @Override - public List> getMessages() { - return List.of(ChatMessage.class); - } - - - @Override - public void receiveMessage(Message message) { - if (message instanceof ChatMessage msg) { - this.chatWindow.receiveChatMessage(msg); - } - } - - @Override - public String getName() { - return "Chat"; - } - - @Override - public String getAuthor() { - return "Flávio Schiavoni"; - } - - @Override - public String getVersion() { - return "0"; - } - - @Override - public String getIdentifier() { - return "Pitiupi.Chat.Example"; - } - - -} diff --git a/pitiupi.mpChat/.idea/.gitignore b/pitiupi.mpChat/.idea/.gitignore deleted file mode 100644 index b58b603..0000000 --- a/pitiupi.mpChat/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/pitiupi.mpChat/.idea/misc.xml b/pitiupi.mpChat/.idea/misc.xml deleted file mode 100644 index 8547fc5..0000000 --- a/pitiupi.mpChat/.idea/misc.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/pitiupi.mpChat/.idea/modules.xml b/pitiupi.mpChat/.idea/modules.xml deleted file mode 100644 index 93aa0d1..0000000 --- a/pitiupi.mpChat/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/pitiupi.mpChat/Makefile b/pitiupi.mpChat/Makefile deleted file mode 100644 index 9c74139..0000000 --- a/pitiupi.mpChat/Makefile +++ /dev/null @@ -1,16 +0,0 @@ -JAR = ../pitiupi/dist/pitiupi.jar -OUT_DIR = out -META-INF_DIR = src/META-INF -DIST = dist/mpChat.jar - -SOURCES = $(shell find -name "*.java") - -all: - mkdir -p "$(OUT_DIR)" - mkdir -p "$(dir $(DIST))" - javac -cp "$(JAR)" -d "$(OUT_DIR)" $(SOURCES) - cp -r "$(META-INF_DIR)" "$(OUT_DIR)" - jar cf "$(DIST)" -C "$(OUT_DIR)" . - -clean: - rm -rf "$(OUT_DIR)"/* diff --git a/pitiupi.mpChat/out/META-INF/services/pitiupi.models.Plugin b/pitiupi.mpChat/out/META-INF/services/pitiupi.models.Plugin deleted file mode 100644 index 3240a6f..0000000 --- a/pitiupi.mpChat/out/META-INF/services/pitiupi.models.Plugin +++ /dev/null @@ -1 +0,0 @@ -plugin.MultiProtocolChat diff --git a/pitiupi.mpChat/pitiupi.mpChat.iml b/pitiupi.mpChat/pitiupi.mpChat.iml deleted file mode 100644 index 4e2cbb3..0000000 --- a/pitiupi.mpChat/pitiupi.mpChat.iml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pitiupi.mpChat/src/GUI/MulticastMpChatMenu.java b/pitiupi.mpChat/src/GUI/MulticastMpChatMenu.java deleted file mode 100644 index a068b1d..0000000 --- a/pitiupi.mpChat/src/GUI/MulticastMpChatMenu.java +++ /dev/null @@ -1,22 +0,0 @@ -package GUI; - -import pitiupi.models.Menus.MulticastMenus.MulticastActionMenu; - -public class MulticastMpChatMenu extends MulticastActionMenu { - PublicChatWindow publicChatWindow; - - public MulticastMpChatMenu(PublicChatWindow publicChatWindow) { - this.publicChatWindow = publicChatWindow; - } - - @Override - public void onClick() { - publicChatWindow.setVisible(true); - } - - - @Override - public String getText() { - return "MP-Chat"; - } -} diff --git a/pitiupi.mpChat/src/GUI/PrivateChatWindow.java b/pitiupi.mpChat/src/GUI/PrivateChatWindow.java deleted file mode 100644 index 0ec9968..0000000 --- a/pitiupi.mpChat/src/GUI/PrivateChatWindow.java +++ /dev/null @@ -1,122 +0,0 @@ -package GUI; - -import net.MpChatMessage; -import pitiupi.control.ConnectionManager; -import pitiupi.net.tcp.TcpConnection; - -import javax.swing.*; -import java.awt.*; -import java.net.InetAddress; -import java.util.logging.Level; -import java.util.logging.Logger; - -public class PrivateChatWindow extends JFrame { - - private final ConnectionManager connectionManager; - private final TcpConnection connection; - - private final JTextArea receivedMessagesArea; - private final JTextField messageField; - - public PrivateChatWindow( - ConnectionManager connectionManager, - TcpConnection connection - ) { - this.connectionManager = connectionManager; - this.connection = connection; - - setTitle("Chat privado"); - setSize(700, 500); - setLocationRelativeTo(null); - setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE); - - JPanel mainPanel = new JPanel(new BorderLayout(10, 10)); - mainPanel.setBorder( - BorderFactory.createEmptyBorder(10, 10, 10, 10) - ); - - receivedMessagesArea = new JTextArea(); - receivedMessagesArea.setEditable(false); - receivedMessagesArea.setLineWrap(true); - receivedMessagesArea.setWrapStyleWord(true); - - JScrollPane messagesScrollPane = - new JScrollPane(receivedMessagesArea); - - messagesScrollPane.setBorder( - BorderFactory.createTitledBorder("Mensagens") - ); - - mainPanel.add( - messagesScrollPane, - BorderLayout.CENTER - ); - - JPanel bottomPanel = new JPanel( - new BorderLayout(10, 10) - ); - - messageField = new JTextField(); - - JButton tcpButton = new JButton("TCP"); - tcpButton.addActionListener(e -> sendTCP()); - - bottomPanel.add( - messageField, - BorderLayout.CENTER - ); - - bottomPanel.add( - tcpButton, - BorderLayout.EAST - ); - - mainPanel.add( - bottomPanel, - BorderLayout.SOUTH - ); - - setContentPane(mainPanel); - } - - private void sendTCP() { - try { - String nome = connectionManager.getUserName(); - - MpChatMessage message = new MpChatMessage( - nome, - InetAddress.getLocalHost().getHostAddress(), - "TCP Private", - messageField.getText() - ); - - connection.send(message.toByteArray()); - receiveMessage(message); - - messageField.setText(""); - - } catch (Exception ex) { - Logger.getLogger( - PrivateChatWindow.class.getName() - ).log(Level.SEVERE, null, ex); - } - } - - public void receiveMessage(MpChatMessage message) { - SwingUtilities.invokeLater(() -> { - String texto = String.format( - "[%s] %s (%s): %s%n", - message.getTipoMensagem(), - message.getNome(), - message.getIp(), - message.getMessage() - ); - - receivedMessagesArea.append(texto); - - receivedMessagesArea.setCaretPosition( - receivedMessagesArea.getDocument().getLength() - ); - }); - } -} \ No newline at end of file diff --git a/pitiupi.mpChat/src/GUI/PublicChatWindow.java b/pitiupi.mpChat/src/GUI/PublicChatWindow.java deleted file mode 100644 index 3716217..0000000 --- a/pitiupi.mpChat/src/GUI/PublicChatWindow.java +++ /dev/null @@ -1,217 +0,0 @@ -package GUI; - -import net.MpChatMessage; -import pitiupi.control.ConnectionManager; - -import javax.swing.*; -import javax.swing.text.BadLocationException; -import javax.swing.text.SimpleAttributeSet; -import javax.swing.text.StyleConstants; -import javax.swing.text.StyledDocument; -import java.awt.*; -import java.net.InetAddress; -import java.util.logging.Level; -import java.util.logging.Logger; - -public class PublicChatWindow extends JFrame { - private final ConnectionManager connectionManager; - - private JTextPane receivedMessagesArea; - private JTextField messageField; - private JTextField ipField; - - public PublicChatWindow(ConnectionManager connectionManager) { - this.connectionManager = connectionManager; - - initComponents(); - } - - private void initComponents() { - setTitle("Public Chat"); - setSize(700, 500); - setLocationRelativeTo(null); - setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE); - - JPanel mainPanel = new JPanel(new BorderLayout(10, 10)); - - mainPanel.setBorder( - BorderFactory.createEmptyBorder(10, 10, 10, 10) - ); - - initReceivedMessagesPanel(mainPanel); - initBottomPanel(mainPanel); - - setContentPane(mainPanel); - } - - private void initReceivedMessagesPanel(JPanel mainPanel) { - receivedMessagesArea = new JTextPane(); - - receivedMessagesArea.setEditable(false); - - JScrollPane messagesScrollPane = new JScrollPane(receivedMessagesArea); - - messagesScrollPane.setBorder( - BorderFactory.createTitledBorder("Mensagens recebidas") - ); - - mainPanel.add(messagesScrollPane, BorderLayout.CENTER); - } - - private void initBottomPanel(JPanel mainPanel) { - messageField = new JTextField(); - - JPanel bottomPanel = new JPanel( - new BorderLayout(10, 10) - ); - - bottomPanel.add(messageField, BorderLayout.CENTER); - - initIpField(bottomPanel); - initCommunicationButtons(bottomPanel); - - mainPanel.add(bottomPanel, BorderLayout.SOUTH); - } - - private void initIpField(JPanel bottomPanel) { - ipField = new JTextField(); - ipField.setPreferredSize(new Dimension(130, 30)); - - JPanel ipPanel = new JPanel( - new BorderLayout(5, 5) - ); - - ipPanel.add( - new JLabel("IP destino:"), - BorderLayout.WEST - ); - - ipPanel.add( - ipField, - BorderLayout.CENTER - ); - - bottomPanel.add(ipPanel, BorderLayout.WEST); - } - - private void initCommunicationButtons(JPanel bottomPanel) { - JButton tcpButton = new JButton("TCP"); - JButton udpButton = new JButton("UDP"); - JButton multicastButton = new JButton("Multicast"); - - tcpButton.addActionListener(e -> sendTCP()); - udpButton.addActionListener(e -> sendUDP()); - multicastButton.addActionListener(e -> sendMulticast()); - - JPanel buttonsPanel = new JPanel( - new FlowLayout(FlowLayout.RIGHT, 5, 0) - ); - - buttonsPanel.add(tcpButton); - buttonsPanel.add(udpButton); - buttonsPanel.add(multicastButton); - - bottomPanel.add( - buttonsPanel, - BorderLayout.EAST - ); - } - - private MpChatMessage createMessage(String tipoMensagem) { - try { - String nome = connectionManager.getUserName(); - String ip = InetAddress.getLocalHost().getHostAddress(); - String message = messageField.getText(); - - return new MpChatMessage( - nome, - ip, - tipoMensagem, - message - ); - - } catch (Exception ex) { - throw new RuntimeException(ex); - } - } - - public void receiveMessage(MpChatMessage msg) { - SwingUtilities.invokeLater(() -> { - String mensagem = String.format( - "[%s] %s (%s): %s%n", - msg.getTipoMensagem(), - msg.getNome(), - msg.getIp(), - msg.getMessage() - ); - - StyledDocument document = receivedMessagesArea.getStyledDocument(); - - SimpleAttributeSet attributes = new SimpleAttributeSet(); - - if (msg.getTipoMensagem().equals("TCP Unicast") || msg.getTipoMensagem().equals("UDP Unicast")) { - StyleConstants.setForeground(attributes, new Color(128, 0, 128)); - } - - try { - document.insertString( - document.getLength(), - mensagem, - attributes - ); - - receivedMessagesArea.setCaretPosition( - document.getLength() - ); - - } catch (BadLocationException ex) { - Logger.getLogger(PublicChatWindow.class.getName()).log(Level.SEVERE, null, ex); - } - }); - } - - private void sendTCP() { - try { - MpChatMessage msg = createMessage("TCP Unicast"); - - InetAddress ip = InetAddress.getByName(ipField.getText()); - - connectionManager.sendTcp(msg, ip); - receiveMessage(msg); - - } catch (Exception ex) { - Logger.getLogger( - PublicChatWindow.class.getName() - ).log(Level.SEVERE, null, ex); - } - } - - private void sendUDP() { - try { - MpChatMessage msg = createMessage("UDP Unicast"); - - InetAddress ip = InetAddress.getByName(ipField.getText()); - - connectionManager.sendUdpUnicast(msg, ip); - receiveMessage(msg); - - } catch (Exception ex) { - Logger.getLogger( - PublicChatWindow.class.getName() - ).log(Level.SEVERE, null, ex); - } - } - - private void sendMulticast() { - try { - MpChatMessage msg = createMessage("UDP Multicast"); - - connectionManager.sendUdpMulticast(msg); - - } catch (Exception ex) { - Logger.getLogger( - PublicChatWindow.class.getName() - ).log(Level.SEVERE, null, ex); - } - } -} \ No newline at end of file diff --git a/pitiupi.mpChat/src/GUI/UnicastMpChatMenu.java b/pitiupi.mpChat/src/GUI/UnicastMpChatMenu.java deleted file mode 100644 index e848440..0000000 --- a/pitiupi.mpChat/src/GUI/UnicastMpChatMenu.java +++ /dev/null @@ -1,26 +0,0 @@ -package GUI; - -import control.PrivateChatController; -import pitiupi.models.Menus.UnicastMenus.UnicastActionMenu; -import pitiupi.models.User; - -public class UnicastMpChatMenu extends UnicastActionMenu { - - private final PrivateChatController controller; - - public UnicastMpChatMenu( - PrivateChatController controller - ) { - this.controller = controller; - } - - @Override - public void onClick(User user) { - controller.openChat(user); - } - - @Override - public String getText() { - return "Start Private Chat"; - } -} \ No newline at end of file diff --git a/pitiupi.mpChat/src/META-INF/services/pitiupi.models.Plugin b/pitiupi.mpChat/src/META-INF/services/pitiupi.models.Plugin deleted file mode 100644 index 3240a6f..0000000 --- a/pitiupi.mpChat/src/META-INF/services/pitiupi.models.Plugin +++ /dev/null @@ -1 +0,0 @@ -plugin.MultiProtocolChat diff --git a/pitiupi.mpChat/src/control/PrivateChatController.java b/pitiupi.mpChat/src/control/PrivateChatController.java deleted file mode 100644 index d806153..0000000 --- a/pitiupi.mpChat/src/control/PrivateChatController.java +++ /dev/null @@ -1,120 +0,0 @@ -package control; - -import GUI.PrivateChatWindow; -import net.MpChatMessage; -import pitiupi.control.ConnectionManager; -import pitiupi.models.User; -import pitiupi.net.tcp.TcpConnection; -import plugin.MultiProtocolChat; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -public class PrivateChatController { - - private final ConnectionManager connectionManager; - private final MultiProtocolChat multiProtocolChat; - - /* - * Relaciona o endereço do usuário à janela de chat. - */ - private final Map windows; - - /* - * Endereços para os quais uma conexão já foi solicitada, - * mas ainda não chegou. - */ - private final Set pendingConnections; - - public PrivateChatController( - ConnectionManager connectionManager, - MultiProtocolChat multiProtocolChat - ) { - this.connectionManager = connectionManager; - this.multiProtocolChat = multiProtocolChat; - - this.windows = new HashMap<>(); - this.pendingConnections = new HashSet<>(); - } - - private static String addressKey(TcpConnection connection) { - return connection.getAddress().getHostAddress(); - } - - public void openChat(User user) { - String address = user.getAddress().getHostAddress(); - - PrivateChatWindow window = windows.get(address); - - /* - * Já existe uma janela para esse usuário. - */ - if (window != null) { - window.setVisible(true); - window.toFront(); - window.requestFocus(); - return; - } - - /* - * Já estamos esperando uma conexão para esse usuário. - */ - if (pendingConnections.contains(address)) { - return; - } - - /* - * Ainda não existe conexão. - */ - pendingConnections.add(address); - - multiProtocolChat.openConnection(address); - } - - public void receiveMessage(TcpConnection connection, MpChatMessage message) { - String address = addressKey(connection); - - PrivateChatWindow window = windows.get(address); - - if (window == null) { - return; - } - - window.receiveMessage(message); - } - - /** - * Deve ser chamado quando uma nova conexão privada chegar. - */ - public void connectionReceived(TcpConnection connection) { - String address = addressKey(connection); - - pendingConnections.remove(address); - - PrivateChatWindow window = new PrivateChatWindow( - connectionManager, - connection - ); - - windows.put(address, window); - - window.setVisible(true); - window.toFront(); - window.requestFocus(); - } - - /** - * Fecha a janela e remove o relacionamento. - */ - public void closeChat(TcpConnection connection) { - String address = addressKey(connection); - - PrivateChatWindow window = windows.remove(address); - - if (window != null) { - window.dispose(); - } - } -} diff --git a/pitiupi.mpChat/src/plugin/MultiProtocolChat.java b/pitiupi.mpChat/src/plugin/MultiProtocolChat.java deleted file mode 100644 index c9915b2..0000000 --- a/pitiupi.mpChat/src/plugin/MultiProtocolChat.java +++ /dev/null @@ -1,129 +0,0 @@ -package plugin; - -import GUI.MulticastMpChatMenu; -import GUI.PublicChatWindow; -import GUI.UnicastMpChatMenu; -import control.PrivateChatController; -import net.MpChatMessage; -import pitiupi.control.ConnectionManager; -import pitiupi.control.GuiManager; -import pitiupi.models.Menus.MenuCategory; -import pitiupi.models.Messages.Message; -import pitiupi.models.Plugin; -import pitiupi.net.tcp.TcpConnection; - -import javax.swing.*; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.ObjectInputStream; -import java.net.InetAddress; -import java.util.List; - -public class MultiProtocolChat extends Plugin { - private ConnectionManager connectionManager; - - PrivateChatController privateChatController; - PublicChatWindow publicChatWindow; - - @Override - public String getName() { - return "MultiProtocol chat"; - } - - @Override - public String getAuthor() { - return "Gustavo Henrique"; - } - - @Override - public String getVersion() { - return "0"; - } - - @Override - public String getIdentifier() { - return "Multi.Protocol.Chat.Pitiupi"; - } - - @Override - public void createPlugin(ConnectionManager connectionManager, GuiManager guiManager) { - this.connectionManager = connectionManager; - - privateChatController = new PrivateChatController(connectionManager, this); - - publicChatWindow = new PublicChatWindow(connectionManager); - - MulticastMpChatMenu multicastMpChatMenu = new MulticastMpChatMenu(publicChatWindow); - UnicastMpChatMenu unicastMpChatMenu = new UnicastMpChatMenu(privateChatController); - - guiManager.createMenu(MenuCategory.Social, multicastMpChatMenu); - guiManager.createMenu(MenuCategory.Social, unicastMpChatMenu); - } - - @Override - public List> getMessages() { - return List.of(MpChatMessage.class); - } - - @Override - public void receiveMessage(Message message) { - if (message instanceof MpChatMessage mpChatMessage) { - publicChatWindow.receiveMessage(mpChatMessage); - } - } - - - @Override - public boolean receivePrivateConnection(TcpConnection connection) { - SwingUtilities.invokeLater(() -> { - privateChatController.connectionReceived(connection); - }); - return true; - } - - @Override - public boolean receiveBytes(TcpConnection connection, byte[] bytes) { - MpChatMessage mpChatMessage = parseMessage(bytes); - if (mpChatMessage == null) { - System.out.println("Invalid message received"); - return true; - } - else System.out.println(mpChatMessage.getMessage()); - - privateChatController.receiveMessage(connection, mpChatMessage); - return true; - } - - @Override - public boolean onSocketClosed(TcpConnection connection) { - privateChatController.closeChat(connection); - return true; - } - - public void openConnection(String ip) { - try { - connectionManager.connectPluginTcp( - InetAddress.getByName(ip), - getIdentifier(), - getIdentifier() - ); - } - catch (Exception e) { - System.out.println("Error opening connection: " + e.getMessage()); - } - } - - private MpChatMessage parseMessage(byte[] bytes) { - ByteArrayInputStream bis = new ByteArrayInputStream(bytes); - ObjectInputStream ois; - try { - ois = new ObjectInputStream(bis); - if (ois.readObject() instanceof MpChatMessage msg) { - return msg; - } - } catch (IOException | ClassNotFoundException ex) { - System.out.println(ex.toString()); - } - return null; - } -} diff --git a/pitiupi.screenCast/.idea/vcs.xml b/pitiupi.screenCast/.idea/vcs.xml deleted file mode 100644 index 6c0b863..0000000 --- a/pitiupi.screenCast/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/pitiupi.screenCast/nbproject/private/private.properties b/pitiupi.screenCast/nbproject/private/private.properties deleted file mode 100644 index 5e86d43..0000000 --- a/pitiupi.screenCast/nbproject/private/private.properties +++ /dev/null @@ -1,2 +0,0 @@ -compile.on.save=true -user.properties.file=/home/flavio/snap/netbeans/145/build.properties diff --git a/pitiupi.screenCast/src/pitiupi/screenCast/GUI/ScreenCastWindow.java b/pitiupi.screenCast/src/pitiupi/screenCast/GUI/ScreenCastWindow.java deleted file mode 100644 index 573dc9b..0000000 --- a/pitiupi.screenCast/src/pitiupi/screenCast/GUI/ScreenCastWindow.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package pitiupi.screenCast.GUI; - -import pitiupi.control.ConnectionManager; -import pitiupi.control.GuiManager; -import pitiupi.screenCast.control.AlarmClock; -import pitiupi.screenCast.control.ImageTransformation; -import pitiupi.screenCast.control.ScreenShot; -import pitiupi.screenCast.net.ScreenCastMessage; -import java.awt.AWTException; -import java.awt.event.ActionEvent; -import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.util.Timer; -import java.util.logging.Level; -import java.util.logging.Logger; -import javax.imageio.ImageIO; -import javax.swing.ImageIcon; -import javax.swing.JLabel; - -/** - * - * @author flavio - */ -public class ScreenCastWindow extends javax.swing.JFrame { - - private javax.swing.JScrollPane jScrollPane1; - private javax.swing.JTabbedPane tabbedPane; - private javax.swing.JCheckBox onlineButton; - - private AlarmClock alarmClock; - private Timer timer; - public final static int TIME = 1000; - - private float compressRatio; - private final ConnectionManager connectionManager; - private final GuiManager guiManager; - - public ScreenCastWindow(ConnectionManager connectionManager, GuiManager guiManager) { - this.connectionManager = connectionManager; - this.guiManager = guiManager; - - this.compressRatio = 0.5f; - this.initComponents(); - } - - private void initComponents() { - this.setTitle("screen cast"); - - onlineButton = new javax.swing.JCheckBox("Sharing?"); - onlineButton.setSelected(false); - onlineButton.addActionListener(this::onlineButtonActionPerformed); - this.getContentPane().add(onlineButton, java.awt.BorderLayout.NORTH); - - jScrollPane1 = new javax.swing.JScrollPane(); - tabbedPane = new javax.swing.JTabbedPane(); - jScrollPane1.setViewportView(tabbedPane); - this.getContentPane().add(jScrollPane1, java.awt.BorderLayout.CENTER); - - this.pack(); - - this.timer = new Timer(); - this.setSize(new java.awt.Dimension(1024, 768)); - } - - public void shareMySheet(boolean status) { - if (status) { - this.timer = new Timer(); - this.alarmClock = new AlarmClock(this); - this.timer.scheduleAtFixedRate(this.alarmClock, 0, ScreenCastWindow.TIME); - } else { - this.timer.cancel(); - } - } - - public void closeCurrentTab() { - if (tabbedPane.getSelectedIndex() > 0) { - tabbedPane.remove(tabbedPane.getSelectedIndex()); - } - } - - public void exit() { - this.shareMySheet(false); - } - - public void sendScreenCastMessage() { - byte[] msg = null; - try { - BufferedImage image = ScreenShot.takeAShot(); - BufferedImage rescaled = ImageTransformation.resize(image, 1024, 768); - byte[] byteImage = ImageTransformation.compress(rescaled, compressRatio); - ScreenCastMessage message = new ScreenCastMessage(this.connectionManager.getUserName(), byteImage); - - if (!connectionManager.sendUdpMulticast(message)) this.compressRatio -= 0.1f; - } catch (AWTException | IOException ex) { - Logger.getLogger(AlarmClock.class.getName()).log(Level.SEVERE, null, ex); - } - } - - public void receiveScreenCastMessage(ScreenCastMessage message) { - ByteArrayInputStream bais = new ByteArrayInputStream(message.getImageByte()); - BufferedImage image; - try { - - image = ImageIO.read(bais); - ImageIcon imageIcon = new ImageIcon(image); - - JLabel myShoot = new JLabel(imageIcon); - - for (int i = 0; i < tabbedPane.getTabCount(); i++) { - String title = tabbedPane.getTitleAt(i); - if (title.equals(message.getName())) { - tabbedPane.setComponentAt(i, myShoot); - return; - } - } - tabbedPane.addTab(message.getName(), myShoot); - } catch (IOException ex) { - Logger.getLogger(ScreenCastWindow.class.getName()).log(Level.SEVERE, null, ex); - } - } - - private void onlineButtonActionPerformed(ActionEvent ae) { - this.shareMySheet(this.onlineButton.isSelected()); - } -} diff --git a/pitiupi.screenCast/src/pitiupi/screenCast/control/AlarmClock.java b/pitiupi.screenCast/src/pitiupi/screenCast/control/AlarmClock.java deleted file mode 100644 index c3d1e79..0000000 --- a/pitiupi.screenCast/src/pitiupi/screenCast/control/AlarmClock.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package pitiupi.screenCast.control; - -import java.util.TimerTask; -import pitiupi.screenCast.GUI.ScreenCastWindow; - -/** - * - * @author flavio - */ -public class AlarmClock extends TimerTask { - - private final ScreenCastWindow screenCastWindow; - - public AlarmClock(ScreenCastWindow mainWindow) { - this.screenCastWindow = mainWindow; - } - - @Override - public void run() { - this.screenCastWindow.sendScreenCastMessage(); - } -} diff --git a/pitiupi.screenCast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java b/pitiupi.screenCast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java deleted file mode 100644 index 91b18ae..0000000 --- a/pitiupi.screenCast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/Classes/Class.java to edit this template - */ -package pitiupi.screenCast.plugin; - -import java.util.List; - -import pitiupi.control.ConnectionManager; -import pitiupi.control.GuiManager; -import pitiupi.models.Menus.MenuCategory; -import pitiupi.models.Messages.Message; -import pitiupi.models.Plugin; -import pitiupi.screenCast.GUI.ScreenCastMenu; -import pitiupi.screenCast.GUI.ScreenCastWindow; -import pitiupi.screenCast.net.ScreenCastMessage; - -/** - * - * @author flavio - */ -public class ScreenCastPlugin extends Plugin { - - private ScreenCastWindow screenCastWindow; - - @Override - public String getName() { - return "ScreenCast"; - } - - @Override - public String getAuthor() { - return "Flávio Schiavoni"; - } - - @Override - public String getVersion() { - return "0.0"; - } - - @Override - public String getIdentifier() { - return "Pitiupi.ScreenCast.Example"; - } - - @Override - public void createPlugin(ConnectionManager connectionManager, GuiManager guiManager) { - this.screenCastWindow = new ScreenCastWindow(connectionManager, guiManager); - guiManager.createMenu(MenuCategory.Streaming, new ScreenCastMenu(screenCastWindow)); - } - - @Override - public List> getMessages() { - return List.of(ScreenCastMessage.class); - } - - @Override - public void receiveMessage(Message message) { - if (message instanceof ScreenCastMessage msg) { - this.screenCastWindow.receiveScreenCastMessage(msg); - } - } -} diff --git a/pitiupi/build.xml b/pitiupi/build.xml index 8d808a2..21102c4 100644 --- a/pitiupi/build.xml +++ b/pitiupi/build.xml @@ -70,4 +70,26 @@ nbproject/build-impl.xml file. --> + + + + + + + + + + + + + + + + diff --git a/pitiupi/nbproject/private/config.properties b/pitiupi/nbproject/private/config.properties deleted file mode 100644 index e69de29..0000000 diff --git a/pitiupi/nbproject/private/private.properties b/pitiupi/nbproject/private/private.properties deleted file mode 100644 index 2a5dca8..0000000 --- a/pitiupi/nbproject/private/private.properties +++ /dev/null @@ -1,8 +0,0 @@ -compile.on.save=true -do.depend=false -do.jar=true -do.jlink=false -javac.debug=true -javadoc.preview=true -jlink.strip=false -user.properties.file=/home/flavio/snap/netbeans/145/build.properties diff --git a/pitiupi/nbproject/private/private.xml b/pitiupi/nbproject/private/private.xml deleted file mode 100644 index b64f1b3..0000000 --- a/pitiupi/nbproject/private/private.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - file:/home/flavio/NetBeansProjects/pitiupi/src/pitiupi/control/PluginManager.java - file:/home/flavio/NetBeansProjects/pitiupi/src/pitiupi/control/ImageTransformation.java - - - diff --git a/pitiupi/plugins/README.md b/pitiupi/plugins/README.md index 77acaf8..3636ac1 100644 --- a/pitiupi/plugins/README.md +++ b/pitiupi/plugins/README.md @@ -1,33 +1,24 @@ -# Plugins instalados +# Plugins instalados (runtime) -O núcleo carrega **todos os arquivos `.jar`** desta pasta ao iniciar. Execute sempre a partir de `pitiupi/`: - -```bash -java -jar dist/pitiupi.jar -``` - -## Pasta única (sem subpastas por categoria) - -Os JARs ficam lado a lado em `plugins/`. A divisão **chat / jogo / streaming** aparece no menu **Plugin → Social | Jogos | Streaming**, definida por cada plugin com `MenuCategory` no código. +O núcleo carrega **todos os arquivos `.jar`** de `PITIUPI_HOME/plugins` ao iniciar (padrão `~/pitiupi/plugins`). Variável de ambiente opcional: `PITIUPI_HOME`. ## Instalação automática (recomendado) Na raiz do repositório, o catálogo [`plugins.registry.json`](../../plugins.registry.json) lista módulos buildáveis: ```bash +./scripts/pitiupi.sh build --all ./scripts/pitiupi.sh link --all -./scripts/pitiupi.sh build --plugins chat ``` -Symlinks gerados: `plugins/{id}.jar` → `../../{módulo}/{jar}`. +Symlinks: `{PITIUPI_HOME}/plugins/{id}.jar` → JAR buildado em `plugins/{módulo}/dist/` no clone. ## Manual ```bash -cd pitiupi/plugins -ln -sf ../../pitiupi.chat/dist/pitiupi.chat.jar chat.jar +ln -sf /caminho/ao/clone/plugins/chat/dist/pitiupi.chat.jar ~/pitiupi/plugins/chat.jar ``` -Não commite `.jar` — apenas este README e symlinks locais. +Não commite `.jar` binários no Git — apenas este README. Novo módulo: registrar no catálogo e ver [docs/documentation/10_tutorial_plugin.md](../../docs/documentation/10_tutorial_plugin.md). diff --git a/pitiupi/src/pitiupi/GUI/MainWindow.java b/pitiupi/src/pitiupi/GUI/MainWindow.java index 87c4495..ceddaa3 100644 --- a/pitiupi/src/pitiupi/GUI/MainWindow.java +++ b/pitiupi/src/pitiupi/GUI/MainWindow.java @@ -8,97 +8,76 @@ package pitiupi.GUI; import pitiupi.control.ConnectionManager; import pitiupi.control.GuiManager; import pitiupi.control.plugins.PluginManager; +import pitiupi.presentation.shell.NetworkUsersPanel; +import pitiupi.presentation.shell.ShellHost; +import pitiupi.presentation.shell.ShellMenuBar; import javax.swing.*; - import java.awt.*; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; /** - * Janela principal da aplicação. - * - *

Responsável por inicializar a interface gráfica e coordenar os - * principais componentes da aplicação, incluindo comunicação de rede, - * gerenciamento de heartbeat e carregamento de plugins.

- * - *

A instância desta classe é compartilhada com os plugins para permitir - * integração com a aplicação principal.

- * - * @author flavio - * @author tony - * @author Gustavo + * @deprecated Substituído por {@link pitiupi.presentation.shell.MainShellFrame}. */ -public class MainWindow extends JFrame { +@Deprecated +public class MainWindow extends JFrame implements ShellHost { - private MenuBar menuBar; private JLabel status; - OnlineUsersPanel onlineUsersPanel; + private final NetworkUsersPanel networkUsersPanel; private final ConnectionManager connectionManager; private final PluginManager pluginManager; private final GuiManager guiManager; - /** - * Cria a janela principal da aplicação. - * - * @param userName nome utilizado para identificação na rede. - * @param port porta utilizada na comunicação multicast. - */ public MainWindow(String userName, int port) { - initMenu(); - pluginManager = new PluginManager(); - connectionManager = new ConnectionManager(this, userName, pluginManager, port); + connectionManager = new ConnectionManager(userName, pluginManager, port); + + ShellMenuBar menuBar = new ShellMenuBar(); + setJMenuBar(menuBar); + setDefaultCloseOperation(javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE); + setTitle("Pitiupi"); + + networkUsersPanel = new NetworkUsersPanel(connectionManager); + getContentPane().add(new JScrollPane(new JTabbedPane()), BorderLayout.CENTER); + getContentPane().add(networkUsersPanel, BorderLayout.LINE_END); - initUserPanel(); initStatusPanel(); - guiManager = new GuiManager(this, menuBar, onlineUsersPanel); + guiManager = new GuiManager(this, menuBar, networkUsersPanel); loadPlugins(); - this.pack(); + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + exit(); + } + }); - this.setSize(new java.awt.Dimension(1024, 768)); - } - - private void initMenu() { - menuBar = new MenuBar(this); - - this.setJMenuBar(menuBar); - this.setDefaultCloseOperation(javax.swing.WindowConstants.EXIT_ON_CLOSE); - this.setTitle("Pitiupi"); - - javax.swing.JScrollPane jScrollPane1 = new javax.swing.JScrollPane(); - javax.swing.JTabbedPane tabbedPane = new javax.swing.JTabbedPane(); - - jScrollPane1.setViewportView(tabbedPane); - - this.getContentPane().add(jScrollPane1, java.awt.BorderLayout.CENTER); - } - - private void initUserPanel() { - onlineUsersPanel = new OnlineUsersPanel(); - this.connectionManager.addPeerListener(onlineUsersPanel); - this.getContentPane().add(onlineUsersPanel, java.awt.BorderLayout.LINE_END); + pack(); + setSize(new Dimension(1024, 768)); } private void initStatusPanel() { JPanel statusPanel = new JPanel(); status = new JLabel(); - statusPanel.setLayout(new FlowLayout(FlowLayout.RIGHT)); updateStatus(); statusPanel.add(status); - - this.getContentPane().add(statusPanel, BorderLayout.PAGE_END); + getContentPane().add(statusPanel, BorderLayout.PAGE_END); } + @Override public void exit() { - this.setVisible(false); - this.dispose(); + connectionManager.shutdownGracefully(); + setVisible(false); + dispose(); System.exit(0); } + @Override public ConnectionManager getConnectionManager() { return connectionManager; } @@ -107,13 +86,18 @@ public class MainWindow extends JFrame { pluginManager.loadPlugins(connectionManager, guiManager); } + @Override public void setUserName(String userName) { connectionManager.setUserName(userName); updateStatus(); } - private void updateStatus() { - this.status.setText("Connected on port " + connectionManager.getPort() + " name " + connectionManager.getUserName()); + @Override + public void setStatusMessage(String message) { + status.setText(message); } + private void updateStatus() { + status.setText("Connected on port " + connectionManager.getPort() + " name " + connectionManager.getUserName()); + } } diff --git a/pitiupi/src/pitiupi/GUI/OnlineUsersPanel.java b/pitiupi/src/pitiupi/GUI/OnlineUsersPanel.java index 81084ad..88ce969 100644 --- a/pitiupi/src/pitiupi/GUI/OnlineUsersPanel.java +++ b/pitiupi/src/pitiupi/GUI/OnlineUsersPanel.java @@ -33,7 +33,7 @@ public class OnlineUsersPanel extends JPanel implements PeerListener { initMenu(); setLayout(new BorderLayout()); - setBorder(BorderFactory.createTitledBorder("Online")); + setBorder(BorderFactory.createTitledBorder("Usuários nesta rede")); setPreferredSize(new Dimension(160, 0)); listModel = new DefaultListModel<>(); @@ -64,6 +64,10 @@ public class OnlineUsersPanel extends JPanel implements PeerListener { } userList.setSelectedIndex(index); + User selected = listModel.getElementAt(index); + if (selected != null && selected.isBroadcastTarget()) { + return; + } menu.show(userList, e.getX(), e.getY()); } }); @@ -73,7 +77,7 @@ public class OnlineUsersPanel extends JPanel implements PeerListener { menuItem.addActionListener(e -> { User user = userList.getSelectedValue(); - if (user != null) { + if (user != null && !user.isBroadcastTarget() && user.getAddress() != null) { action.accept(user); } }); @@ -92,6 +96,7 @@ public class OnlineUsersPanel extends JPanel implements PeerListener { public void onPeersChanged(List peers) { SwingUtilities.invokeLater(() -> { listModel.clear(); + listModel.addElement(User.broadcastAll()); for (User peer : peers) { listModel.addElement(peer); } diff --git a/pitiupi/src/pitiupi/Main.java b/pitiupi/src/pitiupi/Main.java index 9e07887..742e741 100644 --- a/pitiupi/src/pitiupi/Main.java +++ b/pitiupi/src/pitiupi/Main.java @@ -5,7 +5,9 @@ */ package pitiupi; -import java.net.UnknownHostException; +import pitiupi.platform.LegacyPluginMigration; +import pitiupi.platform.PitiupiHome; + import java.util.logging.Level; import java.util.logging.Logger; @@ -44,12 +46,21 @@ public class Main { java.awt.EventQueue.invokeLater(new Runnable() { @Override public void run() { - pitiupi.GUI.Login login = new pitiupi.GUI.Login(); - login.setVisible(true); + try { + PitiupiHome.get().ensureLayout(); + LegacyPluginMigration.maybeMigrateFromLegacyCwd(); + } catch (Exception e) { + Logger.getLogger(Main.class.getName()).log(Level.SEVERE, "Falha ao preparar PITIUPI_HOME", e); + } - pitiupi.GUI.MainWindow main; - main = new pitiupi.GUI.MainWindow(login.getName(), login.getPort()); - main.setVisible(true); + pitiupi.presentation.shell.LoginDialog login = new pitiupi.presentation.shell.LoginDialog(); + if (!login.showLogin()) { + return; + } + + pitiupi.presentation.shell.MainShellFrame shell = + new pitiupi.presentation.shell.MainShellFrame(login.getUserName(), login.getPort()); + shell.showOnScreen(); } }); } diff --git a/pitiupi/src/pitiupi/api/plugin/InviteDetails.java b/pitiupi/src/pitiupi/api/plugin/InviteDetails.java new file mode 100644 index 0000000..965b50a --- /dev/null +++ b/pitiupi/src/pitiupi/api/plugin/InviteDetails.java @@ -0,0 +1,63 @@ +package pitiupi.api.plugin; + +import pitiupi.application.invites.SessionInviteKind; + +/** + * Convite aceito pelo usuário na UI. + */ +public final class InviteDetails { + + private final String inviteId; + private final String fromInstanceId; + private final String fromDisplayName; + private final String toInstanceId; + private final String pluginId; + private final SessionInviteKind kind; + private final String payload; + + public InviteDetails( + String inviteId, + String fromInstanceId, + String fromDisplayName, + String toInstanceId, + String pluginId, + SessionInviteKind kind, + String payload + ) { + this.inviteId = inviteId; + this.fromInstanceId = fromInstanceId; + this.fromDisplayName = fromDisplayName; + this.toInstanceId = toInstanceId; + this.pluginId = pluginId; + this.kind = kind; + this.payload = payload; + } + + public String getInviteId() { + return inviteId; + } + + public String getFromInstanceId() { + return fromInstanceId; + } + + public String getFromDisplayName() { + return fromDisplayName; + } + + public String getToInstanceId() { + return toInstanceId; + } + + public String getPluginId() { + return pluginId; + } + + public SessionInviteKind getKind() { + return kind; + } + + public String getPayload() { + return payload; + } +} diff --git a/pitiupi/src/pitiupi/api/plugin/LegacyPluginWorkspace.java b/pitiupi/src/pitiupi/api/plugin/LegacyPluginWorkspace.java new file mode 100644 index 0000000..9710750 --- /dev/null +++ b/pitiupi/src/pitiupi/api/plugin/LegacyPluginWorkspace.java @@ -0,0 +1,78 @@ +package pitiupi.api.plugin; + +import pitiupi.application.invites.InviteInboxService; +import pitiupi.application.peeractions.PeerActionBroker; +import pitiupi.application.peeractions.PluginPeerActionsRegistrar; +import pitiupi.control.ConnectionManager; +import pitiupi.control.GuiManager; +import pitiupi.models.Plugin; + +import javax.swing.JComponent; + +/** + * Adaptador Onda C: expõe workspace e mantém {@link GuiManager} para plugins legados. + */ +public final class LegacyPluginWorkspace implements PluginWorkspace { + + private final String pluginId; + private final ConnectionManager connectionManager; + private final GuiManager guiManager; + private final PeerActionBroker peerActionBroker; + private final InviteInboxService inviteInboxService; + private final Plugin plugin; + private final PluginPeerActionsRegistrar registrar; + + public LegacyPluginWorkspace( + String pluginId, + Plugin plugin, + ConnectionManager connectionManager, + GuiManager guiManager, + PeerActionBroker peerActionBroker, + InviteInboxService inviteInboxService + ) { + this.pluginId = pluginId; + this.plugin = plugin; + this.connectionManager = connectionManager; + this.guiManager = guiManager; + this.peerActionBroker = peerActionBroker; + this.inviteInboxService = inviteInboxService; + this.registrar = new PluginPeerActionsRegistrar(peerActionBroker, pluginId); + peerActionBroker.registerPlugin(pluginId); + } + + public GuiManager guiManager() { + return guiManager; + } + + public String pluginId() { + return pluginId; + } + + @Override + public ConnectionManager connectionManager() { + return connectionManager; + } + + @Override + public PeerActionsRegistrar peerActions() { + return registrar; + } + + @Override + public void openOrFocusTab(String pluginInstanceId, String title, JComponent content) { + peerActionBroker.setActivePlugin(pluginInstanceId); + } + + @Override + public void setStatusMessage(String text) { + } + + @Override + public void deliverInviteAccepted(InviteDetails invite) { + plugin.onInviteAccepted(invite); + } + + public void bindInviteDelivery() { + inviteInboxService.setInviteHandler(pluginId, plugin::onInviteAccepted); + } +} diff --git a/pitiupi/src/pitiupi/api/plugin/PeerActionsRegistrar.java b/pitiupi/src/pitiupi/api/plugin/PeerActionsRegistrar.java new file mode 100644 index 0000000..1fe5076 --- /dev/null +++ b/pitiupi/src/pitiupi/api/plugin/PeerActionsRegistrar.java @@ -0,0 +1,11 @@ +package pitiupi.api.plugin; + +import pitiupi.application.peeractions.PeerAction; + +/** + * Registro de ações contextuais na lista de usuários (plugin da aba ativa). + */ +public interface PeerActionsRegistrar { + + void register(PeerAction action); +} diff --git a/pitiupi/src/pitiupi/api/plugin/PluginWorkspace.java b/pitiupi/src/pitiupi/api/plugin/PluginWorkspace.java new file mode 100644 index 0000000..2be9c51 --- /dev/null +++ b/pitiupi/src/pitiupi/api/plugin/PluginWorkspace.java @@ -0,0 +1,29 @@ +package pitiupi.api.plugin; + +import pitiupi.control.ConnectionManager; + +import javax.swing.JComponent; + +/** + * Superfície exposta ao plugin na aba central (evolução da API legada). + */ +public interface PluginWorkspace { + + ConnectionManager connectionManager(); + + PeerActionsRegistrar peerActions(); + + void openOrFocusTab(String pluginInstanceId, String title, JComponent content); + + default void openOrFocusTab(String pluginId) { + openOrFocusTab(pluginId, pluginId, null); + } + + default void openOrFocusTab(String pluginInstanceId, String title) { + openOrFocusTab(pluginInstanceId, title, null); + } + + void setStatusMessage(String text); + + void deliverInviteAccepted(InviteDetails invite); +} diff --git a/pitiupi/src/pitiupi/api/plugin/ShellPluginWorkspace.java b/pitiupi/src/pitiupi/api/plugin/ShellPluginWorkspace.java new file mode 100644 index 0000000..2871574 --- /dev/null +++ b/pitiupi/src/pitiupi/api/plugin/ShellPluginWorkspace.java @@ -0,0 +1,84 @@ +package pitiupi.api.plugin; + +import pitiupi.application.invites.InviteInboxService; +import pitiupi.application.peeractions.PeerActionBroker; +import pitiupi.application.peeractions.PluginPeerActionsRegistrar; +import pitiupi.control.ConnectionManager; +import pitiupi.control.GuiManager; +import pitiupi.models.Plugin; +import pitiupi.presentation.shell.ShellHost; +import pitiupi.presentation.shell.TabWorkspacePanel; + +import javax.swing.JComponent; + +/** + * Workspace com abas e barra de status (Onda D). + */ +public final class ShellPluginWorkspace implements PluginWorkspace { + + private final String pluginId; + private final Plugin plugin; + private final ConnectionManager connectionManager; + private final GuiManager guiManager; + private final TabWorkspacePanel tabWorkspace; + private final ShellHost shellHost; + private final PluginPeerActionsRegistrar registrar; + + public ShellPluginWorkspace( + String pluginId, + Plugin plugin, + ConnectionManager connectionManager, + GuiManager guiManager, + PeerActionBroker peerActionBroker, + InviteInboxService inviteInboxService, + TabWorkspacePanel tabWorkspace, + ShellHost shellHost + ) { + this.pluginId = pluginId; + this.plugin = plugin; + this.connectionManager = connectionManager; + this.guiManager = guiManager; + this.tabWorkspace = tabWorkspace; + this.shellHost = shellHost; + this.registrar = new PluginPeerActionsRegistrar(peerActionBroker, pluginId); + peerActionBroker.registerPlugin(pluginId); + peerActionBroker.registerPluginMetadata(pluginId, plugin.getDisplayName()); + inviteInboxService.setInviteHandler(pluginId, plugin::onInviteAccepted); + } + + public GuiManager guiManager() { + return guiManager; + } + + @Override + public ConnectionManager connectionManager() { + return connectionManager; + } + + @Override + public PeerActionsRegistrar peerActions() { + return registrar; + } + + @Override + public void openOrFocusTab(String pluginInstanceId) { + openOrFocusTab(pluginInstanceId, plugin.getDisplayName(), null); + } + + @Override + public void openOrFocusTab(String pluginInstanceId, String title, JComponent content) { + JComponent panel = content != null ? content : plugin.getShellWorkspaceContent(); + tabWorkspace.openOrFocusTab(pluginInstanceId, title, panel); + connectionManager.getPeerActionBroker().setActivePlugin(pluginInstanceId); + } + + @Override + public void setStatusMessage(String text) { + shellHost.setStatusMessage(text); + } + + @Override + public void deliverInviteAccepted(InviteDetails invite) { + plugin.onInviteAccepted(invite); + } +} diff --git a/pitiupi/src/pitiupi/application/activity/PeerActivityInbox.java b/pitiupi/src/pitiupi/application/activity/PeerActivityInbox.java new file mode 100644 index 0000000..0633cfa --- /dev/null +++ b/pitiupi/src/pitiupi/application/activity/PeerActivityInbox.java @@ -0,0 +1,70 @@ +package pitiupi.application.activity; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Pendências de atividade por peer (mensagem, tela, etc.) para badges na lista lateral. + */ +public final class PeerActivityInbox { + + public static final String BROADCAST_PEER_KEY = "__broadcast__"; + + private final Map> countByPeerAndAction = new ConcurrentHashMap<>(); + private Runnable onChanged = () -> { + }; + + public void setOnChanged(Runnable onChanged) { + this.onChanged = onChanged != null ? onChanged : () -> { + }; + } + + public void record(String peerInstanceId, String pluginId, String peerActionId) { + String peerKey = peerKey(peerInstanceId); + String actionKey = actionKey(pluginId, peerActionId); + countByPeerAndAction.computeIfAbsent(peerKey, k -> new ConcurrentHashMap<>()) + .merge(actionKey, 1, Integer::sum); + onChanged.run(); + } + + public void recordBroadcast(String pluginId, String peerActionId) { + record(BROADCAST_PEER_KEY, pluginId, peerActionId); + } + + public int totalForPeer(String peerInstanceId) { + return sum(countByPeerAndAction.getOrDefault(peerKey(peerInstanceId), Map.of())); + } + + public int countForAction(String peerInstanceId, String pluginId, String peerActionId) { + return countByPeerAndAction + .getOrDefault(peerKey(peerInstanceId), Map.of()) + .getOrDefault(actionKey(pluginId, peerActionId), 0); + } + + public void clearAction(String peerInstanceId, String pluginId, String peerActionId) { + Map actions = countByPeerAndAction.get(peerKey(peerInstanceId)); + if (actions != null) { + actions.remove(actionKey(pluginId, peerActionId)); + if (actions.isEmpty()) { + countByPeerAndAction.remove(peerKey(peerInstanceId)); + } + } + onChanged.run(); + } + + private static int sum(Map map) { + int total = 0; + for (Integer v : map.values()) { + total += v; + } + return total; + } + + private static String peerKey(String peerInstanceId) { + return peerInstanceId != null ? peerInstanceId : BROADCAST_PEER_KEY; + } + + private static String actionKey(String pluginId, String peerActionId) { + return pluginId + ":" + peerActionId; + } +} diff --git a/pitiupi/src/pitiupi/application/invites/InviteInboxService.java b/pitiupi/src/pitiupi/application/invites/InviteInboxService.java new file mode 100644 index 0000000..db0cb12 --- /dev/null +++ b/pitiupi/src/pitiupi/application/invites/InviteInboxService.java @@ -0,0 +1,98 @@ +package pitiupi.application.invites; + +import pitiupi.api.plugin.InviteDetails; +import pitiupi.control.HeartbeatManager; +import pitiupi.models.Messages.SessionInviteMessage; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * Caixa de convites recebidos; incrementa contador por remetente na lista. + */ +public final class InviteInboxService { + + private final HeartbeatManager heartbeatManager; + private final Map notificationCountByFromInstance = new ConcurrentHashMap<>(); + private final Map pendingByInviteId = new ConcurrentHashMap<>(); + private final Map> handlersByPluginId = new ConcurrentHashMap<>(); + private Runnable onNotificationsChanged = () -> { + }; + private volatile String lastAcceptedPluginId; + + public InviteInboxService(HeartbeatManager heartbeatManager) { + this.heartbeatManager = heartbeatManager; + } + + public void setOnNotificationsChanged(Runnable onNotificationsChanged) { + this.onNotificationsChanged = onNotificationsChanged != null ? onNotificationsChanged : () -> { + }; + } + + public void setInviteHandler(String pluginId, Consumer handler) { + handlersByPluginId.put(pluginId, handler); + } + + public void receive(SessionInviteMessage message) { + if (message.getFromInstanceId().equals(heartbeatManager.getInstanceId())) { + return; + } + String to = message.getToInstanceId(); + if (to != null && !to.isBlank() && !to.equals(heartbeatManager.getInstanceId())) { + return; + } + + pendingByInviteId.put(message.getInviteId(), message); + notificationCountByFromInstance.merge(message.getFromInstanceId(), 1, Integer::sum); + onNotificationsChanged.run(); + } + + public int getNotificationCount(String fromInstanceId) { + return notificationCountByFromInstance.getOrDefault(fromInstanceId, 0); + } + + public boolean acceptInvite(String inviteId) { + SessionInviteMessage message = pendingByInviteId.remove(inviteId); + if (message == null) { + return false; + } + decrementNotification(message.getFromInstanceId()); + InviteDetails details = new InviteDetails( + message.getInviteId(), + message.getFromInstanceId(), + message.getFromDisplayName(), + message.getToInstanceId(), + message.getPluginId(), + message.getKind(), + message.getPayload() + ); + lastAcceptedPluginId = message.getPluginId(); + Consumer handler = handlersByPluginId.get(message.getPluginId()); + if (handler != null) { + handler.accept(details); + } + onNotificationsChanged.run(); + return true; + } + + public String acceptFirstInviteFrom(String fromInstanceId) { + for (Map.Entry e : pendingByInviteId.entrySet()) { + if (e.getValue().getFromInstanceId().equals(fromInstanceId)) { + if (acceptInvite(e.getKey())) { + return lastAcceptedPluginId; + } + return null; + } + } + return null; + } + + public String getLastAcceptedPluginId() { + return lastAcceptedPluginId; + } + + private void decrementNotification(String fromInstanceId) { + notificationCountByFromInstance.computeIfPresent(fromInstanceId, (k, v) -> v <= 1 ? null : v - 1); + } +} diff --git a/pitiupi/src/pitiupi/application/invites/SessionInviteKind.java b/pitiupi/src/pitiupi/application/invites/SessionInviteKind.java new file mode 100644 index 0000000..637b00c --- /dev/null +++ b/pitiupi/src/pitiupi/application/invites/SessionInviteKind.java @@ -0,0 +1,8 @@ +package pitiupi.application.invites; + +public enum SessionInviteKind { + CHAT, + SCREEN, + PING, + CUSTOM +} diff --git a/pitiupi/src/pitiupi/application/messaging/MessageDispatcher.java b/pitiupi/src/pitiupi/application/messaging/MessageDispatcher.java new file mode 100644 index 0000000..67378ee --- /dev/null +++ b/pitiupi/src/pitiupi/application/messaging/MessageDispatcher.java @@ -0,0 +1,74 @@ +package pitiupi.application.messaging; + +import pitiupi.application.presence.UserPeerView; +import pitiupi.control.ConnectionManager; +import pitiupi.models.Messages.Message; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; + +/** + * Envio didático multicast / unicast / TCP para sala geral ou um peer. + */ +public final class MessageDispatcher { + + private MessageDispatcher() { + } + + public static int send( + ConnectionManager connectionManager, + Message message, + SendTarget target, + TransportMode mode + ) { + if (target instanceof SendTarget.BroadcastAll) { + return sendToAll(connectionManager, message, mode); + } + if (target instanceof SendTarget.SinglePeer single) { + return sendToOne(connectionManager, message, mode, single.address()) ? 1 : 0; + } + return 0; + } + + private static int sendToAll(ConnectionManager connectionManager, Message message, TransportMode mode) { + if (mode == TransportMode.MULTICAST_UDP) { + return connectionManager.sendUdpMulticast(message) ? 1 : 0; + } + List peers = onlinePeerAddresses(connectionManager); + int sent = 0; + for (InetAddress address : peers) { + if (sendToOne(connectionManager, message, mode, address)) { + sent++; + } + } + return sent; + } + + private static boolean sendToOne( + ConnectionManager connectionManager, + Message message, + TransportMode mode, + InetAddress address + ) { + if (address == null) { + return false; + } + return switch (mode) { + case MULTICAST_UDP -> connectionManager.sendUdpMulticast(message); + case UNICAST_UDP -> connectionManager.sendUdpUnicast(message, address); + case TCP -> connectionManager.sendTcp(message, address); + }; + } + + static List onlinePeerAddresses(ConnectionManager connectionManager) { + List addresses = new ArrayList<>(); + for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) { + if (view.isBroadcastTarget() || view.isSelf()) { + continue; + } + view.getInetAddress().ifPresent(addresses::add); + } + return addresses; + } +} diff --git a/pitiupi/src/pitiupi/application/messaging/MessagingDestinationUi.java b/pitiupi/src/pitiupi/application/messaging/MessagingDestinationUi.java new file mode 100644 index 0000000..fa9f486 --- /dev/null +++ b/pitiupi/src/pitiupi/application/messaging/MessagingDestinationUi.java @@ -0,0 +1,26 @@ +package pitiupi.application.messaging; + +/** + * Rótulos de destino na UI (chat, screencast, etc.). + */ +public final class MessagingDestinationUi { + + public static final String EVERYONE_IN_ROOM = "Todos na sala"; + public static final String SINGLE_PERSON = "Para uma pessoa (IP)"; + + private static final String PRIVATE_TCP_SUMMARY = + "Conversa só entre vocês (TCP privado)"; + + private MessagingDestinationUi() { + } + + public static String summarizeSendMode(boolean everyoneInRoom, TransportMode mode, boolean privateTcp) { + if (!everyoneInRoom && privateTcp) { + return PRIVATE_TCP_SUMMARY; + } + if (mode == null) { + return ""; + } + return mode.describeForUi(everyoneInRoom); + } +} diff --git a/pitiupi/src/pitiupi/application/messaging/PluginTransportCatalog.java b/pitiupi/src/pitiupi/application/messaging/PluginTransportCatalog.java new file mode 100644 index 0000000..ad34c85 --- /dev/null +++ b/pitiupi/src/pitiupi/application/messaging/PluginTransportCatalog.java @@ -0,0 +1,40 @@ +package pitiupi.application.messaging; + +import java.util.List; + +/** + * Transportes expostos na UI por tipo de plugin e destino (didático). + */ +public final class PluginTransportCatalog { + + public enum PluginKind { + CHAT, + SCREEN_CAST + } + + private PluginTransportCatalog() { + } + + public static List forBroadcast(PluginKind kind) { + return switch (kind) { + case CHAT -> List.of( + TransportMode.MULTICAST_UDP, + TransportMode.UNICAST_UDP, + TransportMode.TCP + ); + case SCREEN_CAST -> List.of( + TransportMode.MULTICAST_UDP, + TransportMode.UNICAST_UDP + ); + }; + } + + public static List forSinglePeer(PluginKind kind) { + return switch (kind) { + case CHAT, SCREEN_CAST -> List.of( + TransportMode.UNICAST_UDP, + TransportMode.TCP + ); + }; + } +} diff --git a/pitiupi/src/pitiupi/application/messaging/SendTarget.java b/pitiupi/src/pitiupi/application/messaging/SendTarget.java new file mode 100644 index 0000000..256b734 --- /dev/null +++ b/pitiupi/src/pitiupi/application/messaging/SendTarget.java @@ -0,0 +1,23 @@ +package pitiupi.application.messaging; + +import java.net.InetAddress; + +/** + * Destino de envio: sala geral ou um endereço IP. + */ +public sealed interface SendTarget permits SendTarget.BroadcastAll, SendTarget.SinglePeer { + + record BroadcastAll() implements SendTarget { + } + + record SinglePeer(InetAddress address) implements SendTarget { + } + + static SendTarget broadcastAll() { + return new BroadcastAll(); + } + + static SendTarget singlePeer(InetAddress address) { + return new SinglePeer(address); + } +} diff --git a/pitiupi/src/pitiupi/application/messaging/TransportMode.java b/pitiupi/src/pitiupi/application/messaging/TransportMode.java new file mode 100644 index 0000000..80c0ebf --- /dev/null +++ b/pitiupi/src/pitiupi/application/messaging/TransportMode.java @@ -0,0 +1,47 @@ +package pitiupi.application.messaging; + +/** + * Transportes expostos na UI didática dos plugins. + * {@link #describeForUi(boolean)} é o resumo didático ao lado do destino; + * {@link #getLabel()} e {@link #toString()} são os nomes curtos no combo (UDP unicast, TCP, …). + */ +public enum TransportMode { + MULTICAST_UDP("UDP multicast"), + UNICAST_UDP("UDP unicast"), + TCP("TCP"); + + private final String transcriptTag; + + TransportMode(String transcriptTag) { + this.transcriptTag = transcriptTag; + } + + /** Tag no histórico (ex.: {@code [UDP multicast]}). */ + public String getLabel() { + return transcriptTag; + } + + /** + * Texto do combo e resumos — linguagem simples com o termo técnico entre parênteses. + * + * @param everyoneInRoom {@code true} quando o destino é a sala (todos); {@code false} para um IP. + */ + public String describeForUi(boolean everyoneInRoom) { + return switch (this) { + case MULTICAST_UDP -> everyoneInRoom + ? "Todo mundo na sala de uma vez (UDP multicast)" + : "Para todos de uma vez (UDP multicast)"; + case UNICAST_UDP -> everyoneInRoom + ? "Uma cópia para cada pessoa na sala (UDP unicast)" + : "Direto para o IP da pessoa (UDP unicast)"; + case TCP -> everyoneInRoom + ? "Uma cópia por conexão para cada pessoa na sala (TCP)" + : "Direto para o IP por conexão (TCP)"; + }; + } + + @Override + public String toString() { + return transcriptTag; + } +} diff --git a/pitiupi/src/pitiupi/application/peeractions/GroupedPeerActions.java b/pitiupi/src/pitiupi/application/peeractions/GroupedPeerActions.java new file mode 100644 index 0000000..c2c648c --- /dev/null +++ b/pitiupi/src/pitiupi/application/peeractions/GroupedPeerActions.java @@ -0,0 +1,9 @@ +package pitiupi.application.peeractions; + +import java.util.List; + +/** + * Ações de um plugin para o menu contextual da lista lateral. + */ +public record GroupedPeerActions(String pluginId, String pluginDisplayName, List actions) { +} diff --git a/pitiupi/src/pitiupi/application/peeractions/PeerAction.java b/pitiupi/src/pitiupi/application/peeractions/PeerAction.java new file mode 100644 index 0000000..cd4efd2 --- /dev/null +++ b/pitiupi/src/pitiupi/application/peeractions/PeerAction.java @@ -0,0 +1,46 @@ +package pitiupi.application.peeractions; + +import java.util.EnumSet; +import java.util.Set; + +/** + * Ação exibida no menu contextual da lista de usuários. + */ +public final class PeerAction { + + private final String id; + private final String label; + private final Set targets; + private final PeerActionHandler handler; + + public PeerAction(String id, String label, Set targets, PeerActionHandler handler) { + this.id = id; + this.label = label; + this.targets = EnumSet.copyOf(targets); + this.handler = handler; + } + + public static PeerAction of(String id, String label, PeerActionTarget target, PeerActionHandler handler) { + return new PeerAction(id, label, Set.of(target), handler); + } + + public String getId() { + return id; + } + + public String getLabel() { + return label; + } + + public Set getTargets() { + return targets; + } + + public PeerActionHandler getHandler() { + return handler; + } + + public boolean supports(PeerActionTarget target) { + return targets.contains(target); + } +} diff --git a/pitiupi/src/pitiupi/application/peeractions/PeerActionBroker.java b/pitiupi/src/pitiupi/application/peeractions/PeerActionBroker.java new file mode 100644 index 0000000..ff064d9 --- /dev/null +++ b/pitiupi/src/pitiupi/application/peeractions/PeerActionBroker.java @@ -0,0 +1,84 @@ +package pitiupi.application.peeractions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Registro de peer actions por plugin; lista lateral pode exibir todos os plugins. + */ +public final class PeerActionBroker { + + private final Map> actionsByPlugin = new ConcurrentHashMap<>(); + private final Map displayNameByPlugin = new ConcurrentHashMap<>(); + private volatile String activePluginId; + + public void registerPlugin(String pluginId) { + actionsByPlugin.putIfAbsent(pluginId, new ArrayList<>()); + if (activePluginId == null) { + activePluginId = pluginId; + } + } + + public void registerPluginMetadata(String pluginId, String displayName) { + displayNameByPlugin.put(pluginId, displayName); + } + + public void setActivePlugin(String pluginId) { + this.activePluginId = pluginId; + } + + public String getActivePluginId() { + return activePluginId; + } + + public void registerAction(String pluginId, PeerAction action) { + actionsByPlugin.computeIfAbsent(pluginId, k -> new ArrayList<>()).add(action); + } + + public List getActionsFor(PeerActionTarget target) { + if (activePluginId == null) { + return List.of(); + } + return filterActions(actionsByPlugin.getOrDefault(activePluginId, List.of()), target); + } + + public List getAllActionsFor(PeerActionTarget target) { + List groups = new ArrayList<>(); + List pluginIds = new ArrayList<>(actionsByPlugin.keySet()); + pluginIds.sort(Comparator.comparing(id -> displayNameByPlugin.getOrDefault(id, id))); + + for (String pluginId : pluginIds) { + List filtered = filterActions(actionsByPlugin.getOrDefault(pluginId, List.of()), target); + if (!filtered.isEmpty()) { + groups.add(new GroupedPeerActions( + pluginId, + displayNameByPlugin.getOrDefault(pluginId, pluginId), + filtered + )); + } + } + return Collections.unmodifiableList(groups); + } + + private static List filterActions(List registered, PeerActionTarget target) { + List filtered = new ArrayList<>(); + for (PeerAction action : registered) { + if (action.supports(target)) { + filtered.add(action); + } + } + return Collections.unmodifiableList(filtered); + } + + public void clearPlugin(String pluginId) { + actionsByPlugin.remove(pluginId); + displayNameByPlugin.remove(pluginId); + if (pluginId.equals(activePluginId)) { + activePluginId = actionsByPlugin.keySet().stream().findFirst().orElse(null); + } + } +} diff --git a/pitiupi/src/pitiupi/application/peeractions/PeerActionHandler.java b/pitiupi/src/pitiupi/application/peeractions/PeerActionHandler.java new file mode 100644 index 0000000..a9cd2a8 --- /dev/null +++ b/pitiupi/src/pitiupi/application/peeractions/PeerActionHandler.java @@ -0,0 +1,7 @@ +package pitiupi.application.peeractions; + +@FunctionalInterface +public interface PeerActionHandler { + + void run(PeerTarget target); +} diff --git a/pitiupi/src/pitiupi/application/peeractions/PeerActionTarget.java b/pitiupi/src/pitiupi/application/peeractions/PeerActionTarget.java new file mode 100644 index 0000000..3435778 --- /dev/null +++ b/pitiupi/src/pitiupi/application/peeractions/PeerActionTarget.java @@ -0,0 +1,9 @@ +package pitiupi.application.peeractions; + +/** + * Público-alvo de uma peer action. + */ +public enum PeerActionTarget { + SINGLE_PEER, + ALL_PEERS +} diff --git a/pitiupi/src/pitiupi/application/peeractions/PeerTarget.java b/pitiupi/src/pitiupi/application/peeractions/PeerTarget.java new file mode 100644 index 0000000..36beef9 --- /dev/null +++ b/pitiupi/src/pitiupi/application/peeractions/PeerTarget.java @@ -0,0 +1,15 @@ +package pitiupi.application.peeractions; + +import pitiupi.application.presence.UserPeerView; + +/** + * Destino escolhido na lista (um peer ou Todos). + */ +public sealed interface PeerTarget permits PeerTarget.Single, PeerTarget.All { + + record Single(UserPeerView peer) implements PeerTarget { + } + + record All() implements PeerTarget { + } +} diff --git a/pitiupi/src/pitiupi/application/peeractions/PluginPeerActionsRegistrar.java b/pitiupi/src/pitiupi/application/peeractions/PluginPeerActionsRegistrar.java new file mode 100644 index 0000000..e843807 --- /dev/null +++ b/pitiupi/src/pitiupi/application/peeractions/PluginPeerActionsRegistrar.java @@ -0,0 +1,19 @@ +package pitiupi.application.peeractions; + +import pitiupi.api.plugin.PeerActionsRegistrar; + +public final class PluginPeerActionsRegistrar implements PeerActionsRegistrar { + + private final PeerActionBroker broker; + private final String pluginId; + + public PluginPeerActionsRegistrar(PeerActionBroker broker, String pluginId) { + this.broker = broker; + this.pluginId = pluginId; + } + + @Override + public void register(PeerAction action) { + broker.registerAction(pluginId, action); + } +} diff --git a/pitiupi/src/pitiupi/application/presence/PresenceService.java b/pitiupi/src/pitiupi/application/presence/PresenceService.java new file mode 100644 index 0000000..0e8ca0e --- /dev/null +++ b/pitiupi/src/pitiupi/application/presence/PresenceService.java @@ -0,0 +1,80 @@ +package pitiupi.application.presence; + +import pitiupi.application.activity.PeerActivityInbox; +import pitiupi.application.invites.InviteInboxService; +import pitiupi.control.HeartbeatManager; +import pitiupi.models.Messages.GoodbyeMessage; +import pitiupi.models.Messages.HeartbeatMessage; +import pitiupi.models.User; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; + +/** + * Orquestra presença na camada de aplicação (sem Swing). + */ +public final class PresenceService { + + private final HeartbeatManager heartbeatManager; + private final InviteInboxService inviteInboxService; + private final PeerActivityInbox peerActivityInbox; + + public PresenceService(HeartbeatManager heartbeatManager) { + this(heartbeatManager, null, null); + } + + public PresenceService(HeartbeatManager heartbeatManager, InviteInboxService inviteInboxService) { + this(heartbeatManager, inviteInboxService, null); + } + + public PresenceService( + HeartbeatManager heartbeatManager, + InviteInboxService inviteInboxService, + PeerActivityInbox peerActivityInbox + ) { + this.heartbeatManager = heartbeatManager; + this.inviteInboxService = inviteInboxService; + this.peerActivityInbox = peerActivityInbox; + } + + public void onHeartbeat(HeartbeatMessage msg, InetAddress from) { + heartbeatManager.receiveHeartbeat(msg, from); + } + + public void onGoodbye(GoodbyeMessage msg) { + heartbeatManager.receiveGoodbye(msg); + } + + public List listPeerViews() { + List views = new ArrayList<>(); + int broadcastActivity = peerActivityInbox != null + ? peerActivityInbox.totalForPeer(PeerActivityInbox.BROADCAST_PEER_KEY) + : 0; + views.add(new UserPeerView("Todos", null, null, broadcastActivity, false, true)); + String selfId = heartbeatManager.getInstanceId(); + for (User user : heartbeatManager.getPeers()) { + boolean isSelf = selfId.equals(user.getInstanceId()); + int notifications = 0; + if (inviteInboxService != null && user.getInstanceId() != null) { + notifications += inviteInboxService.getNotificationCount(user.getInstanceId()); + } + if (peerActivityInbox != null && user.getInstanceId() != null) { + notifications += peerActivityInbox.totalForPeer(user.getInstanceId()); + } + views.add(new UserPeerView( + user.getUserName(), + user.getInstanceId(), + user.getAddress(), + notifications, + isSelf, + false + )); + } + return views; + } + + public UserPeerView broadcastTarget() { + return UserPeerView.broadcastTarget(); + } +} diff --git a/pitiupi/src/pitiupi/application/presence/UserPeerView.java b/pitiupi/src/pitiupi/application/presence/UserPeerView.java new file mode 100644 index 0000000..610654f --- /dev/null +++ b/pitiupi/src/pitiupi/application/presence/UserPeerView.java @@ -0,0 +1,61 @@ +package pitiupi.application.presence; + +import java.net.InetAddress; +import java.util.Optional; + +/** + * DTO de peer para a UI (lista lateral e peer actions futuras). + */ +public final class UserPeerView { + + private final String displayName; + private final String instanceId; + private final InetAddress inetAddress; + private final int notificationCount; + private final boolean isSelf; + private final boolean isBroadcastTarget; + + public UserPeerView( + String displayName, + String instanceId, + InetAddress inetAddress, + int notificationCount, + boolean isSelf, + boolean isBroadcastTarget + ) { + this.displayName = displayName; + this.instanceId = instanceId; + this.inetAddress = inetAddress; + this.notificationCount = notificationCount; + this.isSelf = isSelf; + this.isBroadcastTarget = isBroadcastTarget; + } + + public String getDisplayName() { + return displayName; + } + + public String getInstanceId() { + return instanceId; + } + + public Optional getInetAddress() { + return Optional.ofNullable(inetAddress); + } + + public int getNotificationCount() { + return notificationCount; + } + + public boolean isSelf() { + return isSelf; + } + + public boolean isBroadcastTarget() { + return isBroadcastTarget; + } + + public static UserPeerView broadcastTarget() { + return new UserPeerView("Todos", null, null, 0, false, true); + } +} diff --git a/pitiupi/src/pitiupi/control/ConnectionManager.java b/pitiupi/src/pitiupi/control/ConnectionManager.java index 56a24d8..b7c7e0f 100644 --- a/pitiupi/src/pitiupi/control/ConnectionManager.java +++ b/pitiupi/src/pitiupi/control/ConnectionManager.java @@ -1,7 +1,12 @@ package pitiupi.control; -import pitiupi.GUI.MainWindow; +import pitiupi.application.activity.PeerActivityInbox; +import pitiupi.application.invites.InviteInboxService; +import pitiupi.application.peeractions.PeerActionBroker; +import pitiupi.application.presence.PresenceService; +import pitiupi.models.Messages.SessionInviteMessage; import pitiupi.control.plugins.PluginManager; +import pitiupi.models.Messages.GoodbyeMessage; import pitiupi.models.Messages.HeartbeatMessage; import pitiupi.models.Messages.Message; import pitiupi.net.tcp.SocketTCP; @@ -9,6 +14,7 @@ import pitiupi.net.tcp.TcpConnection; import pitiupi.net.udp.SocketUDP; import java.net.InetAddress; +import java.util.function.Consumer; public class ConnectionManager { private final String pitiupiIdentifier; @@ -16,13 +22,20 @@ public class ConnectionManager { private final PluginManager pluginManager; private final HeartbeatManager heartbeatManager; + private final PresenceService presenceService; + private final PeerActionBroker peerActionBroker; + private final InviteInboxService inviteInboxService; + private final PeerActivityInbox peerActivityInbox; private SocketTCP socketTCP; private SocketUDP socketUDP; private String username; private int port; + private volatile boolean shutdownComplete; + private Consumer inviteAcceptedUiListener = pluginId -> { + }; - public ConnectionManager(MainWindow main, String username, PluginManager pluginManager, int port) { + public ConnectionManager(String username, PluginManager pluginManager, int port) { this.pluginManager = pluginManager; this.username = username; @@ -33,7 +46,64 @@ public class ConnectionManager { reconnect(); heartbeatManager = new HeartbeatManager(this); + peerActionBroker = new PeerActionBroker(); + inviteInboxService = new InviteInboxService(heartbeatManager); + peerActivityInbox = new PeerActivityInbox(); + presenceService = new PresenceService(heartbeatManager, inviteInboxService, peerActivityInbox); + inviteInboxService.setOnNotificationsChanged(heartbeatManager::notifyPeerListeners); + peerActivityInbox.setOnChanged(heartbeatManager::notifyPeerListeners); heartbeatManager.start(); + + Runtime.getRuntime().addShutdownHook(new Thread(this::shutdownGracefully, "pitiupi-goodbye")); + } + + public PresenceService getPresenceService() { + return presenceService; + } + + public PeerActionBroker getPeerActionBroker() { + return peerActionBroker; + } + + public InviteInboxService getInviteInboxService() { + return inviteInboxService; + } + + public PeerActivityInbox getPeerActivityInbox() { + return peerActivityInbox; + } + + public void setInviteAcceptedUiListener(Consumer listener) { + this.inviteAcceptedUiListener = listener != null ? listener : pluginId -> { + }; + } + + public void notifyInviteAcceptedForUi(String pluginId) { + if (pluginId != null) { + inviteAcceptedUiListener.accept(pluginId); + } + } + + public HeartbeatManager getHeartbeatManager() { + return heartbeatManager; + } + + public void receiveSessionInvite(SessionInviteMessage message) { + inviteInboxService.receive(message); + } + + public void shutdownGracefully() { + if (shutdownComplete) { + return; + } + shutdownComplete = true; + heartbeatManager.sendGoodbyeAndStop(); + if (socketUDP != null) { + socketUDP.close(); + } + if (socketTCP != null) { + socketTCP.close(); + } } public TcpConnection connectPluginTcp(InetAddress destinationAdress, String senderId, String receiverId) { @@ -72,7 +142,11 @@ public class ConnectionManager { } public void receiveHeartbeat(HeartbeatMessage message, InetAddress destinationAddress) { - heartbeatManager.receiveHeartbeat(message, destinationAddress); + presenceService.onHeartbeat(message, destinationAddress); + } + + public void receiveGoodbye(GoodbyeMessage message) { + presenceService.onGoodbye(message); } public void setPort(int port) { diff --git a/pitiupi/src/pitiupi/control/GuiManager.java b/pitiupi/src/pitiupi/control/GuiManager.java index b2a6f63..28fe68a 100644 --- a/pitiupi/src/pitiupi/control/GuiManager.java +++ b/pitiupi/src/pitiupi/control/GuiManager.java @@ -1,8 +1,7 @@ package pitiupi.control; -import pitiupi.GUI.MainWindow; -import pitiupi.GUI.MenuBar; -import pitiupi.GUI.OnlineUsersPanel; +import pitiupi.presentation.shell.NetworkUsersPanel; +import pitiupi.presentation.shell.ShellHost; import pitiupi.models.Menus.*; import pitiupi.models.Menus.MulticastMenus.MulticastActionMenu; import pitiupi.models.Menus.MulticastMenus.MulticastMenu; @@ -16,39 +15,25 @@ import java.util.logging.Level; import java.util.logging.Logger; public class GuiManager { - private MainWindow mainWindow; - private MenuBar menuBar; - private OnlineUsersPanel onlineUsersPanel; + private final ShellHost shellHost; + private final NetworkUsersPanel networkUsersPanel; private Logger logger; - public GuiManager(MainWindow mainWindow, MenuBar menuBar, OnlineUsersPanel onlineUsersPanel) { - this.mainWindow = mainWindow; - this.menuBar = menuBar; - this.onlineUsersPanel = onlineUsersPanel; + public GuiManager(ShellHost shellHost, javax.swing.JMenuBar menuBar, NetworkUsersPanel networkUsersPanel) { + this.shellHost = shellHost; + this.networkUsersPanel = networkUsersPanel; this.logger = Logger.getLogger(GuiManager.class.getName()); } public void createMenu(MenuCategory category, Menu menu) { - System.out.println( - menu.getClass().getSuperclass().getSuperclass() - ); - - System.out.println( - MulticastMenu.class - ); - - System.out.println( - menu.getClass().getSuperclass().getSuperclass() - == MulticastMenu.class - ); try { JMenuItem newMenu = buildMenu(menu); if (menu instanceof MulticastMenu) { - menuBar.addPluginMenu(category, newMenu); + // Onda D: ações multicast migram para peer actions / abas (Onda E). } if (menu instanceof UnicastMenu) { - onlineUsersPanel.addUnicastMenu(category, newMenu); + networkUsersPanel.addUnicastMenu(category, newMenu); } } catch (IllegalArgumentException e) { @@ -86,7 +71,7 @@ public class GuiManager { if (menu instanceof UnicastActionMenu unicastActionMenu) { JMenuItem newMenu = new JMenuItem(menu.getText()); - onlineUsersPanel.initUnicastMenu(newMenu, unicastActionMenu::onClick); + networkUsersPanel.initUnicastMenu(newMenu, unicastActionMenu::onClick); return newMenu; } diff --git a/pitiupi/src/pitiupi/control/HeartbeatManager.java b/pitiupi/src/pitiupi/control/HeartbeatManager.java index 28d58a6..7b295f1 100644 --- a/pitiupi/src/pitiupi/control/HeartbeatManager.java +++ b/pitiupi/src/pitiupi/control/HeartbeatManager.java @@ -11,6 +11,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import pitiupi.models.User; +import pitiupi.models.Messages.GoodbyeMessage; import pitiupi.models.Messages.HeartbeatMessage; import java.util.UUID; /** @@ -40,6 +41,17 @@ public class HeartbeatManager { this.connectionManager = connectionManager; } + /** + * Testes unitários sem rede (não chamar {@link #start()}). + */ + public static HeartbeatManager createForTesting() { + return new HeartbeatManager(null); + } + + public String getInstanceId() { + return instanceId; + } + /** * Inicia o mecanismo de heartbeat. */ @@ -59,10 +71,31 @@ public class HeartbeatManager { } private void announce() { + if (connectionManager == null) { + return; + } HeartbeatMessage msg = new HeartbeatMessage(connectionManager.getUserName(), instanceId); connectionManager.sendUdpMulticast(msg); } + public void sendGoodbyeAndStop() { + if (connectionManager != null) { + GoodbyeMessage msg = new GoodbyeMessage(connectionManager.getUserName(), instanceId); + connectionManager.sendUdpMulticast(msg); + } + stop(); + } + + public void receiveGoodbye(GoodbyeMessage msg) { + if (msg.getInstanceId().equals(instanceId)) { + return; + } + boolean removed = peers.remove(msg.getInstanceId()) != null; + if (removed) { + notifyListeners(); + } + } + // Chamado pelo Socket quando um pacote é identificado como heartbeat /** * Processa uma mensagem de heartbeat recebida de outro usuário. @@ -85,7 +118,7 @@ public class HeartbeatManager { String key = msg.getInstanceId(); boolean isNew = !peers.containsKey(key); - User peer = peers.computeIfAbsent(key, k -> new User(msg.getUserName(), from, msg.getTimestamp())); + User peer = peers.computeIfAbsent(key, k -> new User(msg.getUserName(), from, msg.getTimestamp(), msg.getInstanceId())); peer.touch(System.currentTimeMillis()); if (isNew) { @@ -138,4 +171,11 @@ public class HeartbeatManager { public List getPeers() { return new ArrayList<>(peers.values()); } + + /** + * Re-notifica listeners (ex.: contadores de convite na lista). + */ + public void notifyPeerListeners() { + notifyListeners(); + } } diff --git a/pitiupi/src/pitiupi/control/plugins/PluginManager.java b/pitiupi/src/pitiupi/control/plugins/PluginManager.java index 94cabf3..200129b 100644 --- a/pitiupi/src/pitiupi/control/plugins/PluginManager.java +++ b/pitiupi/src/pitiupi/control/plugins/PluginManager.java @@ -1,12 +1,16 @@ package pitiupi.control.plugins; import pitiupi.Main; +import pitiupi.api.plugin.LegacyPluginWorkspace; +import pitiupi.api.plugin.PluginWorkspace; +import pitiupi.models.Plugin; import pitiupi.control.ConnectionManager; import pitiupi.control.GuiManager; +import pitiupi.errors.ErrorCode; import pitiupi.models.Messages.Message; +import pitiupi.platform.PitiupiHome; +import pitiupi.plugins.store.PluginMetadataWriter; import pitiupi.net.tcp.TcpConnection; -import pitiupi.models.Plugin; - import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; @@ -15,6 +19,7 @@ import java.net.URLClassLoader; import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.function.Function; import java.util.ServiceLoader; import java.util.logging.Level; import java.util.logging.Logger; @@ -24,12 +29,14 @@ public class PluginManager { private final HashSet loadedJars; private final Logger logger; + private final PluginMetadataWriter metadataWriter; public PluginManager() { this.loadedPlugins = new ArrayList<>(); this.loadedJars = new HashSet<>(); logger = Logger.getLogger(getClass().getName()); + this.metadataWriter = new PluginMetadataWriter(); } public void handleMessage(byte[] msg) { @@ -90,8 +97,39 @@ public class PluginManager { plugin.onSocketClosed(connection); } + public List getLoadedPlugins() { + return loadedPlugins.stream().map(LoadedPlugin::getPlugin).toList(); + } + + public void notifyShellTabClosed(String pluginId) { + if (pluginId == null || pluginId.isBlank()) { + return; + } + Plugin plugin = getPlugin(pluginId); + if (plugin != null) { + plugin.onShellTabClosed(); + } + } + + /** @deprecated use {@link #loadPlugins(ConnectionManager, GuiManager, Function)} com {@link pitiupi.api.plugin.ShellPluginWorkspace} */ + @Deprecated public void loadPlugins(ConnectionManager connectionManager, GuiManager guiManager) { - File pluginDir = new File("plugins"); + loadPlugins(connectionManager, guiManager, plugin -> new LegacyPluginWorkspace( + plugin.getIdentifier(), + plugin, + connectionManager, + guiManager, + connectionManager.getPeerActionBroker(), + connectionManager.getInviteInboxService() + )); + } + + public void loadPlugins( + ConnectionManager connectionManager, + GuiManager guiManager, + Function workspaceFactory + ) { + File pluginDir = PitiupiHome.get().pluginsDirectory().toFile(); File[] jarFiles = pluginDir.listFiles((dir, name) -> name.endsWith(".jar")); if (jarFiles == null || jarFiles.length == 0) { @@ -118,13 +156,20 @@ public class PluginManager { for (Plugin plugin : serviceLoader) { LoadedPlugin loadedPlugin = new LoadedPlugin(plugin, classLoader); - plugin.createPlugin(connectionManager, guiManager); + PluginWorkspace workspace = workspaceFactory.apply(plugin); + plugin.createPlugin(connectionManager, workspace); loadedPlugins.add(loadedPlugin); loadedJars.add(file.getName()); + try { + metadataWriter.write(plugin, file.toPath()); + } catch (Exception metaEx) { + logger.log(Level.WARNING, "Failed to write plugin metadata for " + file.getName(), metaEx); + } } } catch (Exception e) { - logger.log(Level.WARNING, "Failed to load plugin " + file.getName(), e); + logger.log(Level.WARNING, "Failed to load plugin " + file.getName() + ": " + + ErrorCode.PLUGIN_LOAD_FAILED.getUserMessage(), e); } } } diff --git a/pitiupi/src/pitiupi/errors/ErrorCode.java b/pitiupi/src/pitiupi/errors/ErrorCode.java new file mode 100644 index 0000000..aa747ea --- /dev/null +++ b/pitiupi/src/pitiupi/errors/ErrorCode.java @@ -0,0 +1,21 @@ +package pitiupi.errors; + +/** + * Códigos de erro expostos à UI (mensagens em português). + */ +public enum ErrorCode { + PLUGIN_INSTALL_FAILED("Não foi possível instalar o plugin."), + PLUGIN_REMOVE_FAILED("Não foi possível remover o plugin."), + PLUGIN_LOAD_FAILED("Falha ao carregar um plugin."), + INVALID_PATH("Caminho de arquivo inválido."); + + private final String userMessage; + + ErrorCode(String userMessage) { + this.userMessage = userMessage; + } + + public String getUserMessage() { + return userMessage; + } +} diff --git a/pitiupi/src/pitiupi/errors/PitiupiException.java b/pitiupi/src/pitiupi/errors/PitiupiException.java new file mode 100644 index 0000000..922ad0c --- /dev/null +++ b/pitiupi/src/pitiupi/errors/PitiupiException.java @@ -0,0 +1,32 @@ +package pitiupi.errors; + +/** + * Exceção de domínio com código para a interface. + */ +public final class PitiupiException extends Exception { + + private final ErrorCode code; + + public PitiupiException(ErrorCode code) { + super(code.getUserMessage()); + this.code = code; + } + + public PitiupiException(ErrorCode code, String detail) { + super(detail); + this.code = code; + } + + public PitiupiException(ErrorCode code, Throwable cause) { + super(code.getUserMessage(), cause); + this.code = code; + } + + public ErrorCode getCode() { + return code; + } + + public String getUserMessage() { + return code.getUserMessage(); + } +} diff --git a/pitiupi/src/pitiupi/models/Messages/GoodbyeMessage.java b/pitiupi/src/pitiupi/models/Messages/GoodbyeMessage.java new file mode 100644 index 0000000..e9ba399 --- /dev/null +++ b/pitiupi/src/pitiupi/models/Messages/GoodbyeMessage.java @@ -0,0 +1,26 @@ +package pitiupi.models.Messages; + +/** + * Mensagem de saída graciosa enviada em multicast ao fechar a aplicação. + */ +public class GoodbyeMessage extends Message { + + private static final long serialVersionUID = 1L; + + private final String userName; + private final String instanceId; + + public GoodbyeMessage(String userName, String instanceId) { + super(true); + this.userName = userName; + this.instanceId = instanceId; + } + + public String getUserName() { + return userName; + } + + public String getInstanceId() { + return instanceId; + } +} diff --git a/pitiupi/src/pitiupi/models/Messages/SessionInviteMessage.java b/pitiupi/src/pitiupi/models/Messages/SessionInviteMessage.java new file mode 100644 index 0000000..a814aa5 --- /dev/null +++ b/pitiupi/src/pitiupi/models/Messages/SessionInviteMessage.java @@ -0,0 +1,66 @@ +package pitiupi.models.Messages; + +import pitiupi.application.invites.SessionInviteKind; + +/** + * Convite de sessão (chat, tela, etc.) sem abrir UI no peer remoto. + */ +public class SessionInviteMessage extends Message { + + private static final long serialVersionUID = 1L; + + private final String inviteId; + private final String fromInstanceId; + private final String fromDisplayName; + private final String toInstanceId; + private final String pluginId; + private final SessionInviteKind kind; + private final String payload; + + public SessionInviteMessage( + String inviteId, + String fromInstanceId, + String fromDisplayName, + String toInstanceId, + String pluginId, + SessionInviteKind kind, + String payload + ) { + super(true); + this.inviteId = inviteId; + this.fromInstanceId = fromInstanceId; + this.fromDisplayName = fromDisplayName; + this.toInstanceId = toInstanceId; + this.pluginId = pluginId; + this.kind = kind; + this.payload = payload; + } + + public String getInviteId() { + return inviteId; + } + + public String getFromInstanceId() { + return fromInstanceId; + } + + public String getFromDisplayName() { + return fromDisplayName; + } + + public String getToInstanceId() { + return toInstanceId; + } + + public String getPluginId() { + return pluginId; + } + + public SessionInviteKind getKind() { + return kind; + } + + public String getPayload() { + return payload; + } +} diff --git a/pitiupi/src/pitiupi/models/Plugin.java b/pitiupi/src/pitiupi/models/Plugin.java index ffd898c..33737dc 100644 --- a/pitiupi/src/pitiupi/models/Plugin.java +++ b/pitiupi/src/pitiupi/models/Plugin.java @@ -4,11 +4,16 @@ */ package pitiupi.models; +import pitiupi.api.plugin.InviteDetails; +import pitiupi.api.plugin.PeerActionsRegistrar; +import pitiupi.api.plugin.PluginWorkspace; +import pitiupi.api.plugin.ShellPluginWorkspace; import pitiupi.control.ConnectionManager; import pitiupi.control.GuiManager; import pitiupi.models.Messages.Message; import pitiupi.net.tcp.TcpConnection; +import javax.swing.JComponent; import java.util.List; /** @@ -20,11 +25,34 @@ import java.util.List; * @author flavio */ public abstract class Plugin { + + private JComponent shellWorkspaceContent; + public abstract String getName(); + + /** + * Nome amigável na UI (galeria, abas). Padrão: {@link #getName()}. + */ + public String getDisplayName() { + return getName(); + } + public abstract String getAuthor(); public abstract String getVersion(); public abstract String getIdentifier(); + /** + * Registra o painel principal da aba central (shell). Não abre a aba — use + * {@link pitiupi.api.plugin.PluginWorkspace#openOrFocusTab} na galeria ou peer actions. + */ + protected final void bindShellWorkspaceContent(JComponent content) { + this.shellWorkspaceContent = content; + } + + public final JComponent getShellWorkspaceContent() { + return shellWorkspaceContent; + } + /** * Inicializa o plugin. * @@ -35,6 +63,28 @@ public abstract class Plugin { */ public abstract void createPlugin(ConnectionManager connectionManager, GuiManager guiManager); + /** + * Inicialização via workspace (Onda C+). O adaptador chama o legado e {@link #installPeerActions}. + */ + public void createPlugin(ConnectionManager connectionManager, PluginWorkspace workspace) { + if (workspace instanceof ShellPluginWorkspace shell) { + createPlugin(connectionManager, shell.guiManager()); + installPeerActions(workspace.peerActions()); + } + } + + public void installPeerActions(PeerActionsRegistrar registrar) { + } + + public void onInviteAccepted(InviteDetails invite) { + } + + /** + * Aba central do plugin fechada na shell (× na aba superior). Use para parar timers, captura, etc. + */ + public void onShellTabClosed() { + } + /** * Retorna uma mensagem utilizada pelo plugin. * diff --git a/pitiupi/src/pitiupi/models/User.java b/pitiupi/src/pitiupi/models/User.java index e7d7a6f..13c6ce8 100644 --- a/pitiupi/src/pitiupi/models/User.java +++ b/pitiupi/src/pitiupi/models/User.java @@ -12,14 +12,34 @@ import java.net.InetAddress; */ public class User { + public static final String BROADCAST_INSTANCE_ID = "__broadcast__"; + private final String userName; private final InetAddress address; private volatile long lastSeen; + private final String instanceId; public User(String userName, InetAddress address, long lastSeen) { + this(userName, address, lastSeen, null); + } + + public User(String userName, InetAddress address, long lastSeen, String instanceId) { this.userName = userName; this.address = address; this.lastSeen = lastSeen; + this.instanceId = instanceId; + } + + public static User broadcastAll() { + return new User("Todos", null, 0, BROADCAST_INSTANCE_ID); + } + + public boolean isBroadcastTarget() { + return BROADCAST_INSTANCE_ID.equals(instanceId); + } + + public String getInstanceId() { + return instanceId; } public String getUserName() { @@ -45,6 +65,9 @@ public class User { @Override public String toString() { + if (isBroadcastTarget() || address == null) { + return userName; + } return userName + " (" + address.getHostAddress() + ")"; } } diff --git a/pitiupi/src/pitiupi/net/udp/SocketUDP.java b/pitiupi/src/pitiupi/net/udp/SocketUDP.java index 1f39746..fee201f 100644 --- a/pitiupi/src/pitiupi/net/udp/SocketUDP.java +++ b/pitiupi/src/pitiupi/net/udp/SocketUDP.java @@ -15,7 +15,9 @@ import java.util.logging.Logger; import pitiupi.control.ConnectionManager; import pitiupi.control.plugins.PluginManager; +import pitiupi.models.Messages.GoodbyeMessage; import pitiupi.models.Messages.HeartbeatMessage; +import pitiupi.models.Messages.SessionInviteMessage; import pitiupi.models.Messages.Message; /** @@ -151,11 +153,19 @@ public class SocketUDP extends Thread { throw ex; } - HeartbeatMessage heartbeat = tryParseHeartbeat(msgPacket.getData()); - if (heartbeat != null) { + Object coreMessage = tryParseCoreUdpMessage(msgPacket.getData()); + if (coreMessage instanceof HeartbeatMessage heartbeat) { connectionManager.receiveHeartbeat(heartbeat, msgPacket.getAddress()); continue; } + if (coreMessage instanceof GoodbyeMessage goodbye) { + connectionManager.receiveGoodbye(goodbye); + continue; + } + if (coreMessage instanceof SessionInviteMessage invite) { + connectionManager.receiveSessionInvite(invite); + continue; + } pluginManager.handleMessage(msgPacket.getData()); } @@ -168,14 +178,19 @@ public class SocketUDP extends Thread { * @return a mensagem de heartbeat caso os dados correspondam a uma; * {@code null} caso contrário. */ - private HeartbeatMessage tryParseHeartbeat(byte[] data) { + private Object tryParseCoreUdpMessage(byte[] data) { try { ByteArrayInputStream bais = new ByteArrayInputStream(data); ObjectInput in = new ObjectInputStream(bais); Object obj = in.readObject(); - return (obj instanceof HeartbeatMessage) ? (HeartbeatMessage) obj : null; + if (obj instanceof HeartbeatMessage + || obj instanceof GoodbyeMessage + || obj instanceof SessionInviteMessage) { + return obj; + } + return null; } catch (IOException | ClassNotFoundException | ClassCastException ex) { - return null; + return null; } } diff --git a/pitiupi/src/pitiupi/platform/ConfigLoader.java b/pitiupi/src/pitiupi/platform/ConfigLoader.java new file mode 100644 index 0000000..8fb7438 --- /dev/null +++ b/pitiupi/src/pitiupi/platform/ConfigLoader.java @@ -0,0 +1,68 @@ +package pitiupi.platform; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +/** + * Leitura e gravação de {@code config.properties} na raiz do PITIUPI_HOME. + */ +public final class ConfigLoader { + + private final Path configFile; + private final Properties properties = new Properties(); + + public ConfigLoader(Path homeRoot) { + this.configFile = homeRoot.resolve("config.properties"); + } + + public void loadOrCreateDefaults() throws IOException { + if (Files.isRegularFile(configFile)) { + try (InputStream in = Files.newInputStream(configFile)) { + properties.load(in); + } + } else { + properties.setProperty("migration.cwdPluginsPrompt", "true"); + store(); + } + } + + public void store() throws IOException { + Files.createDirectories(configFile.getParent()); + try (OutputStream out = Files.newOutputStream(configFile)) { + properties.store(out, "Pitiupi configuration"); + } + } + + public boolean getBoolean(String key, boolean defaultValue) { + String v = properties.getProperty(key); + if (v == null) { + return defaultValue; + } + return "true".equalsIgnoreCase(v.trim()); + } + + public void setBoolean(String key, boolean value) throws IOException { + properties.setProperty(key, value ? "true" : "false"); + store(); + } + + public boolean isMigrationCwdPluginsDone() { + return getBoolean("migration.cwdPluginsDone", false); + } + + public void setMigrationCwdPluginsDone(boolean done) throws IOException { + setBoolean("migration.cwdPluginsDone", done); + } + + public boolean isMigrationCwdPluginsPromptEnabled() { + return getBoolean("migration.cwdPluginsPrompt", true); + } + + public void setMigrationCwdPluginsPromptEnabled(boolean enabled) throws IOException { + setBoolean("migration.cwdPluginsPrompt", enabled); + } +} diff --git a/pitiupi/src/pitiupi/platform/LegacyPluginMigration.java b/pitiupi/src/pitiupi/platform/LegacyPluginMigration.java new file mode 100644 index 0000000..c58c05b --- /dev/null +++ b/pitiupi/src/pitiupi/platform/LegacyPluginMigration.java @@ -0,0 +1,93 @@ +package pitiupi.platform; + +import javax.swing.JOptionPane; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.stream.Stream; + +/** + * Migração única de {@code ./plugins} (CWD legado) para {@code PITIUPI_HOME/plugins}. + */ +public final class LegacyPluginMigration { + + private LegacyPluginMigration() { + } + + public static void maybeMigrateFromLegacyCwd() throws IOException { + PitiupiHome home = PitiupiHome.get(); + ConfigLoader config = home.config(); + + if (config.isMigrationCwdPluginsDone()) { + return; + } + + Path legacyDir = Path.of(System.getProperty("user.dir"), "plugins").normalize(); + Path targetDir = home.pluginsDirectory(); + + boolean legacyHasJars = hasJarFiles(legacyDir); + boolean targetEmpty = !hasJarFiles(targetDir); + + if (!legacyHasJars || !targetEmpty) { + config.setMigrationCwdPluginsDone(true); + return; + } + + boolean copy = false; + if (GraphicsEnvironmentHelper.isHeadless() || !config.isMigrationCwdPluginsPromptEnabled()) { + copy = false; + } else { + int choice = JOptionPane.showConfirmDialog( + null, + "Encontramos plugins na pasta antiga (" + legacyDir + ").\n" + + "Copiar para " + targetDir + "?", + "Migração de plugins", + JOptionPane.YES_NO_OPTION + ); + copy = choice == JOptionPane.YES_OPTION; + } + + if (copy) { + copyJarsAndMeta(legacyDir, targetDir); + } else { + config.setMigrationCwdPluginsPromptEnabled(false); + } + + config.setMigrationCwdPluginsDone(true); + } + + private static boolean hasJarFiles(Path dir) throws IOException { + if (!Files.isDirectory(dir)) { + return false; + } + try (Stream s = Files.list(dir)) { + return s.anyMatch(p -> p.getFileName().toString().endsWith(".jar")); + } + } + + private static void copyJarsAndMeta(Path from, Path to) throws IOException { + Files.createDirectories(to); + try (Stream s = Files.list(from)) { + s.filter(p -> { + String name = p.getFileName().toString(); + return name.endsWith(".jar") || name.endsWith(".plugin.meta"); + }).forEach(src -> { + try { + Files.copy(src, to.resolve(src.getFileName()), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + } + + /** + * Evita referência direta a java.awt.GraphicsEnvironment em testes headless. + */ + static final class GraphicsEnvironmentHelper { + static boolean isHeadless() { + return java.awt.GraphicsEnvironment.isHeadless(); + } + } +} diff --git a/pitiupi/src/pitiupi/platform/PitiupiHome.java b/pitiupi/src/pitiupi/platform/PitiupiHome.java new file mode 100644 index 0000000..35d997d --- /dev/null +++ b/pitiupi/src/pitiupi/platform/PitiupiHome.java @@ -0,0 +1,88 @@ +package pitiupi.platform; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Raiz de dados do usuário ({@code PITIUPI_HOME} ou {@code ~/pitiupi}). + */ +public final class PitiupiHome { + + private static final PitiupiHome DEFAULT = new PitiupiHome(resolveRoot()); + + private final Path root; + private ConfigLoader configLoader; + + private PitiupiHome(Path root) { + this.root = root.normalize().toAbsolutePath(); + } + + public static PitiupiHome get() { + return DEFAULT; + } + + /** + * Instância com raiz explícita (testes e ferramentas). + */ + public static PitiupiHome open(Path root) { + return new PitiupiHome(root); + } + + public static Path resolveRoot() { + String env = System.getenv("PITIUPI_HOME"); + if (env != null && !env.isBlank()) { + return expandUserHome(env.trim()); + } + return Paths.get(System.getProperty("user.home"), "pitiupi"); + } + + private static Path expandUserHome(String path) { + if (path.startsWith("~/")) { + return Paths.get(System.getProperty("user.home"), path.substring(2)); + } + if ("~".equals(path)) { + return Paths.get(System.getProperty("user.home")); + } + return Paths.get(path); + } + + public Path rootDirectory() { + return root; + } + + public Path pluginsDirectory() { + return root.resolve("plugins"); + } + + public Path cacheDirectory() { + return root.resolve("cache"); + } + + public Path logsDirectory() { + return root.resolve("logs"); + } + + public Path configFile() { + return root.resolve("config.properties"); + } + + public ConfigLoader config() throws IOException { + if (configLoader == null) { + configLoader = new ConfigLoader(root); + configLoader.loadOrCreateDefaults(); + } + return configLoader; + } + + /** + * Cria diretórios padrão se ausentes. + */ + public void ensureLayout() throws IOException { + Files.createDirectories(pluginsDirectory()); + Files.createDirectories(cacheDirectory()); + Files.createDirectories(logsDirectory()); + config(); + } +} diff --git a/pitiupi/src/pitiupi/plugins/store/InstalledPluginRef.java b/pitiupi/src/pitiupi/plugins/store/InstalledPluginRef.java new file mode 100644 index 0000000..761a2de --- /dev/null +++ b/pitiupi/src/pitiupi/plugins/store/InstalledPluginRef.java @@ -0,0 +1,71 @@ +package pitiupi.plugins.store; + +import java.nio.file.Path; +import java.util.Optional; + +/** + * Referência a um JAR instalado e metadados opcionais. + */ +public final class InstalledPluginRef { + + private final Path jarPath; + private final String fileName; + private final Optional arquivoId; + private final Optional version; + private final Optional name; + private final Optional displayName; + private final Optional identifier; + private final Optional author; + + public InstalledPluginRef( + Path jarPath, + String fileName, + Optional arquivoId, + Optional version, + Optional name, + Optional displayName, + Optional identifier, + Optional author + ) { + this.jarPath = jarPath; + this.fileName = fileName; + this.arquivoId = arquivoId; + this.version = version; + this.name = name; + this.displayName = displayName; + this.identifier = identifier; + this.author = author; + } + + public Path getJarPath() { + return jarPath; + } + + public String getFileName() { + return fileName; + } + + public Optional getArquivoId() { + return arquivoId; + } + + public Optional getVersion() { + return version; + } + + public Optional getName() { + return name; + } + + public Optional getDisplayName() { + return displayName; + } + + public Optional getIdentifier() { + return identifier; + } + + public Optional getAuthor() { + return author; + } +} diff --git a/pitiupi/src/pitiupi/plugins/store/PluginMetadataWriter.java b/pitiupi/src/pitiupi/plugins/store/PluginMetadataWriter.java new file mode 100644 index 0000000..36698d9 --- /dev/null +++ b/pitiupi/src/pitiupi/plugins/store/PluginMetadataWriter.java @@ -0,0 +1,47 @@ +package pitiupi.plugins.store; + +import pitiupi.models.Plugin; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +/** + * Grava {@code *.plugin.meta} após carregar um plugin com sucesso. + */ +public final class PluginMetadataWriter { + + public void write(Plugin plugin, Path jarPath) throws IOException { + Path metaPath = metaPathFor(jarPath); + Properties props = new Properties(); + props.setProperty("identifier", plugin.getIdentifier()); + props.setProperty("name", plugin.getName()); + props.setProperty("displayName", plugin.getDisplayName()); + props.setProperty("author", plugin.getAuthor()); + props.setProperty("version", plugin.getVersion()); + String arquivoId = deriveArquivoId(jarPath, plugin); + props.setProperty("arquivoId", arquivoId); + + try (OutputStream out = Files.newOutputStream(metaPath)) { + props.store(out, "Pitiupi plugin metadata"); + } + } + + static Path metaPathFor(Path jarPath) { + return Path.of(jarPath.toString() + ".plugin.meta"); + } + + private static String deriveArquivoId(Path jarPath, Plugin plugin) { + String file = jarPath.getFileName().toString(); + if (file.endsWith(".jar")) { + String base = file.substring(0, file.length() - 4); + if (base.startsWith("pitiupi.")) { + return base.substring("pitiupi.".length()); + } + return base; + } + return plugin.getIdentifier(); + } +} diff --git a/pitiupi/src/pitiupi/plugins/store/PluginStore.java b/pitiupi/src/pitiupi/plugins/store/PluginStore.java new file mode 100644 index 0000000..e22fa72 --- /dev/null +++ b/pitiupi/src/pitiupi/plugins/store/PluginStore.java @@ -0,0 +1,147 @@ +package pitiupi.plugins.store; + +import pitiupi.errors.ErrorCode; +import pitiupi.errors.PitiupiException; +import pitiupi.platform.PitiupiHome; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Properties; +import java.util.UUID; +import java.util.stream.Stream; + +/** + * Instalação e remoção local de JARs em {@code PITIUPI_HOME/plugins}. + */ +public final class PluginStore { + + private final Path pluginsDir; + private final Path cacheDir; + + public PluginStore() { + PitiupiHome home = PitiupiHome.get(); + this.pluginsDir = home.pluginsDirectory(); + this.cacheDir = home.cacheDirectory(); + } + + public PluginStore(Path pluginsDir, Path cacheDir) { + this.pluginsDir = pluginsDir.normalize().toAbsolutePath(); + this.cacheDir = cacheDir.normalize().toAbsolutePath(); + } + + public List listInstalled() throws IOException { + if (!Files.isDirectory(pluginsDir)) { + return List.of(); + } + List result = new ArrayList<>(); + try (Stream stream = Files.list(pluginsDir)) { + stream.filter(p -> p.getFileName().toString().endsWith(".jar")) + .forEach(jar -> result.add(toRef(jar))); + } + return result; + } + + public Path installFromFile(Path sourceJar) throws PitiupiException, IOException { + if (sourceJar == null || !Files.isRegularFile(sourceJar)) { + throw new PitiupiException(ErrorCode.INVALID_PATH); + } + String fileName = sourceJar.getFileName().toString(); + if (!fileName.endsWith(".jar") || fileName.contains("..")) { + throw new PitiupiException(ErrorCode.INVALID_PATH); + } + + Files.createDirectories(pluginsDir); + Files.createDirectories(cacheDir); + + Path dest = pluginsDir.resolve(fileName).normalize(); + if (!dest.startsWith(pluginsDir.normalize().toAbsolutePath())) { + throw new PitiupiException(ErrorCode.INVALID_PATH); + } + + Path tmp = cacheDir.resolve(fileName + "." + UUID.randomUUID() + ".part"); + try { + Files.copy(sourceJar, tmp, StandardCopyOption.REPLACE_EXISTING); + Files.move(tmp, dest, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + return dest; + } catch (IOException e) { + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) { + // best effort + } + throw new PitiupiException(ErrorCode.PLUGIN_INSTALL_FAILED, e); + } + } + + public void remove(Path jarInPluginsDir) throws PitiupiException, IOException { + Path normalized = jarInPluginsDir.normalize().toAbsolutePath(); + Path pluginsAbs = pluginsDir.normalize().toAbsolutePath(); + if (!normalized.startsWith(pluginsAbs) || !normalized.getFileName().toString().endsWith(".jar")) { + throw new PitiupiException(ErrorCode.INVALID_PATH); + } + try { + Files.deleteIfExists(normalized); + Files.deleteIfExists(PluginMetadataWriter.metaPathFor(normalized)); + } catch (IOException e) { + throw new PitiupiException(ErrorCode.PLUGIN_REMOVE_FAILED, e); + } + } + + public boolean exists(String arquivoId) throws IOException { + return findJarByArquivoId(arquivoId).isPresent(); + } + + public Optional findJarByArquivoId(String id) throws IOException { + if (id == null || id.isBlank()) { + return Optional.empty(); + } + for (InstalledPluginRef ref : listInstalled()) { + if (ref.getArquivoId().map(id::equals).orElse(false)) { + return Optional.of(ref.getJarPath()); + } + } + return Optional.empty(); + } + + private InstalledPluginRef toRef(Path jar) { + Properties meta = loadMeta(PluginMetadataWriter.metaPathFor(jar)); + String fileName = jar.getFileName().toString(); + return new InstalledPluginRef( + jar, + fileName, + optional(meta, "arquivoId"), + optional(meta, "version"), + optional(meta, "name"), + optional(meta, "displayName"), + optional(meta, "identifier"), + optional(meta, "author") + ); + } + + private static Optional optional(Properties meta, String key) { + String v = meta.getProperty(key); + if (v == null || v.isBlank()) { + return Optional.empty(); + } + return Optional.of(v.trim()); + } + + private Properties loadMeta(Path metaPath) { + Properties props = new Properties(); + if (!Files.isRegularFile(metaPath)) { + return props; + } + try (InputStream in = Files.newInputStream(metaPath)) { + props.load(in); + } catch (IOException ignored) { + // listagem tolerante + } + return props; + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/ApplicationRestarter.java b/pitiupi/src/pitiupi/presentation/shell/ApplicationRestarter.java new file mode 100644 index 0000000..b4ec6e1 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/ApplicationRestarter.java @@ -0,0 +1,63 @@ +package pitiupi.presentation.shell; + +import javax.swing.JOptionPane; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Reinício da aplicação após mutação de plugins (D11). + */ +public final class ApplicationRestarter { + + private ApplicationRestarter() { + } + + public static void promptRestartIfNeeded(java.awt.Component parent) { + int choice = JOptionPane.showConfirmDialog( + parent, + "Para aplicar a instalação ou remoção, reinicie o Pitiupi.\nReiniciar agora?", + "Reinício necessário", + JOptionPane.YES_NO_OPTION + ); + if (choice == JOptionPane.YES_OPTION) { + restart(); + } + } + + public static void restart() { + try { + String javaHome = System.getProperty("java.home"); + String javaBin = javaHome + "/bin/java"; + List cmd = new ArrayList<>(); + cmd.add(javaBin); + for (String arg : ManagementArgs.currentJvmArgs()) { + cmd.add(arg); + } + cmd.add("-jar"); + String jar = ManagementArgs.locateRunningJar(); + if (jar == null) { + JOptionPane.showMessageDialog(null, + "Feche e abra o Pitiupi novamente (reinício automático indisponível neste modo)."); + System.exit(0); + return; + } + cmd.add(jar); + new ProcessBuilder(cmd).inheritIO().start(); + System.exit(0); + } catch (IOException e) { + JOptionPane.showMessageDialog(null, + "Não foi possível reiniciar automaticamente. Feche e abra o Pitiupi novamente."); + } + } + + private static final class ManagementArgs { + private static List currentJvmArgs() { + return List.copyOf(java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments()); + } + + private static String locateRunningJar() { + return pitiupi.Main.class.getProtectionDomain().getCodeSource().getLocation().getPath(); + } + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/ClosableTabbedPane.java b/pitiupi/src/pitiupi/presentation/shell/ClosableTabbedPane.java new file mode 100644 index 0000000..b65695d --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/ClosableTabbedPane.java @@ -0,0 +1,256 @@ +package pitiupi.presentation.shell; + +import pitiupi.presentation.shell.ui.ShellUi; + +import javax.swing.JComponent; +import javax.swing.JLabel; +import java.awt.Component; +import javax.swing.JPanel; +import javax.swing.JTabbedPane; +import javax.swing.border.EmptyBorder; +import java.awt.BorderLayout; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Abas com botão fechar circular (estilo shell). Conteúdo real fica fora (strip-only). + */ +public final class ClosableTabbedPane { + + private final StripOnlyTabbedPane tabbedPane = new StripOnlyTabbedPane(); + private final List orderedKeys = new ArrayList<>(); + private final Map keyToIndex = new HashMap<>(); + private Consumer onTabSelected = key -> { + }; + private Consumer onTabClosed = key -> { + }; + private boolean suppressSelectionEvents; + private String closeFallbackKey = PluginSessionKeys.ROOM; + + public ClosableTabbedPane() { + tabbedPane.addChangeListener(e -> { + if (suppressSelectionEvents) { + return; + } + onTabSelected.accept(selectedKey()); + }); + } + + public void setCloseFallbackKey(String closeFallbackKey) { + this.closeFallbackKey = closeFallbackKey; + } + + public JTabbedPane getTabbedPane() { + return tabbedPane; + } + + public int getTabCount() { + reconcileKeyListWithTabPane(); + return tabbedPane.getTabCount(); + } + + public void setOnTabSelected(Consumer onTabSelected) { + this.onTabSelected = onTabSelected != null ? onTabSelected : key -> { + }; + } + + public void setOnTabClosed(Consumer onTabClosed) { + this.onTabClosed = onTabClosed != null ? onTabClosed : key -> { + }; + } + + public String selectedKey() { + reconcileKeyListWithTabPane(); + int idx = tabbedPane.getSelectedIndex(); + if (idx < 0 || idx >= orderedKeys.size()) { + return null; + } + return orderedKeys.get(idx); + } + + public void selectTab(String key) { + selectTabInternal(key, false); + } + + public void selectTabSilently(String key) { + selectTabInternal(key, true); + } + + private void selectTabInternal(String key, boolean silent) { + reconcileKeyListWithTabPane(); + int index = indexOfKey(key); + if (index < 0 || index >= tabbedPane.getTabCount()) { + return; + } + boolean prev = suppressSelectionEvents; + suppressSelectionEvents = silent; + try { + tabbedPane.setSelectedIndex(index); + } finally { + suppressSelectionEvents = prev; + } + } + + public void addOrSelectTab(String key, String title, JComponent content, boolean closable) { + addOrSelectTab(key, title, title, content, closable); + } + + public void addOrSelectTab(String key, String title, String tooltip, JComponent content, boolean closable) { + reconcileKeyListWithTabPane(); + dedupeKey(key); + int index = indexOfKey(key); + if (index >= 0 && index < tabbedPane.getTabCount()) { + setTabHeader(index, title, tooltip, closable, key); + tabbedPane.setSelectedIndex(index); + reindex(); + return; + } + tabbedPane.addTab(title, null); + int newIndex = tabbedPane.getTabCount() - 1; + orderedKeys.add(key); + setTabHeader(newIndex, title, tooltip, closable, key); + reindex(); + tabbedPane.setSelectedIndex(newIndex); + } + + public void closeTab(String key) { + closeTab(key, closeFallbackKey); + } + + 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); + } + keyToIndex.remove(key); + reindex(); + onTabClosed.accept(key); + String next = null; + if (fallbackKey != null && !fallbackKey.isBlank() && hasTab(fallbackKey)) { + next = fallbackKey; + } else if (closeFallbackKey != null && !closeFallbackKey.isBlank() && hasTab(closeFallbackKey)) { + next = closeFallbackKey; + } else { + next = firstTabKey(); + } + if (next != null) { + selectTab(next); + } else if (!suppressSelectionEvents) { + onTabSelected.accept(null); + } + } + + public boolean hasTab(String key) { + reconcileKeyListWithTabPane(); + int index = indexOfKey(key); + return index >= 0 && index < tabbedPane.getTabCount(); + } + + public void setTabTitle(String key, String title) { + setTabTitle(key, title, title); + } + + public void setTabTitle(String key, String title, String tooltip) { + reconcileKeyListWithTabPane(); + int index = indexOfKey(key); + if (index < 0 || index >= tabbedPane.getTabCount()) { + return; + } + Component tabComponent = tabbedPane.getTabComponentAt(index); + if (tabComponent instanceof JPanel header) { + for (Component child : header.getComponents()) { + if (child instanceof JLabel label) { + label.setText(title); + break; + } + } + } + String tip = tooltip != null && !tooltip.isBlank() ? tooltip : title; + tabbedPane.setToolTipTextAt(index, tip); + } + + int stripHeight() { + return tabbedPane.stripHeight(); + } + + private String firstTabKey() { + if (orderedKeys.isEmpty()) { + return null; + } + return orderedKeys.get(0); + } + + private int indexOfKey(String key) { + Integer mapped = keyToIndex.get(key); + if (mapped != null && mapped < orderedKeys.size() && key.equals(orderedKeys.get(mapped))) { + return mapped; + } + for (int i = 0; i < orderedKeys.size(); i++) { + if (key.equals(orderedKeys.get(i))) { + return i; + } + } + return -1; + } + + private void reconcileKeyListWithTabPane() { + while (orderedKeys.size() > tabbedPane.getTabCount()) { + orderedKeys.remove(orderedKeys.size() - 1); + } + reindex(); + } + + private void dedupeKey(String key) { + List indices = new ArrayList<>(); + for (int i = 0; i < orderedKeys.size(); i++) { + if (key.equals(orderedKeys.get(i))) { + indices.add(i); + } + } + 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); + } + } + reindex(); + } + + private void setTabHeader(int tabIndex, String title, String tooltip, boolean closable, String key) { + if (tabIndex < 0 || tabIndex >= tabbedPane.getTabCount()) { + return; + } + JPanel header = new JPanel(new BorderLayout(4, 0)); + header.setOpaque(false); + header.setBorder(new EmptyBorder(0, 6, 0, 2)); + JLabel label = new JLabel(title); + header.add(label, BorderLayout.CENTER); + if (closable) { + header.add(ShellUi.roundTabCloseButton(() -> closeTab(key, closeFallbackKey)), BorderLayout.EAST); + } + tabbedPane.setTabComponentAt(tabIndex, header); + String tip = tooltip != null && !tooltip.isBlank() ? tooltip : title; + tabbedPane.setToolTipTextAt(tabIndex, tip); + } + + private void reindex() { + keyToIndex.clear(); + for (int i = 0; i < orderedKeys.size(); i++) { + keyToIndex.put(orderedKeys.get(i), i); + } + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/LocalNetworkInfo.java b/pitiupi/src/pitiupi/presentation/shell/LocalNetworkInfo.java new file mode 100644 index 0000000..5d3493e --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/LocalNetworkInfo.java @@ -0,0 +1,77 @@ +package pitiupi.presentation.shell; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.Enumeration; + +/** + * Endereço IPv4 local preferencial para compartilhar com peers. + */ +public final class LocalNetworkInfo { + + private LocalNetworkInfo() { + } + + public static String bestLocalIpv4() { + try { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface ni = interfaces.nextElement(); + if (!ni.isUp() || ni.isLoopback() || ni.isVirtual()) { + continue; + } + Enumeration addresses = ni.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress addr = addresses.nextElement(); + if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) { + return addr.getHostAddress(); + } + } + } + InetAddress local = InetAddress.getLocalHost(); + return local.getHostAddress(); + } catch (Exception ex) { + return "—"; + } + } + + /** + * {@code true} se o IP informado é deste host (inclui loopback e interfaces ativas). + */ + public static boolean isLocalIpv4(String ip) { + if (ip == null || ip.isBlank()) { + return false; + } + String trimmed = ip.trim(); + if ("127.0.0.1".equals(trimmed) || "localhost".equalsIgnoreCase(trimmed)) { + return true; + } + try { + String canonical = InetAddress.getByName(trimmed).getHostAddress(); + if ("127.0.0.1".equals(canonical)) { + return true; + } + if (canonical.equals(bestLocalIpv4())) { + return true; + } + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface ni = interfaces.nextElement(); + if (!ni.isUp()) { + continue; + } + Enumeration addresses = ni.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress addr = addresses.nextElement(); + if (addr instanceof Inet4Address && canonical.equals(addr.getHostAddress())) { + return true; + } + } + } + } catch (Exception ignored) { + return trimmed.equals(bestLocalIpv4()); + } + return false; + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/LocalUserIdentity.java b/pitiupi/src/pitiupi/presentation/shell/LocalUserIdentity.java new file mode 100644 index 0000000..99434e1 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/LocalUserIdentity.java @@ -0,0 +1,36 @@ +package pitiupi.presentation.shell; + +import pitiupi.control.ConnectionManager; + +import java.net.InetAddress; + +/** + * Identifica mensagens e atividade originadas neste host (eco de rede). + */ +public final class LocalUserIdentity { + + private LocalUserIdentity() { + } + + public static boolean isLocalSender(ConnectionManager connectionManager, String senderName, String senderIp) { + if (connectionManager == null || senderName == null) { + return false; + } + String localName = connectionManager.getUserName(); + if (localName == null || !localName.equals(senderName.trim())) { + return false; + } + if (senderIp == null || senderIp.isBlank()) { + return false; + } + return LocalNetworkInfo.isLocalIpv4(canonicalIpv4(senderIp)); + } + + private static String canonicalIpv4(String ip) { + try { + return InetAddress.getByName(ip.trim()).getHostAddress(); + } catch (Exception ex) { + return ip.trim(); + } + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/LoginDialog.java b/pitiupi/src/pitiupi/presentation/shell/LoginDialog.java new file mode 100644 index 0000000..d2f47ab --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/LoginDialog.java @@ -0,0 +1,130 @@ +package pitiupi.presentation.shell; + +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import javax.swing.border.EmptyBorder; +import java.awt.BorderLayout; +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; + + public LoginDialog() { + super((java.awt.Frame) null, "Pitiupi", true); + setLocationByPlatform(false); + UiGeometry.installCenterOnShow(this); + buildUi(); + applyPreferredSize(); + } + + private void buildUi() { + String nomeSalvo = prefs.get(PREF_NOME, System.getProperty("user.name")); + int portaSalva = prefs.getInt(PREF_PORTA, 5000); + portField.setModel(new javax.swing.SpinnerNumberModel(portaSalva, 1, 65535, 1)); + nameField.setText(nomeSalvo); + + JPanel form = new JPanel(new GridBagLayout()); + form.setBorder(new EmptyBorder(16, 24, 8, 24)); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(8, 8, 8, 8); + gbc.anchor = GridBagConstraints.WEST; + + gbc.gridx = 0; + gbc.gridy = 0; + form.add(new JLabel("Seu nome na rede:"), gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1; + form.add(nameField, gbc); + + gbc.gridx = 0; + gbc.gridy = 1; + gbc.weightx = 0; + gbc.fill = GridBagConstraints.NONE; + form.add(new JLabel("Porta:"), gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1; + form.add(portField, gbc); + + JButton enterButton = new JButton("Entrar"); + JButton cancelButton = new JButton("Cancelar"); + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + buttons.setBorder(new EmptyBorder(8, 16, 16, 16)); + buttons.add(enterButton); + buttons.add(cancelButton); + + enterButton.addActionListener(e -> onEnter()); + cancelButton.addActionListener(e -> System.exit(0)); + getRootPane().setDefaultButton(enterButton); + + setLayout(new BorderLayout()); + add(form, BorderLayout.CENTER); + add(buttons, BorderLayout.SOUTH); + } + + private void applyPreferredSize() { + pack(); + java.awt.Dimension fraction = UiGeometry.fractionOfScreen(0.3, 0.3); + int width = Math.max(fraction.width, getWidth()); + int height = Math.max(240, getHeight()); + setSize(width, height); + } + + private void onEnter() { + try { + portField.commitEdit(); + } catch (java.text.ParseException ex) { + return; + } + String name = nameField.getText().trim(); + if (name.isEmpty()) { + return; + } + int port = (Integer) portField.getValue(); + if (port <= 0) { + return; + } + prefs.put(PREF_NOME, name); + prefs.putInt(PREF_PORTA, port); + accepted = true; + setVisible(false); + dispose(); + } + + public boolean showLogin() { + applyPreferredSize(); + validate(); + UiGeometry.centerOnScreen(this); + setVisible(true); + SwingUtilities.invokeLater(() -> UiGeometry.centerOnScreen(this)); + return accepted; + } + + public String getUserName() { + return nameField.getText().trim(); + } + + public int getPort() { + return (Integer) portField.getValue(); + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java b/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java new file mode 100644 index 0000000..0b222d6 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java @@ -0,0 +1,148 @@ +package pitiupi.presentation.shell; + +import pitiupi.api.plugin.ShellPluginWorkspace; +import pitiupi.control.ConnectionManager; +import pitiupi.control.GuiManager; +import pitiupi.control.plugins.PluginManager; +import pitiupi.models.Plugin; + +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.util.List; + +/** + * Janela principal pós-login (shell com abas). + */ +public final class MainShellFrame extends JFrame implements ShellHost { + + private final PluginManager pluginManager = new PluginManager(); + private final ConnectionManager connectionManager; + private final NetworkUsersPanel networkUsersPanel; + private final GuiManager guiManager; + private final TabWorkspacePanel tabWorkspace; + private final PluginGalleryPanel galleryPanel; + private final SettingsPanel settingsPanel; + private final JLabel statusLabel = new JLabel(" "); + + public MainShellFrame(String userName, int port) { + setTitle("Pitiupi"); + setDefaultCloseOperation(DO_NOTHING_ON_CLOSE); + addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosing(java.awt.event.WindowEvent e) { + exit(); + } + }); + + connectionManager = new ConnectionManager(userName, pluginManager, port); + settingsPanel = new SettingsPanel(connectionManager); + PluginGalleryPanel gallery = new PluginGalleryPanel((pluginId, title) -> { }); + tabWorkspace = new TabWorkspacePanel(gallery, settingsPanel); + galleryPanel = gallery; + galleryPanel.setOpenHandler(this::openPluginTabFromGallery); + + networkUsersPanel = new NetworkUsersPanel(connectionManager); + ShellMenuBar menuBar = new ShellMenuBar(); + setJMenuBar(menuBar); + + guiManager = new GuiManager(this, menuBar, networkUsersPanel); + + JPanel center = new JPanel(new BorderLayout()); + center.add(tabWorkspace.getRootPanel(), BorderLayout.CENTER); + getContentPane().add(center, BorderLayout.CENTER); + getContentPane().add(networkUsersPanel, BorderLayout.EAST); + + JPanel statusPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + statusPanel.add(statusLabel); + getContentPane().add(statusPanel, BorderLayout.SOUTH); + + tabWorkspace.setOnActivePluginChanged(activeId -> { + if (activeId != null) { + connectionManager.getPeerActionBroker().setActivePlugin(activeId); + } + }); + tabWorkspace.setOnSettingsTabSelected(settingsPanel::onTabSelected); + tabWorkspace.setOnPluginTabClosed(pluginManager::notifyShellTabClosed); + connectionManager.setInviteAcceptedUiListener(this::focusPluginTab); + + loadPlugins(); + updateStatus(userName, port); + setLocationByPlatform(false); + UiGeometry.installCenterOnShow(this); + pack(); + setSize(1024, 768); + } + + public void showOnScreen() { + UiGeometry.centerOnScreen(this); + setVisible(true); + javax.swing.SwingUtilities.invokeLater(() -> UiGeometry.centerOnScreen(this)); + } + + private void loadPlugins() { + pluginManager.loadPlugins(connectionManager, guiManager, (plugin) -> new ShellPluginWorkspace( + plugin.getIdentifier(), + plugin, + connectionManager, + guiManager, + connectionManager.getPeerActionBroker(), + connectionManager.getInviteInboxService(), + tabWorkspace, + this + )); + List plugins = pluginManager.getLoadedPlugins(); + galleryPanel.setPlugins(plugins); + settingsPanel.setLoadedPlugins(plugins); + } + + private void updateStatus(String userName, int port) { + setStatusMessage("Conectado na rede · porta " + port + " · você: " + userName); + } + + private void openPluginTabFromGallery(String pluginId, String title) { + JComponent content = pluginManager.getLoadedPlugins().stream() + .filter(p -> p.getIdentifier().equals(pluginId)) + .map(Plugin::getShellWorkspaceContent) + .findFirst() + .orElse(null); + tabWorkspace.openOrFocusTab(pluginId, title, content); + connectionManager.getPeerActionBroker().setActivePlugin(pluginId); + } + + private void focusPluginTab(String pluginId) { + String title = pluginManager.getLoadedPlugins().stream() + .filter(p -> p.getIdentifier().equals(pluginId)) + .map(Plugin::getDisplayName) + .findFirst() + .orElse(pluginId); + openPluginTabFromGallery(pluginId, title); + } + + @Override + public void exit() { + connectionManager.shutdownGracefully(); + setVisible(false); + dispose(); + System.exit(0); + } + + @Override + public void setUserName(String userName) { + connectionManager.setUserName(userName); + updateStatus(userName, connectionManager.getPort()); + } + + @Override + public ConnectionManager getConnectionManager() { + return connectionManager; + } + + @Override + public void setStatusMessage(String message) { + statusLabel.setText(message); + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/NetworkUsersPanel.java b/pitiupi/src/pitiupi/presentation/shell/NetworkUsersPanel.java new file mode 100644 index 0000000..1a8cf30 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/NetworkUsersPanel.java @@ -0,0 +1,213 @@ +package pitiupi.presentation.shell; + +import pitiupi.application.activity.PeerActivityInbox; +import pitiupi.application.peeractions.GroupedPeerActions; +import pitiupi.application.peeractions.PeerAction; +import pitiupi.application.peeractions.PeerActionBroker; +import pitiupi.application.peeractions.PeerActionTarget; +import pitiupi.application.peeractions.PeerTarget; +import pitiupi.application.presence.UserPeerView; +import pitiupi.control.ConnectionManager; +import pitiupi.control.GuiManager; +import pitiupi.control.PeerListener; +import pitiupi.models.Menus.MenuCategory; +import pitiupi.models.User; +import pitiupi.presentation.shell.ui.ShellUi; + +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JList; +import javax.swing.JLabel; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.SwingUtilities; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Lista lateral com Todos, badges e peer actions. + */ +public final class NetworkUsersPanel extends JPanel implements PeerListener { + + private final DefaultListModel listModel = new DefaultListModel<>(); + private final JList userList = new JList<>(listModel); + private final JPopupMenu menu = new JPopupMenu(); + private final Map legacyCategories = new HashMap<>(); + private final ConnectionManager connectionManager; + + public NetworkUsersPanel(ConnectionManager connectionManager) { + this.connectionManager = connectionManager; + setLayout(new BorderLayout()); + setPreferredSize(new Dimension(200, 0)); + + for (MenuCategory category : MenuCategory.values()) { + JMenu cat = new JMenu(category.toString()); + menu.add(cat); + legacyCategories.put(category, cat); + } + + userList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent( + JList list, + Object value, + int index, + boolean isSelected, + boolean cellHasFocus + ) { + JLabel label = (JLabel) super.getListCellRendererComponent( + list, value, index, isSelected, cellHasFocus); + if (value instanceof DisplayPeer peer) { + label.setText(ShellUi.formatPeerListLabel( + peer.view.getDisplayName(), + peer.view.getNotificationCount() + )); + } + return label; + } + }); + JScrollPane userScroll = new JScrollPane(userList); + ShellUi.borderlessScroll(userScroll); + add(ShellUi.sidebarPanel("Usuários nesta rede", userScroll), BorderLayout.CENTER); + userList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + int index = userList.locationToIndex(e.getPoint()); + if (index < 0) { + return; + } + userList.setSelectedIndex(index); + DisplayPeer peer = listModel.getElementAt(index); + PeerActionTarget target = peer.view.isBroadcastTarget() + ? PeerActionTarget.ALL_PEERS + : PeerActionTarget.SINGLE_PEER; + showPeerActions(peer, target, e.getX(), e.getY()); + } + }); + + connectionManager.addPeerListener(this); + refreshFromPresence(); + connectionManager.getInviteInboxService().setOnNotificationsChanged(this::refreshFromPresence); + connectionManager.getPeerActivityInbox().setOnChanged(this::refreshFromPresence); + } + + private void showPeerActions(DisplayPeer peer, PeerActionTarget target, int x, int y) { + JPopupMenu popup = new JPopupMenu(); + PeerActionBroker broker = connectionManager.getPeerActionBroker(); + PeerTarget peerTarget = target == PeerActionTarget.ALL_PEERS + ? new PeerTarget.All() + : new PeerTarget.Single(peer.view); + + PeerActivityInbox activity = connectionManager.getPeerActivityInbox(); + String activityPeerKey = peer.view.isBroadcastTarget() + ? PeerActivityInbox.BROADCAST_PEER_KEY + : peer.view.getInstanceId(); + + for (GroupedPeerActions group : broker.getAllActionsFor(target)) { + JMenu pluginMenu = new JMenu(group.pluginDisplayName()); + for (PeerAction action : group.actions()) { + int pending = activityPeerKey != null + ? activity.countForAction(activityPeerKey, group.pluginId(), action.getId()) + : 0; + JMenuItem item = new JMenuItem( + ShellUi.formatActionLabelWithBadge(action.getLabel(), pending) + ); + item.addActionListener(e -> { + if (activityPeerKey != null) { + activity.clearAction(activityPeerKey, group.pluginId(), action.getId()); + } + broker.setActivePlugin(group.pluginId()); + action.getHandler().run(peerTarget); + }); + pluginMenu.add(item); + } + popup.add(pluginMenu); + } + if (peer.view.getNotificationCount() > 0 && !peer.view.isBroadcastTarget()) { + JMenuItem accept = new JMenuItem("Aceitar convite"); + accept.addActionListener(e -> { + String pluginId = connectionManager.getInviteInboxService() + .acceptFirstInviteFrom(peer.view.getInstanceId()); + if (pluginId != null) { + connectionManager.getPeerActionBroker().setActivePlugin(pluginId); + connectionManager.notifyInviteAcceptedForUi(pluginId); + } + }); + popup.add(accept); + } + for (JMenu legacy : legacyCategories.values()) { + if (legacy.getItemCount() > 0 && target == PeerActionTarget.SINGLE_PEER && !peer.view.isBroadcastTarget()) { + popup.addSeparator(); + JMenu copy = new JMenu(legacy.getText()); + for (int i = 0; i < legacy.getItemCount(); i++) { + copy.add(legacy.getItem(i)); + } + popup.add(copy); + } + } + if (popup.getComponentCount() > 0) { + popup.show(userList, x, y); + } + } + + public void initUnicastMenu(JMenuItem menuItem, Consumer action) { + menuItem.addActionListener(e -> { + DisplayPeer selected = userList.getSelectedValue(); + if (selected == null || selected.view.isBroadcastTarget()) { + return; + } + User user = selected.toUser(); + if (user.getAddress() != null) { + action.accept(user); + } + }); + } + + public void addUnicastMenu(MenuCategory category, JMenuItem menuItem) { + legacyCategories.get(category).add(menuItem); + } + + @Override + public void onPeersChanged(List peers) { + refreshFromPresence(); + } + + private void refreshFromPresence() { + SwingUtilities.invokeLater(() -> { + listModel.clear(); + for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) { + listModel.addElement(new DisplayPeer(view)); + } + }); + } + + record DisplayPeer(UserPeerView view) { + @Override + public String toString() { + String name = view.getDisplayName(); + if (view.getNotificationCount() > 0) { + return name + " (" + view.getNotificationCount() + ")"; + } + return name; + } + + User toUser() { + return new User( + view.getDisplayName(), + view.getInetAddress().orElse(null), + System.currentTimeMillis(), + view.getInstanceId() + ); + } + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/PeerDraftPanel.java b/pitiupi/src/pitiupi/presentation/shell/PeerDraftPanel.java new file mode 100644 index 0000000..46f928b --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/PeerDraftPanel.java @@ -0,0 +1,55 @@ +package pitiupi.presentation.shell; + +import pitiupi.presentation.shell.ui.ShellUi; + +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.util.function.Consumer; + +/** + * Entrada de IP inline para aba rascunho (Nova conversa). + */ +public final class PeerDraftPanel extends JPanel { + + private final JTextField ipField = new JTextField(24); + private final Consumer onCommit; + + public PeerDraftPanel(String title, String hint, Consumer onCommit) { + this.onCommit = onCommit; + setLayout(new BorderLayout(8, 8)); + JPanel center = new JPanel(new BorderLayout(6, 6)); + center.add(new JLabel(title), BorderLayout.NORTH); + center.add(ShellUi.mutedLabel(hint), BorderLayout.CENTER); + JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 4)); + row.add(new JLabel("IP do peer:")); + row.add(ipField); + JButton open = new JButton("Abrir conversa"); + open.addActionListener(e -> commit()); + row.add(open); + center.add(row, BorderLayout.SOUTH); + add(center, BorderLayout.NORTH); + ipField.addActionListener(e -> commit()); + } + + public void focusIpField() { + ipField.requestFocusInWindow(); + } + + public void clearIpField() { + ipField.setText(""); + } + + private void commit() { + String ip = ipField.getText(); + if (ip == null || ip.isBlank()) { + return; + } + if (onCommit != null) { + onCommit.accept(ip.trim()); + } + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/PluginGalleryPanel.java b/pitiupi/src/pitiupi/presentation/shell/PluginGalleryPanel.java new file mode 100644 index 0000000..80d534a --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/PluginGalleryPanel.java @@ -0,0 +1,80 @@ +package pitiupi.presentation.shell; + +import pitiupi.models.Plugin; +import pitiupi.presentation.shell.ui.ShellUi; + +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.SwingConstants; +import javax.swing.border.EmptyBorder; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.util.Comparator; +import java.util.List; +import java.util.function.BiConsumer; + +/** + * Aba Plugins: tiles compactos para abrir/focar aba do plugin. + */ +public final class PluginGalleryPanel extends JPanel { + + private BiConsumer onPluginSelected; + + public PluginGalleryPanel(BiConsumer onPluginSelected) { + this.onPluginSelected = onPluginSelected; + setLayout(new BorderLayout()); + setBorder(new EmptyBorder(6, 6, 6, 6)); + } + + public void setOpenHandler(BiConsumer onPluginSelected) { + this.onPluginSelected = onPluginSelected; + } + + public void setPlugins(List plugins) { + removeAll(); + if (plugins.isEmpty()) { + add(new JLabel( + "
Escolha um plugin para abrir aqui.
" + + "Tudo acontece nesta área — sem janelas extras.
", + SwingConstants.CENTER + ), BorderLayout.CENTER); + return; + } + JPanel grid = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8)); + List ordered = plugins.stream() + .sorted(Comparator.comparingInt(PluginGalleryPanel::galleryOrder)) + .toList(); + for (Plugin plugin : ordered) { + String display = plugin.getDisplayName(); + JButton btn = new JButton(display); + ShellUi.applyPluginGalleryTile(btn); + btn.setToolTipText(buildTooltip(plugin)); + btn.addActionListener(e -> onPluginSelected.accept(plugin.getIdentifier(), display)); + grid.add(btn); + } + JScrollPane scroll = new JScrollPane(grid); + ShellUi.borderlessScroll(scroll); + add(scroll, BorderLayout.CENTER); + } + + private static String buildTooltip(Plugin plugin) { + return "" + + plugin.getDisplayName() + + "
" + plugin.getName() + "" + + "
ID: " + plugin.getIdentifier() + + "
v" + plugin.getVersion() + " · " + plugin.getAuthor() + + ""; + } + + private static int galleryOrder(Plugin plugin) { + if (plugin.getIdentifier() != null && plugin.getIdentifier().toLowerCase().contains("chat")) { + return 0; + } + if (plugin.getIdentifier() != null && plugin.getIdentifier().toLowerCase().contains("screencast")) { + return 1; + } + return 2; + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/PluginRoutingPanel.java b/pitiupi/src/pitiupi/presentation/shell/PluginRoutingPanel.java new file mode 100644 index 0000000..0535249 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/PluginRoutingPanel.java @@ -0,0 +1,250 @@ +package pitiupi.presentation.shell; + +import pitiupi.application.messaging.MessagingDestinationUi; +import pitiupi.application.messaging.PluginTransportCatalog; +import pitiupi.application.messaging.TransportMode; + +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import pitiupi.presentation.shell.ui.ShellUi; +import javax.swing.DefaultComboBoxModel; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.JToggleButton; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Transporte e contexto da sessão ativa (aba). Destino vem da aba lateral / lista de sessões. + */ +public final class PluginRoutingPanel extends JPanel { + + public record RoutingSnapshot( + boolean everyoneInRoom, + String peerIp, + TransportMode transport, + boolean privateTcpOn + ) { + } + + private final PluginTransportCatalog.PluginKind pluginKind; + private final JLabel sessionBanner = new JLabel(); + private final JLabel receiveScopeHint = new JLabel(); + private final JLabel destinationModeHint = new JLabel(); + private final JLabel ipLabel = new JLabel("IP do peer:"); + private final JTextField ipField = new JTextField(18); + private final JComboBox transportCombo = new JComboBox<>(); + private final JToggleButton privateTcpToggle = new JToggleButton("TCP privado (desligado)"); + private final JLabel ipHint; + private final JPanel rowPeer = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 4)); + private final JPanel rowHint = new JPanel(new BorderLayout()); + private final List> listeners = new ArrayList<>(); + private Consumer privateTcpListener = on -> { + }; + private boolean everyoneInRoom = true; + private TransportMode lastRoomTransport = TransportMode.MULTICAST_UDP; + private TransportMode lastPeerTransport = TransportMode.UNICAST_UDP; + + public PluginRoutingPanel( + PluginTransportCatalog.PluginKind pluginKind, + String peerHintHtml, + boolean showPrivateTcpToggle + ) { + this.pluginKind = pluginKind; + this.ipHint = new JLabel(peerHintHtml); + setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + ShellUi.applySectionTitleBorder(this, "Destino e transporte"); + buildUi(showPrivateTcpToggle); + bindRoomSession(); + } + + public void addRoutingListener(Consumer listener) { + if (listener != null) { + listeners.add(listener); + } + } + + public void setPrivateTcpListener(Consumer listener) { + this.privateTcpListener = listener != null ? listener : on -> { + }; + } + + public RoutingSnapshot snapshot() { + String ip = ipField.getText().trim(); + TransportMode mode = (TransportMode) transportCombo.getSelectedItem(); + return new RoutingSnapshot(everyoneInRoom, ip, mode, privateTcpToggle.isSelected() && !everyoneInRoom); + } + + public void bindRoomSession() { + everyoneInRoom = true; + ipField.setText(""); + sessionBanner.setText("Sessão: Sala geral (Todos)"); + setReceiveScopeHint(null); + applyRoutingState(); + } + + public void bindPeerSession(String ip, String displayName) { + bindPeerSession(ip, displayName, null); + } + + public void bindPeerSession(String ip, String displayName, String receiveScopeHtml) { + everyoneInRoom = false; + if (ip != null) { + ipField.setText(ip); + } + String who = displayName != null && !displayName.isBlank() ? displayName : ip; + sessionBanner.setText(SessionPeerLabels.sessionBannerHtml(who, ip)); + setReceiveScopeHint(receiveScopeHtml); + applyRoutingState(); + } + + public void setReceiveScopeHint(String html) { + if (html == null || html.isBlank()) { + receiveScopeHint.setText(""); + receiveScopeHint.setVisible(false); + } else { + receiveScopeHint.setText(html); + receiveScopeHint.setVisible(true); + } + } + + public void setPrivateTcpOn(boolean on) { + privateTcpToggle.setSelected(on); + updatePrivateToggleLabel(); + applyRoutingState(); + privateTcpListener.accept(on); + } + + public void selectTransport(TransportMode mode) { + if (mode != null) { + transportCombo.setSelectedItem(mode); + rememberSelectedTransport(mode); + } + updateDestinationModeHint(); + fireRoutingChanged(); + } + + private void buildUi(boolean showPrivateTcpToggle) { + JPanel rowSession = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 4)); + rowSession.add(sessionBanner); + rowSession.add(destinationModeHint); + receiveScopeHint.setVisible(false); + rowSession.add(receiveScopeHint); + + privateTcpToggle.setFocusPainted(false); + transportCombo.addActionListener(e -> { + TransportMode selected = (TransportMode) transportCombo.getSelectedItem(); + rememberSelectedTransport(selected); + updateDestinationModeHint(); + fireRoutingChanged(); + }); + + rowPeer.add(ipLabel); + rowPeer.add(ipField); + rowPeer.add(new JLabel("Forma de envio:")); + rowPeer.add(transportCombo); + + privateTcpToggle.setVisible(showPrivateTcpToggle); + privateTcpToggle.setToolTipText( + "Ligado: conversa só entre vocês, em conexão dedicada (TCP privado). " + + "Desligado: use a forma de envio escolhida ao lado." + ); + privateTcpToggle.addActionListener(e -> { + updatePrivateToggleLabel(); + updateDestinationModeHint(); + privateTcpListener.accept(privateTcpToggle.isSelected()); + applyRoutingState(); + }); + if (showPrivateTcpToggle) { + rowPeer.add(privateTcpToggle); + } + + rowHint.setBorder(BorderFactory.createEmptyBorder(0, 8, 2, 8)); + rowHint.add(ipHint, BorderLayout.CENTER); + + add(rowSession); + add(rowPeer); + add(rowHint); + + ipField.addActionListener(e -> applyRoutingState()); + } + + private void applyRoutingState() { + ipLabel.setVisible(!everyoneInRoom); + ipField.setVisible(!everyoneInRoom); + ipField.setEnabled(!everyoneInRoom); + privateTcpToggle.setEnabled(!everyoneInRoom); + rowHint.setVisible(!everyoneInRoom); + if (everyoneInRoom) { + privateTcpToggle.setSelected(false); + updatePrivateToggleLabel(); + } + boolean privateOn = privateTcpToggle.isSelected(); + transportCombo.setEnabled(!everyoneInRoom && (!privateTcpToggle.isVisible() || !privateOn)); + + List modes = everyoneInRoom + ? PluginTransportCatalog.forBroadcast(pluginKind) + : PluginTransportCatalog.forSinglePeer(pluginKind); + DefaultComboBoxModel model = new DefaultComboBoxModel<>(); + for (TransportMode mode : modes) { + model.addElement(mode); + } + transportCombo.setModel(model); + TransportMode preferred = everyoneInRoom ? lastRoomTransport : lastPeerTransport; + if (preferred != null && modes.contains(preferred)) { + transportCombo.setSelectedItem(preferred); + } else if (!modes.isEmpty()) { + transportCombo.setSelectedIndex(0); + rememberSelectedTransport((TransportMode) transportCombo.getSelectedItem()); + } + updateDestinationModeHint(); + fireRoutingChanged(); + } + + private void updateDestinationModeHint() { + RoutingSnapshot snap = snapshot(); + String line = MessagingDestinationUi.summarizeSendMode( + snap.everyoneInRoom(), + snap.transport(), + snap.privateTcpOn() + ); + destinationModeHint.setText("— " + line + ""); + } + + private void updatePrivateToggleLabel() { + privateTcpToggle.setText(privateTcpToggle.isSelected() + ? "TCP privado (ligado)" + : "TCP privado (desligado)"); + } + + private void fireRoutingChanged() { + RoutingSnapshot snap = snapshot(); + for (Consumer listener : listeners) { + listener.accept(snap); + } + } + + private void rememberSelectedTransport(TransportMode mode) { + if (mode == null) { + return; + } + if (everyoneInRoom) { + lastRoomTransport = mode; + } else { + lastPeerTransport = mode; + } + } + + private static String escape(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&").replace("<", "<"); + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/PluginSessionKeys.java b/pitiupi/src/pitiupi/presentation/shell/PluginSessionKeys.java new file mode 100644 index 0000000..a1b5313 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/PluginSessionKeys.java @@ -0,0 +1,18 @@ +package pitiupi.presentation.shell; + +/** + * Chaves de abas de sessão (chat, screencast). + */ +public final class PluginSessionKeys { + + public static final String ROOM = "room"; + public static final String ADD_PEER = "__add_peer__"; + public static final String PEER_DRAFT = "peer:__draft__"; + + private PluginSessionKeys() { + } + + public static String peer(String ip) { + return "peer:" + ip; + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/PluginSessionWorkspace.java b/pitiupi/src/pitiupi/presentation/shell/PluginSessionWorkspace.java new file mode 100644 index 0000000..667e5b4 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/PluginSessionWorkspace.java @@ -0,0 +1,46 @@ +package pitiupi.presentation.shell; + +import javax.swing.BoxLayout; +import javax.swing.JComponent; +import javax.swing.JPanel; +import java.awt.BorderLayout; + +/** + * Chrome compartilhado dos plugins de sessão: roteamento, faixa de abas e corpo. + */ +public final class PluginSessionWorkspace extends JPanel { + + private final PluginRoutingPanel routingPanel; + private final SessionTabBar sessionTabBar = new SessionTabBar(); + private final JPanel bodyHost = new JPanel(new BorderLayout()); + + public PluginSessionWorkspace(PluginRoutingPanel routingPanel, JComponent pluginToolbar) { + this.routingPanel = routingPanel; + setLayout(new BorderLayout(0, 0)); + + JPanel top = new JPanel(); + top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS)); + top.add(routingPanel); + if (pluginToolbar != null) { + top.add(pluginToolbar); + } + add(top, BorderLayout.NORTH); + + JPanel sessionColumn = new JPanel(new BorderLayout(0, 0)); + sessionColumn.add(sessionTabBar.getPanel(), BorderLayout.NORTH); + sessionColumn.add(new SessionBodyChrome(bodyHost), BorderLayout.CENTER); + add(sessionColumn, BorderLayout.CENTER); + } + + public PluginRoutingPanel getRoutingPanel() { + return routingPanel; + } + + public SessionTabBar getSessionTabBar() { + return sessionTabBar; + } + + public JPanel getBodyHost() { + return bodyHost; + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/SessionBodyChrome.java b/pitiupi/src/pitiupi/presentation/shell/SessionBodyChrome.java new file mode 100644 index 0000000..765135b --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/SessionBodyChrome.java @@ -0,0 +1,24 @@ +package pitiupi.presentation.shell; + +import pitiupi.presentation.shell.ui.ShellUi; + +import javax.swing.BorderFactory; +import javax.swing.JPanel; +import javax.swing.border.EmptyBorder; +import javax.swing.border.MatteBorder; +import java.awt.BorderLayout; + +/** + * Envolve o corpo da sessão com separador leve abaixo da faixa de abas. + */ +public final class SessionBodyChrome extends JPanel { + + public SessionBodyChrome(JPanel bodyHost) { + setLayout(new BorderLayout()); + setBorder(BorderFactory.createCompoundBorder( + new MatteBorder(1, 0, 0, 0, ShellUi.shellEdgeColor()), + new EmptyBorder(2, 2, 2, 2) + )); + add(bodyHost, BorderLayout.CENTER); + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/SessionPeerLabels.java b/pitiupi/src/pitiupi/presentation/shell/SessionPeerLabels.java new file mode 100644 index 0000000..a903fa2 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/SessionPeerLabels.java @@ -0,0 +1,81 @@ +package pitiupi.presentation.shell; + +import pitiupi.application.presence.UserPeerView; +import pitiupi.control.ConnectionManager; + +import java.net.InetAddress; + +/** + * Nomes amigáveis para abas e banner de sessão com peers. + */ +public final class SessionPeerLabels { + + private SessionPeerLabels() { + } + + public static String resolveDisplayName(ConnectionManager connectionManager, String ip, String hintName) { + if (ip == null || ip.isBlank()) { + return hintName != null && !hintName.isBlank() ? hintName : ""; + } + String trimmedIp = ip.trim(); + if (hintName != null && !hintName.isBlank() && !hintName.trim().equals(trimmedIp)) { + return hintName.trim(); + } + for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) { + if (view.isBroadcastTarget()) { + continue; + } + if (view.isSelf() && isLocalIp(trimmedIp)) { + return selfLabel(connectionManager); + } + if (view.getInetAddress().map(InetAddress::getHostAddress).orElse("").equals(trimmedIp)) { + String name = view.getDisplayName(); + if (name != null && !name.isBlank() && !name.equals(trimmedIp)) { + return name; + } + if (view.isSelf()) { + return selfLabel(connectionManager); + } + } + } + if (isLocalIp(trimmedIp)) { + return selfLabel(connectionManager); + } + return trimmedIp; + } + + public static String tabTitle(ConnectionManager connectionManager, String ip, String hintName) { + return resolveDisplayName(connectionManager, ip, hintName); + } + + public static String sessionBannerHtml(String displayName, String ip) { + String safeIp = ip != null ? ip.trim() : ""; + String who = displayName != null && !displayName.isBlank() ? displayName.trim() : safeIp; + if (safeIp.isEmpty()) { + return "Sessão: " + escape(who) + ""; + } + if (who.equals(safeIp)) { + return "Sessão: IP " + escape(safeIp) + ""; + } + return "Sessão: " + escape(who) + " · IP " + escape(safeIp) + ""; + } + + private static String selfLabel(ConnectionManager connectionManager) { + String name = connectionManager.getUserName(); + if (name == null || name.isBlank()) { + return "Você"; + } + return name + " (você)"; + } + + private static boolean isLocalIp(String ip) { + return LocalNetworkInfo.isLocalIpv4(ip); + } + + private static String escape(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&").replace("<", "<"); + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/SessionTabBar.java b/pitiupi/src/pitiupi/presentation/shell/SessionTabBar.java new file mode 100644 index 0000000..25bc3fd --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/SessionTabBar.java @@ -0,0 +1,132 @@ +package pitiupi.presentation.shell; + +import pitiupi.presentation.shell.ui.ShellUi; + +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JPanel; +import java.awt.Dimension; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.util.function.Consumer; + +/** + * Faixa de abas de sessão com botão à direita para nova conversa por IP. + */ +public final class SessionTabBar { + + public static final String ADD_PEER_BUTTON_LABEL = "+ Nova"; + public static final String ADD_PEER_BUTTON_TOOLTIP = "Nova conversa… — informar o IP do peer manualmente"; + + private static final int ADD_BUTTON_MIN_WIDTH = 100; + + private final ClosableTabbedPane tabs = new ClosableTabbedPane(); + private final JButton addPeerButton = new JButton(ADD_PEER_BUTTON_LABEL); + private final JPanel panel = new JPanel(new GridBagLayout()); + private final JPanel stripRow = new JPanel(new GridBagLayout()); + + public SessionTabBar() { + addPeerButton.setToolTipText(ADD_PEER_BUTTON_TOOLTIP); + GridBagConstraints tabsGbc = new GridBagConstraints(); + tabsGbc.gridx = 0; + tabsGbc.gridy = 0; + tabsGbc.weightx = 1.0; + tabsGbc.fill = GridBagConstraints.HORIZONTAL; + tabsGbc.anchor = GridBagConstraints.WEST; + stripRow.add(tabs.getTabbedPane(), tabsGbc); + + GridBagConstraints btnGbc = new GridBagConstraints(); + btnGbc.gridx = 1; + btnGbc.gridy = 0; + btnGbc.weightx = 0; + btnGbc.fill = GridBagConstraints.NONE; + btnGbc.anchor = GridBagConstraints.EAST; + stripRow.add(addPeerButton, btnGbc); + + GridBagConstraints rowGbc = new GridBagConstraints(); + rowGbc.gridx = 0; + rowGbc.gridy = 0; + rowGbc.weightx = 1.0; + rowGbc.fill = GridBagConstraints.HORIZONTAL; + panel.add(stripRow, rowGbc); + alignAddButtonToStrip(); + } + + private void alignAddButtonToStrip() { + int h = Math.max(26, tabs.stripHeight()); + int w = Math.max(ADD_BUTTON_MIN_WIDTH, addPeerButton.getPreferredSize().width); + addPeerButton.setPreferredSize(new Dimension(w, h)); + addPeerButton.setMinimumSize(new Dimension(ADD_BUTTON_MIN_WIDTH, h)); + addPeerButton.setMaximumSize(new Dimension(Integer.MAX_VALUE, h)); + ShellUi.alignButtonToTabStrip(addPeerButton); + stripRow.setPreferredSize(new Dimension(stripRow.getPreferredSize().width, h)); + } + + public JPanel getPanel() { + alignAddButtonToStrip(); + return panel; + } + + public JButton getAddPeerButton() { + return addPeerButton; + } + + public void setOnAddPeerRequested(Runnable action) { + addPeerButton.addActionListener(e -> { + if (action != null) { + action.run(); + } + }); + } + + public void setOnTabSelected(Consumer onTabSelected) { + tabs.setOnTabSelected(onTabSelected); + } + + public void setOnTabClosed(Consumer onTabClosed) { + tabs.setOnTabClosed(onTabClosed); + } + + public void setCloseFallbackKey(String key) { + tabs.setCloseFallbackKey(key); + } + + public void addOrSelectTab(String key, String title, JComponent content, boolean closable) { + tabs.addOrSelectTab(key, title, content, closable); + alignAddButtonToStrip(); + } + + public void addOrSelectTab(String key, String title, String tooltip, JComponent content, boolean closable) { + tabs.addOrSelectTab(key, title, tooltip, content, closable); + alignAddButtonToStrip(); + } + + public void selectTab(String key) { + tabs.selectTab(key); + } + + public void selectTabSilently(String key) { + tabs.selectTabSilently(key); + } + + public void closeTab(String key) { + tabs.closeTab(key); + alignAddButtonToStrip(); + } + + public boolean hasTab(String key) { + return tabs.hasTab(key); + } + + public int getTabCount() { + return tabs.getTabCount(); + } + + public void setTabTitle(String key, String title, String tooltip) { + tabs.setTabTitle(key, title, tooltip); + } + + public String selectedKey() { + return tabs.selectedKey(); + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/SettingsPanel.java b/pitiupi/src/pitiupi/presentation/shell/SettingsPanel.java new file mode 100644 index 0000000..abab9b0 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/SettingsPanel.java @@ -0,0 +1,239 @@ +package pitiupi.presentation.shell; + +import pitiupi.control.ConnectionManager; +import pitiupi.errors.PitiupiException; +import pitiupi.models.Plugin; +import pitiupi.plugins.store.InstalledPluginRef; +import pitiupi.plugins.store.PluginStore; +import pitiupi.presentation.shell.ui.ShellUi; + +import javax.swing.JButton; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.JTextField; +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.Dimension; +import java.awt.FlowLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.io.File; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Configurações: perfil de rede e gestão de JARs. + */ +public final class SettingsPanel extends JPanel { + + 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 JTextField ipField = new JTextField(); + private final JTextField instanceField = new JTextField(); + private final JLabel peersLabel = new JLabel(); + private final Timer refreshTimer; + private Map loadedByIdentifier = Map.of(); + + public SettingsPanel(ConnectionManager connectionManager) { + this.connectionManager = connectionManager; + this.store = new PluginStore(); + setLayout(new BorderLayout(10, 10)); + setBorder(new EmptyBorder(8, 8, 8, 8)); + + for (JTextField field : List.of(nameField, portField, ipField, instanceField)) { + field.setEditable(false); + } + + add(buildNetworkPanel(), BorderLayout.NORTH); + add(buildPluginsPanel(), BorderLayout.CENTER); + + refreshTimer = new Timer(5000, e -> refreshNetworkInfo()); + refreshTimer.setRepeats(true); + addAncestorListener(new javax.swing.event.AncestorListener() { + @Override + public void ancestorAdded(javax.swing.event.AncestorEvent event) { + refreshNetworkInfo(); + refreshTimer.start(); + } + + @Override + public void ancestorRemoved(javax.swing.event.AncestorEvent event) { + refreshTimer.stop(); + } + + @Override + public void ancestorMoved(javax.swing.event.AncestorEvent event) { + } + }); + + refreshTable(); + refreshNetworkInfo(); + } + + public void setLoadedPlugins(List plugins) { + Map map = new HashMap<>(); + for (Plugin p : plugins) { + map.put(p.getIdentifier(), p); + } + loadedByIdentifier = map; + refreshTable(); + } + + public void onTabSelected() { + refreshNetworkInfo(); + } + + private JPanel buildNetworkPanel() { + JPanel panel = ShellUi.sectionPanel("Sua conexão"); + panel.setLayout(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 4, 4, 4); + gbc.anchor = GridBagConstraints.WEST; + 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); + + gbc.gridx = 0; + gbc.gridy = 4; + panel.add(new JLabel("Peers visíveis:"), gbc); + gbc.gridx = 1; + panel.add(peersLabel, gbc); + + return panel; + } + + private void addRow(JPanel panel, GridBagConstraints gbc, int row, String label, JTextField field) { + gbc.gridx = 0; + gbc.gridy = row; + gbc.gridwidth = 1; + gbc.weightx = 0; + panel.add(new JLabel(label + ":"), gbc); + gbc.gridx = 1; + gbc.weightx = 1; + panel.add(field, gbc); + } + + private JPanel buildPluginsPanel() { + JPanel wrapper = ShellUi.sectionPanel("Plugins instalados"); + wrapper.setLayout(new BorderLayout(8, 8)); + + JScrollPane scroll = new JScrollPane(table); + scroll.setPreferredSize(new Dimension(400, 200)); + ShellUi.borderlessScroll(scroll); + wrapper.add(scroll, BorderLayout.CENTER); + + JLabel help = ShellUi.mutedLabel("Depois de instalar ou remover, reinicie o Pitiupi para aplicar."); + wrapper.add(help, BorderLayout.NORTH); + + JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton install = new JButton("Instalar JAR…"); + JButton remove = new JButton("Remover selecionado"); + install.setFocusPainted(false); + remove.setFocusPainted(false); + actions.add(install); + actions.add(remove); + wrapper.add(actions, BorderLayout.SOUTH); + + install.addActionListener(e -> installJar()); + remove.addActionListener(e -> removeSelected()); + + return wrapper; + } + + private void refreshNetworkInfo() { + nameField.setText(connectionManager.getUserName()); + portField.setText(String.valueOf(connectionManager.getPort())); + 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 void refreshTable() { + tableModel.setRowCount(0); + try { + List refs = store.listInstalled(); + for (InstalledPluginRef ref : refs) { + String id = ref.getIdentifier().orElse("—"); + String display = loadedByIdentifier.values().stream() + .filter(p -> id.equals(p.getIdentifier())) + .map(Plugin::getDisplayName) + .findFirst() + .orElse(ref.getDisplayName().orElse(ref.getName().orElse("—"))); + tableModel.addRow(new Object[]{ + display, + id, + ref.getFileName(), + ref.getVersion().orElse("—") + }); + } + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Não foi possível listar plugins: " + ex.getMessage()); + } + } + + private void installJar() { + JFileChooser chooser = new JFileChooser(); + chooser.setFileFilter(new FileNameExtensionFilter("Plugins JAR", "jar")); + if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) { + return; + } + File file = chooser.getSelectedFile(); + try { + store.installFromFile(file.toPath()); + refreshTable(); + ApplicationRestarter.promptRestartIfNeeded(this); + } catch (PitiupiException ex) { + JOptionPane.showMessageDialog(this, ex.getUserMessage()); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Falha ao instalar: " + ex.getMessage()); + } + } + + private void removeSelected() { + int row = table.getSelectedRow(); + if (row < 0) { + return; + } + String fileName = (String) tableModel.getValueAt(row, 2); + int ok = JOptionPane.showConfirmDialog(this, "Remover " + fileName + "?", "Confirmar", JOptionPane.YES_NO_OPTION); + if (ok != JOptionPane.YES_OPTION) { + return; + } + try { + Path pluginsDir = store.listInstalled().stream() + .filter(r -> r.getFileName().equals(fileName)) + .map(InstalledPluginRef::getJarPath) + .findFirst() + .orElse(null); + if (pluginsDir == null) { + return; + } + store.remove(pluginsDir); + refreshTable(); + ApplicationRestarter.promptRestartIfNeeded(this); + } catch (PitiupiException ex) { + JOptionPane.showMessageDialog(this, ex.getUserMessage()); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Falha ao remover: " + ex.getMessage()); + } + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/ShellHost.java b/pitiupi/src/pitiupi/presentation/shell/ShellHost.java new file mode 100644 index 0000000..9e915b4 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/ShellHost.java @@ -0,0 +1,17 @@ +package pitiupi.presentation.shell; + +import pitiupi.control.ConnectionManager; + +/** + * Operações da janela principal expostas à barra de menus. + */ +public interface ShellHost { + + void exit(); + + void setUserName(String userName); + + ConnectionManager getConnectionManager(); + + void setStatusMessage(String message); +} diff --git a/pitiupi/src/pitiupi/presentation/shell/ShellMenuBar.java b/pitiupi/src/pitiupi/presentation/shell/ShellMenuBar.java new file mode 100644 index 0000000..1a567bc --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/ShellMenuBar.java @@ -0,0 +1,21 @@ +package pitiupi.presentation.shell; + +import pitiupi.GUI.About; + +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; + +/** + * Barra de menus mínima (apenas Ajuda). + */ +public final class ShellMenuBar extends JMenuBar { + + public ShellMenuBar() { + JMenu help = new JMenu("Ajuda"); + JMenuItem about = new JMenuItem("Sobre"); + about.addActionListener(e -> new About().setVisible(true)); + help.add(about); + add(help); + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/StripOnlyTabbedPane.java b/pitiupi/src/pitiupi/presentation/shell/StripOnlyTabbedPane.java new file mode 100644 index 0000000..2c8e0cd --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/StripOnlyTabbedPane.java @@ -0,0 +1,68 @@ +package pitiupi.presentation.shell; + +import javax.swing.JComponent; +import javax.swing.JTabbedPane; +import java.awt.Dimension; + +/** + * JTabbedPane que ocupa apenas a altura da trilha de abas (conteúdo real fica fora). + */ +final class StripOnlyTabbedPane extends JTabbedPane { + + StripOnlyTabbedPane() { + setTabLayoutPolicy(SCROLL_TAB_LAYOUT); + putClientProperty("JTabbedPane.contentBorderInsets", new java.awt.Insets(0, 0, 0, 0)); + } + + /** + * Cada aba precisa de um componente distinto; reutilizar o mesmo quebra o tab count do Swing. + */ + static JComponent newHiddenPlaceholder() { + return new JComponent() { + @Override + public Dimension getPreferredSize() { + return new Dimension(0, 0); + } + + @Override + public Dimension getMinimumSize() { + return new Dimension(0, 0); + } + + @Override + public Dimension getMaximumSize() { + return new Dimension(0, 0); + } + }; + } + + @Override + public void addTab(String title, java.awt.Component component) { + super.addTab(title, newHiddenPlaceholder()); + } + + @Override + public Dimension getPreferredSize() { + return new Dimension(super.getPreferredSize().width, stripHeight()); + } + + @Override + public Dimension getMaximumSize() { + return new Dimension(Integer.MAX_VALUE, stripHeight()); + } + + int stripHeight() { + if (getTabCount() == 0) { + return 30; + } + try { + java.awt.Rectangle bounds = getUI().getTabBounds(this, 0); + if (bounds != null && bounds.height > 0) { + return bounds.height + 6; + } + } catch (Exception ignored) { + // headless / UI not ready + } + return 30; + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/TabWorkspacePanel.java b/pitiupi/src/pitiupi/presentation/shell/TabWorkspacePanel.java new file mode 100644 index 0000000..d436a41 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/TabWorkspacePanel.java @@ -0,0 +1,210 @@ +package pitiupi.presentation.shell; + +import pitiupi.presentation.shell.ui.ShellUi; + +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTabbedPane; +import javax.swing.SwingConstants; +import javax.swing.border.EmptyBorder; +import java.awt.BorderLayout; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Abas centrais: Plugins (fixa), dinâmicas por plugin, Configurações (fixa). + * Faixa de abas fina + corpo único abaixo (mesmo padrão dos plugins de sessão). + */ +public final class TabWorkspacePanel { + + public static final String TAB_PLUGINS = "Plugins"; + public static final String TAB_SETTINGS = "Configurações"; + + private final StripOnlyTabbedPane strip = new StripOnlyTabbedPane(); + private final List tabContents = new ArrayList<>(); + private final JPanel contentHost = new JPanel(new BorderLayout()); + private final JPanel root = new JPanel(new BorderLayout(0, 0)); + private final Map pluginTabIndex = new HashMap<>(); + private Consumer onActivePluginChanged = id -> { + }; + private Runnable onSettingsTabSelected = () -> { + }; + private Consumer onPluginTabClosed = id -> { + }; + + public TabWorkspacePanel(JComponent pluginsTab, JComponent settingsTab) { + tabContents.add(pluginsTab); + tabContents.add(settingsTab); + strip.addTab(TAB_PLUGINS, StripOnlyTabbedPane.newHiddenPlaceholder()); + strip.addTab(TAB_SETTINGS, StripOnlyTabbedPane.newHiddenPlaceholder()); + strip.addChangeListener(e -> { + showContentAt(strip.getSelectedIndex()); + fireActivePlugin(); + if (strip.getSelectedIndex() == strip.getTabCount() - 1) { + onSettingsTabSelected.run(); + } + }); + + root.add(strip, BorderLayout.NORTH); + root.add(new SessionBodyChrome(contentHost), BorderLayout.CENTER); + showContentAt(0); + } + + public JPanel getRootPanel() { + return root; + } + + /** Expõe a faixa de abas para testes. */ + public JTabbedPane getTabbedPane() { + return strip; + } + + public void setOnActivePluginChanged(Consumer listener) { + this.onActivePluginChanged = listener != null ? listener : id -> { + }; + } + + public void setOnSettingsTabSelected(Runnable onSettingsTabSelected) { + this.onSettingsTabSelected = onSettingsTabSelected != null ? onSettingsTabSelected : () -> { + }; + } + + public void setOnPluginTabClosed(Consumer listener) { + this.onPluginTabClosed = listener != null ? listener : id -> { + }; + } + + public void openOrFocusTab(String pluginId, String title, JComponent content) { + Integer existing = pluginTabIndex.get(pluginId); + if (existing != null && existing < strip.getTabCount()) { + if (content != null) { + tabContents.set(existing, content); + content.putClientProperty("pitiupi.pluginId", pluginId); + if (strip.getSelectedIndex() == existing) { + showContentAt(existing); + } + } + updateTabHeader(existing, title, pluginId); + strip.setSelectedIndex(existing); + return; + } + JComponent panel = content != null ? content : placeholderPanel(title); + panel.putClientProperty("pitiupi.pluginId", pluginId); + int insertAt = Math.max(1, strip.getTabCount() - 1); + strip.insertTab(" ", null, StripOnlyTabbedPane.newHiddenPlaceholder(), title, insertAt); + tabContents.add(insertAt, panel); + setClosableTabHeader(insertAt, title, pluginId); + rebuildPluginIndex(); + strip.setSelectedIndex(pluginTabIndex.getOrDefault(pluginId, insertAt)); + } + + public void focusPluginsTab() { + strip.setSelectedIndex(0); + } + + boolean isPluginTabOpen(String pluginId) { + return pluginTabIndex.containsKey(pluginId); + } + + int getTabCount() { + return strip.getTabCount(); + } + + private void showContentAt(int index) { + contentHost.removeAll(); + if (index >= 0 && index < tabContents.size()) { + contentHost.add(tabContents.get(index), BorderLayout.CENTER); + } + contentHost.revalidate(); + contentHost.repaint(); + } + + private void setClosableTabHeader(int tabIndex, String title, String pluginId) { + JPanel header = new JPanel(new BorderLayout(2, 0)); + header.setOpaque(false); + header.setBorder(new EmptyBorder(0, 4, 0, 2)); + JLabel label = new JLabel(title); + header.add(label, BorderLayout.CENTER); + header.add(ShellUi.roundTabCloseButton(() -> closePluginTab(pluginId)), BorderLayout.EAST); + strip.setTabComponentAt(tabIndex, header); + } + + private void updateTabHeader(int tabIndex, String title, String pluginId) { + strip.setToolTipTextAt(tabIndex, title); + if (strip.getTabComponentAt(tabIndex) instanceof JPanel header) { + for (java.awt.Component c : header.getComponents()) { + if (c instanceof JLabel) { + ((JLabel) c).setText(title); + } + } + } else { + setClosableTabHeader(tabIndex, title, pluginId); + } + } + + void closePluginTab(String pluginId) { + Integer index = pluginTabIndex.get(pluginId); + if (index == null || index <= 0 || index >= strip.getTabCount() - 1) { + return; + } + onPluginTabClosed.accept(pluginId); + strip.removeTabAt(index); + tabContents.remove(index.intValue()); + pluginTabIndex.remove(pluginId); + rebuildPluginIndex(); + if (strip.getSelectedIndex() < 0 || strip.getSelectedIndex() >= strip.getTabCount()) { + strip.setSelectedIndex(0); + } else { + showContentAt(strip.getSelectedIndex()); + } + fireActivePlugin(); + } + + private void rebuildPluginIndex() { + pluginTabIndex.clear(); + for (int i = 1; i < strip.getTabCount() - 1; i++) { + if (i >= tabContents.size()) { + break; + } + JComponent content = tabContents.get(i); + Object id = content.getClientProperty("pitiupi.pluginId"); + if (id instanceof String s) { + pluginTabIndex.put(s, i); + if (strip.getTabComponentAt(i) == null) { + String tip = strip.getToolTipTextAt(i); + setClosableTabHeader(i, tip != null ? tip : s, s); + } + } + } + } + + private void fireActivePlugin() { + int idx = strip.getSelectedIndex(); + if (idx <= 0 || idx >= strip.getTabCount() - 1 || idx >= tabContents.size()) { + onActivePluginChanged.accept(null); + return; + } + Object id = tabContents.get(idx).getClientProperty("pitiupi.pluginId"); + onActivePluginChanged.accept(id instanceof String s ? s : null); + } + + private static JPanel placeholderPanel(String title) { + JPanel p = new JPanel(new BorderLayout()); + p.setBorder(new EmptyBorder(24, 24, 24, 24)); + JLabel label = new JLabel( + "
Plugin " + escapeHtml(title) + "
" + + "Este plugin não expôs um painel para a aba central.
", + SwingConstants.CENTER + ); + p.add(label, BorderLayout.CENTER); + return p; + } + + private static String escapeHtml(String text) { + return text.replace("&", "&").replace("<", "<"); + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/UiGeometry.java b/pitiupi/src/pitiupi/presentation/shell/UiGeometry.java new file mode 100644 index 0000000..d3da8e2 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/UiGeometry.java @@ -0,0 +1,105 @@ +package pitiupi.presentation.shell; + +import java.awt.Dimension; +import java.awt.GraphicsEnvironment; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Toolkit; +import java.awt.Window; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; + +/** + * Cálculos de tamanho/posição para diálogos e janelas. + */ +public final class UiGeometry { + + private UiGeometry() { + } + + public static Dimension fractionOfScreen(double widthFraction, double heightFraction) { + Rectangle bounds = usableScreenBounds(); + int w = Math.max(320, (int) (bounds.width * widthFraction)); + int h = Math.max(240, (int) (bounds.height * heightFraction)); + return new Dimension(w, h); + } + + public static Rectangle usableScreenBounds() { + GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); + if (!GraphicsEnvironment.isHeadless()) { + Rectangle configBounds = ge.getDefaultScreenDevice() + .getDefaultConfiguration() + .getBounds(); + if (configBounds.width > 0 && configBounds.height > 0) { + return configBounds; + } + } + Dimension screen = Toolkit.getDefaultToolkit().getScreenSize(); + if (screen.width > 0 && screen.height > 0) { + return new Rectangle(0, 0, screen.width, screen.height); + } + return ge.getMaximumWindowBounds(); + } + + public static Dimension effectiveSize(Dimension size, Dimension preferred) { + int w = size.width > 0 ? size.width : preferred.width; + int h = size.height > 0 ? size.height : preferred.height; + if (w <= 0) { + w = preferred.width > 0 ? preferred.width : 320; + } + if (h <= 0) { + h = preferred.height > 0 ? preferred.height : 240; + } + return new Dimension(w, h); + } + + public static Dimension effectiveWindowSize(Window window) { + return effectiveSize(window.getSize(), window.getPreferredSize()); + } + + public static Point centerLocation(Rectangle bounds, Dimension windowSize) { + int x = bounds.x + Math.max(0, (bounds.width - windowSize.width) / 2); + int y = bounds.y + Math.max(0, (bounds.height - windowSize.height) / 2); + return new Point(x, y); + } + + public static void centerOnScreen(Window window) { + window.setLocationByPlatform(false); + Rectangle bounds = usableScreenBounds(); + Dimension size = effectiveWindowSize(window); + Point location = centerLocation(bounds, size); + window.setLocation(location); + } + + /** + * Recentra após o WM reposicionar a janela (comum no WSLg). + */ + public static void installCenterOnShow(Window window) { + window.setLocationByPlatform(false); + WindowAdapter windowAdapter = new WindowAdapter() { + @Override + public void windowOpened(WindowEvent e) { + centerOnScreen(window); + } + }; + ComponentAdapter componentAdapter = new ComponentAdapter() { + @Override + public void componentShown(ComponentEvent e) { + if (e.getComponent() == window) { + centerOnScreen(window); + } + } + }; + window.addWindowListener(windowAdapter); + window.addComponentListener(componentAdapter); + } + + public static void presentWindow(Window window) { + installCenterOnShow(window); + centerOnScreen(window); + window.setVisible(true); + javax.swing.SwingUtilities.invokeLater(() -> centerOnScreen(window)); + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/ui/ShellUi.java b/pitiupi/src/pitiupi/presentation/shell/ui/ShellUi.java new file mode 100644 index 0000000..d4cb514 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/ui/ShellUi.java @@ -0,0 +1,215 @@ +package pitiupi.presentation.shell.ui; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.UIManager; +import javax.swing.border.EmptyBorder; +import javax.swing.border.LineBorder; +import javax.swing.border.MatteBorder; +import javax.swing.border.TitledBorder; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Insets; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; + +/** + * Estilo visual compartilhado da shell (Swing nativo / Nimbus). + */ +public final class ShellUi { + + private static final Color CLOSE_HOVER = new Color(180, 40, 40); + private static final String NOTIFICATION_DOT_COLOR = "#e53935"; + private static final String NOTIFICATION_COUNT_COLOR = "#757575"; + private static final Dimension GALLERY_TILE_SIZE = new Dimension(140, 38); + + private ShellUi() { + } + + public static Color shellEdgeColor() { + Color edge = UIManager.getColor("TabbedPane.borderHighlightColor"); + if (edge == null) { + edge = UIManager.getColor("Component.borderColor"); + } + if (edge == null) { + edge = Color.GRAY; + } + return edge; + } + + public static JPanel sectionPanel(String title) { + JPanel panel = new JPanel(); + TitledBorder titled = BorderFactory.createTitledBorder( + BorderFactory.createEmptyBorder(2, 0, 0, 0), + title, + TitledBorder.LEADING, + TitledBorder.TOP + ); + titled.setTitleFont(panel.getFont().deriveFont(Font.BOLD)); + panel.setBorder(BorderFactory.createCompoundBorder( + new MatteBorder(1, 0, 0, 0, shellEdgeColor()), + BorderFactory.createCompoundBorder( + new EmptyBorder(6, 8, 8, 8), + titled + ) + )); + return panel; + } + + public static JPanel sidebarPanel(String title, java.awt.Component content) { + JPanel panel = new JPanel(new BorderLayout(0, 4)); + JLabel header = new JLabel(title); + header.setFont(header.getFont().deriveFont(Font.BOLD)); + header.setBorder(new EmptyBorder(8, 10, 4, 8)); + panel.add(header, BorderLayout.NORTH); + panel.add(content, BorderLayout.CENTER); + panel.setBorder(new MatteBorder(0, 1, 0, 0, shellEdgeColor())); + return panel; + } + + public static void applySectionTitleBorder(JPanel panel, String title) { + TitledBorder titled = BorderFactory.createTitledBorder( + BorderFactory.createEmptyBorder(2, 0, 0, 0), + title, + TitledBorder.LEADING, + TitledBorder.TOP + ); + titled.setTitleFont(panel.getFont().deriveFont(Font.BOLD)); + panel.setBorder(BorderFactory.createCompoundBorder( + new MatteBorder(1, 0, 0, 0, shellEdgeColor()), + BorderFactory.createCompoundBorder( + new EmptyBorder(4, 6, 6, 6), + titled + ) + )); + } + + public static void applyPluginGalleryTile(JButton button) { + Color bg = UIManager.getColor("Panel.background"); + Color edge = shellEdgeColor(); + button.setFocusPainted(false); + button.setMargin(new Insets(4, 10, 4, 10)); + button.setPreferredSize(GALLERY_TILE_SIZE); + button.setMaximumSize(GALLERY_TILE_SIZE); + button.setMinimumSize(GALLERY_TILE_SIZE); + button.setOpaque(true); + button.setContentAreaFilled(true); + button.setBorder(new LineBorder(edge, 1, true)); + button.setBackground(bg); + button.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + Color hoverBg = UIManager.getColor("List.selectionBackground"); + if (hoverBg == null) { + hoverBg = new Color(220, 220, 220); + } + final Color hover = hoverBg; + final Color normalBg = bg; + button.addMouseListener(new MouseAdapter() { + @Override + public void mouseEntered(MouseEvent e) { + button.setBackground(hover); + } + + @Override + public void mouseExited(MouseEvent e) { + button.setBackground(normalBg); + } + }); + } + + public static void borderlessScroll(JScrollPane scroll) { + if (scroll == null) { + return; + } + scroll.setBorder(BorderFactory.createEmptyBorder()); + scroll.setViewportBorder(BorderFactory.createEmptyBorder()); + } + + public static String formatPeerListLabel(String displayName, int notificationCount) { + if (notificationCount <= 0) { + return displayName; + } + return "" + notificationBadgePrefix() + + escapeHtml(displayName) + + " (" + notificationCount + ")"; + } + + public static String formatActionLabelWithBadge(String label, int pendingCount) { + if (pendingCount <= 0) { + return label; + } + return "" + notificationBadgePrefix() + + escapeHtml(label) + + " (" + pendingCount + ")"; + } + + private static String notificationBadgePrefix() { + return "● "; + } + + private static String escapeHtml(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&").replace("<", "<"); + } + + public static JLabel mutedLabel(String text) { + JLabel label = new JLabel(text); + Color muted = UIManager.getColor("Label.disabledForeground"); + if (muted != null) { + label.setForeground(muted); + } + return label; + } + + public static JButton compactCloseButton(Runnable onClose) { + return roundTabCloseButton(onClose); + } + + public static void alignButtonToTabStrip(JButton button) { + if (button == null) { + return; + } + button.setMargin(new Insets(2, 8, 2, 8)); + button.setFocusPainted(false); + } + + public static JButton roundTabCloseButton(Runnable onClose) { + JButton close = new JButton("×"); + close.setFont(close.getFont().deriveFont(Font.BOLD, 12f)); + Dimension size = new Dimension(20, 20); + close.setPreferredSize(size); + close.setMaximumSize(size); + close.setMinimumSize(size); + close.setMargin(new Insets(0, 0, 0, 0)); + close.setBorder(BorderFactory.createEmptyBorder()); + close.setBorderPainted(true); + close.setContentAreaFilled(true); + close.setFocusPainted(false); + close.setOpaque(true); + close.setBackground(UIManager.getColor("Panel.background")); + close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + close.setToolTipText("Fechar aba"); + close.addActionListener(e -> onClose.run()); + close.addMouseListener(new MouseAdapter() { + @Override + public void mouseEntered(MouseEvent e) { + close.setForeground(Color.WHITE); + close.setBackground(CLOSE_HOVER); + } + + @Override + public void mouseExited(MouseEvent e) { + close.setForeground(UIManager.getColor("Label.foreground")); + close.setBackground(UIManager.getColor("Panel.background")); + } + }); + return close; + } +} diff --git a/pitiupi/test/lib/README.md b/pitiupi/test/lib/README.md new file mode 100644 index 0000000..7c26db3 --- /dev/null +++ b/pitiupi/test/lib/README.md @@ -0,0 +1,7 @@ +# JUnit (núcleo) + +O target `ant test` usa `junit-platform-console-standalone-1.10.2.jar` nesta pasta. + +Se o arquivo não existir, baixe de Maven Central: + +https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/1.10.2/junit-platform-console-standalone-1.10.2.jar diff --git a/pitiupi/test/pitiupi/application/activity/PeerActivityInboxTest.java b/pitiupi/test/pitiupi/application/activity/PeerActivityInboxTest.java new file mode 100644 index 0000000..b25353e --- /dev/null +++ b/pitiupi/test/pitiupi/application/activity/PeerActivityInboxTest.java @@ -0,0 +1,19 @@ +package pitiupi.application.activity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PeerActivityInboxTest { + + @Test + void recordsAndClearsPerAction() { + PeerActivityInbox inbox = new PeerActivityInbox(); + inbox.record("peer-1", "plugin.chat", "chat.peer"); + inbox.record("peer-1", "plugin.chat", "chat.peer"); + assertEquals(2, inbox.countForAction("peer-1", "plugin.chat", "chat.peer")); + assertEquals(2, inbox.totalForPeer("peer-1")); + inbox.clearAction("peer-1", "plugin.chat", "chat.peer"); + assertEquals(0, inbox.totalForPeer("peer-1")); + } +} diff --git a/pitiupi/test/pitiupi/application/invites/InviteInboxServiceTest.java b/pitiupi/test/pitiupi/application/invites/InviteInboxServiceTest.java new file mode 100644 index 0000000..5f2d642 --- /dev/null +++ b/pitiupi/test/pitiupi/application/invites/InviteInboxServiceTest.java @@ -0,0 +1,65 @@ +package pitiupi.application.invites; + +import org.junit.jupiter.api.Test; +import pitiupi.api.plugin.InviteDetails; +import pitiupi.application.presence.PresenceService; +import pitiupi.control.HeartbeatManager; +import pitiupi.models.Messages.HeartbeatMessage; +import pitiupi.models.Messages.SessionInviteMessage; + +import java.net.InetAddress; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InviteInboxServiceTest { + + @Test + void inviteIncrementsNotificationOnSender() throws Exception { + HeartbeatManager heartbeat = HeartbeatManager.createForTesting(); + InviteInboxService inbox = new InviteInboxService(heartbeat); + PresenceService presence = new PresenceService(heartbeat, inbox); + + heartbeat.receiveHeartbeat(new HeartbeatMessage("bob", "peer-bob"), InetAddress.getByName("127.0.0.1")); + + SessionInviteMessage invite = new SessionInviteMessage( + "inv-1", + "peer-alice", + "Alice", + heartbeat.getInstanceId(), + "pitiupi.chat", + SessionInviteKind.CHAT, + "Oi" + ); + inbox.receive(invite); + + assertEquals(1, inbox.getNotificationCount("peer-alice")); + var views = presence.listPeerViews(); + var bob = views.stream().filter(v -> "peer-bob".equals(v.getInstanceId())).findFirst(); + assertTrue(bob.isEmpty() || bob.get().getNotificationCount() == 0); + } + + @Test + void acceptInviteDispatchesHandler() { + HeartbeatManager heartbeat = HeartbeatManager.createForTesting(); + InviteInboxService inbox = new InviteInboxService(heartbeat); + AtomicReference captured = new AtomicReference<>(); + + inbox.setInviteHandler("pitiupi.chat", captured::set); + + SessionInviteMessage invite = new SessionInviteMessage( + "inv-2", + "peer-alice", + "Alice", + "", + "pitiupi.chat", + SessionInviteKind.CHAT, + "teste" + ); + inbox.receive(invite); + assertTrue(inbox.acceptInvite("inv-2")); + assertEquals("inv-2", captured.get().getInviteId()); + assertEquals(0, inbox.getNotificationCount("peer-alice")); + } +} diff --git a/pitiupi/test/pitiupi/application/messaging/MessageDispatcherTest.java b/pitiupi/test/pitiupi/application/messaging/MessageDispatcherTest.java new file mode 100644 index 0000000..01f540f --- /dev/null +++ b/pitiupi/test/pitiupi/application/messaging/MessageDispatcherTest.java @@ -0,0 +1,25 @@ +package pitiupi.application.messaging; + +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MessageDispatcherTest { + + @Test + void sendTargetFactories() throws Exception { + assertEquals(SendTarget.BroadcastAll.class, SendTarget.broadcastAll().getClass()); + InetAddress addr = InetAddress.getByName("127.0.0.1"); + assertEquals(addr, ((SendTarget.SinglePeer) SendTarget.singlePeer(addr)).address()); + } + + @Test + void transportModeLabels() { + assertEquals("UDP multicast", TransportMode.MULTICAST_UDP.getLabel()); + assertEquals("UDP unicast", TransportMode.UNICAST_UDP.getLabel()); + assertTrue(TransportMode.MULTICAST_UDP.describeForUi(true).contains("(UDP multicast)")); + } +} diff --git a/pitiupi/test/pitiupi/application/messaging/PluginTransportCatalogTest.java b/pitiupi/test/pitiupi/application/messaging/PluginTransportCatalogTest.java new file mode 100644 index 0000000..b2ce28a --- /dev/null +++ b/pitiupi/test/pitiupi/application/messaging/PluginTransportCatalogTest.java @@ -0,0 +1,16 @@ +package pitiupi.application.messaging; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PluginTransportCatalogTest { + + @Test + void screenCastBroadcastExcludesTcp() { + var modes = PluginTransportCatalog.forBroadcast(PluginTransportCatalog.PluginKind.SCREEN_CAST); + assertTrue(modes.contains(TransportMode.MULTICAST_UDP)); + assertFalse(modes.contains(TransportMode.TCP)); + } +} diff --git a/pitiupi/test/pitiupi/application/peeractions/PeerActionBrokerTest.java b/pitiupi/test/pitiupi/application/peeractions/PeerActionBrokerTest.java new file mode 100644 index 0000000..44c1238 --- /dev/null +++ b/pitiupi/test/pitiupi/application/peeractions/PeerActionBrokerTest.java @@ -0,0 +1,54 @@ +package pitiupi.application.peeractions; + +import org.junit.jupiter.api.Test; +import pitiupi.application.presence.UserPeerView; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PeerActionBrokerTest { + + @Test + void exposesOnlyActivePluginActions() { + PeerActionBroker broker = new PeerActionBroker(); + broker.registerPlugin("plugin.a"); + broker.registerPlugin("plugin.b"); + + AtomicBoolean ran = new AtomicBoolean(false); + broker.registerAction("plugin.a", PeerAction.of("a1", "Ação A", PeerActionTarget.SINGLE_PEER, t -> ran.set(true))); + broker.registerAction("plugin.b", PeerAction.of("b1", "Ação B", PeerActionTarget.SINGLE_PEER, t -> { + })); + + broker.setActivePlugin("plugin.b"); + assertEquals(1, broker.getActionsFor(PeerActionTarget.SINGLE_PEER).size()); + assertEquals("b1", broker.getActionsFor(PeerActionTarget.SINGLE_PEER).get(0).getId()); + + broker.setActivePlugin("plugin.a"); + broker.getActionsFor(PeerActionTarget.SINGLE_PEER).get(0).getHandler() + .run(new PeerTarget.Single(UserPeerView.broadcastTarget())); + assertTrue(ran.get()); + } + + @Test + void getAllActionsForGroupsByPlugin() { + PeerActionBroker broker = new PeerActionBroker(); + broker.registerPlugin("plugin.a"); + broker.registerPlugin("plugin.b"); + broker.registerPluginMetadata("plugin.a", "Chat"); + broker.registerPluginMetadata("plugin.b", "Tela"); + + broker.registerAction("plugin.a", PeerAction.of("a1", "Abrir", PeerActionTarget.ALL_PEERS, t -> { + })); + broker.registerAction("plugin.b", PeerAction.of("b1", "Focar", PeerActionTarget.ALL_PEERS, t -> { + })); + broker.registerAction("plugin.b", PeerAction.of("b2", "Privado", PeerActionTarget.SINGLE_PEER, t -> { + })); + + assertEquals(2, broker.getAllActionsFor(PeerActionTarget.ALL_PEERS).size()); + assertEquals("Chat", broker.getAllActionsFor(PeerActionTarget.ALL_PEERS).get(0).pluginDisplayName()); + assertEquals(1, broker.getAllActionsFor(PeerActionTarget.SINGLE_PEER).size()); + assertEquals("Tela", broker.getAllActionsFor(PeerActionTarget.SINGLE_PEER).get(0).pluginDisplayName()); + } +} diff --git a/pitiupi/test/pitiupi/application/presence/PresenceServiceTest.java b/pitiupi/test/pitiupi/application/presence/PresenceServiceTest.java new file mode 100644 index 0000000..4d85076 --- /dev/null +++ b/pitiupi/test/pitiupi/application/presence/PresenceServiceTest.java @@ -0,0 +1,18 @@ +package pitiupi.application.presence; + +import org.junit.jupiter.api.Test; +import pitiupi.control.HeartbeatManager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PresenceServiceTest { + + @Test + void listPeerViewsStartsWithBroadcastTarget() { + PresenceService service = new PresenceService(HeartbeatManager.createForTesting()); + var views = service.listPeerViews(); + assertTrue(views.get(0).isBroadcastTarget()); + assertEquals("Todos", views.get(0).getDisplayName()); + } +} diff --git a/pitiupi/test/pitiupi/control/HeartbeatManagerGoodbyeTest.java b/pitiupi/test/pitiupi/control/HeartbeatManagerGoodbyeTest.java new file mode 100644 index 0000000..68ec29b --- /dev/null +++ b/pitiupi/test/pitiupi/control/HeartbeatManagerGoodbyeTest.java @@ -0,0 +1,25 @@ +package pitiupi.control; + +import org.junit.jupiter.api.Test; +import pitiupi.models.Messages.GoodbyeMessage; +import pitiupi.models.Messages.HeartbeatMessage; + +import java.net.InetAddress; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HeartbeatManagerGoodbyeTest { + + @Test + void receiveGoodbyeRemovesPeer() throws Exception { + HeartbeatManager manager = HeartbeatManager.createForTesting(); + InetAddress addr = InetAddress.getByName("127.0.0.1"); + + manager.receiveHeartbeat(new HeartbeatMessage("alice", "peer-1"), addr); + assertEquals(1, manager.getPeers().size()); + + manager.receiveGoodbye(new GoodbyeMessage("alice", "peer-1")); + assertTrue(manager.getPeers().isEmpty()); + } +} diff --git a/pitiupi/test/pitiupi/models/PluginDisplayNameTest.java b/pitiupi/test/pitiupi/models/PluginDisplayNameTest.java new file mode 100644 index 0000000..b397aa3 --- /dev/null +++ b/pitiupi/test/pitiupi/models/PluginDisplayNameTest.java @@ -0,0 +1,97 @@ +package pitiupi.models; + +import org.junit.jupiter.api.Test; +import pitiupi.models.Messages.Message; + +import javax.swing.JPanel; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +class PluginDisplayNameTest { + + @Test + void defaultDisplayNameMatchesName() { + Plugin plugin = new Plugin() { + @Override + public String getName() { + return "Tech"; + } + + @Override + public String getAuthor() { + return "test"; + } + + @Override + public String getVersion() { + return "1"; + } + + @Override + public String getIdentifier() { + return "id"; + } + + @Override + public void createPlugin(pitiupi.control.ConnectionManager connectionManager, + pitiupi.control.GuiManager guiManager) { + } + + @Override + public List> getMessages() { + return List.of(); + } + + @Override + public void receiveMessage(Message message) { + } + }; + assertEquals("Tech", plugin.getDisplayName()); + } + + @Test + void bindShellWorkspaceContentExposesPanel() { + JPanel panel = new JPanel(); + Plugin plugin = new Plugin() { + @Override + public String getName() { + return "P"; + } + + @Override + public String getAuthor() { + return "a"; + } + + @Override + public String getVersion() { + return "1"; + } + + @Override + public String getIdentifier() { + return "id"; + } + + @Override + public void createPlugin(pitiupi.control.ConnectionManager connectionManager, + pitiupi.control.GuiManager guiManager) { + } + + @Override + public List> getMessages() { + return List.of(); + } + + @Override + public void receiveMessage(Message message) { + } + }; + assertNull(plugin.getShellWorkspaceContent()); + plugin.bindShellWorkspaceContent(panel); + assertSame(panel, plugin.getShellWorkspaceContent()); + } +} diff --git a/pitiupi/test/pitiupi/platform/PitiupiHomeTest.java b/pitiupi/test/pitiupi/platform/PitiupiHomeTest.java new file mode 100644 index 0000000..aa7154a --- /dev/null +++ b/pitiupi/test/pitiupi/platform/PitiupiHomeTest.java @@ -0,0 +1,29 @@ +package pitiupi.platform; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PitiupiHomeTest { + + @Test + void ensureLayoutCreatesPluginsAndCache(@TempDir Path temp) throws Exception { + PitiupiHome home = PitiupiHome.open(temp.resolve("pitiupi-data")); + home.ensureLayout(); + assertTrue(Files.isDirectory(home.pluginsDirectory())); + assertTrue(Files.isDirectory(home.cacheDirectory())); + assertTrue(Files.isDirectory(home.logsDirectory())); + assertTrue(Files.isRegularFile(home.configFile())); + } + + @Test + void pluginsDirectoryUnderRoot(@TempDir Path temp) { + Path root = temp.resolve("custom-home"); + PitiupiHome home = PitiupiHome.open(root); + assertTrue(home.pluginsDirectory().startsWith(home.rootDirectory())); + } +} diff --git a/pitiupi/test/pitiupi/plugins/store/PluginStoreTest.java b/pitiupi/test/pitiupi/plugins/store/PluginStoreTest.java new file mode 100644 index 0000000..882b861 --- /dev/null +++ b/pitiupi/test/pitiupi/plugins/store/PluginStoreTest.java @@ -0,0 +1,67 @@ +package pitiupi.plugins.store; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import pitiupi.errors.PitiupiException; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PluginStoreTest { + + @TempDir + Path temp; + + private Path pluginsDir; + private Path cacheDir; + private PluginStore store; + + @BeforeEach + void setUp() throws Exception { + pluginsDir = temp.resolve("plugins"); + cacheDir = temp.resolve("cache"); + Files.createDirectories(pluginsDir); + Files.createDirectories(cacheDir); + store = new PluginStore(pluginsDir, cacheDir); + } + + @Test + void installCopiesJar() throws Exception { + Path source = temp.resolve("sample.jar"); + Files.writeString(source, "fake-jar"); + + Path installed = store.installFromFile(source); + assertTrue(Files.isRegularFile(installed)); + assertEquals("sample.jar", installed.getFileName().toString()); + + List list = store.listInstalled(); + assertEquals(1, list.size()); + } + + @Test + void removeRejectsPathOutsidePlugins() { + Path outside = temp.resolve("evil.jar"); + assertThrows(PitiupiException.class, () -> store.remove(outside)); + } + + @Test + void installRejectsNonJar() { + Path txt = temp.resolve("not-a-jar.txt"); + assertThrows(PitiupiException.class, () -> { + try { + Files.writeString(txt, "x"); + store.installFromFile(txt); + } catch (PitiupiException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/pitiupi/test/pitiupi/presentation/shell/LocalNetworkInfoTest.java b/pitiupi/test/pitiupi/presentation/shell/LocalNetworkInfoTest.java new file mode 100644 index 0000000..2df765a --- /dev/null +++ b/pitiupi/test/pitiupi/presentation/shell/LocalNetworkInfoTest.java @@ -0,0 +1,23 @@ +package pitiupi.presentation.shell; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LocalNetworkInfoTest { + + @Test + void bestLocalIpv4ReturnsNonEmpty() { + String ip = LocalNetworkInfo.bestLocalIpv4(); + assertNotNull(ip); + assertFalse(ip.isBlank()); + } + + @Test + void isLocalIpv4DetectsLoopback() { + assertTrue(LocalNetworkInfo.isLocalIpv4("127.0.0.1")); + assertTrue(LocalNetworkInfo.isLocalIpv4(LocalNetworkInfo.bestLocalIpv4())); + } +} diff --git a/pitiupi/test/pitiupi/presentation/shell/LocalUserIdentityTest.java b/pitiupi/test/pitiupi/presentation/shell/LocalUserIdentityTest.java new file mode 100644 index 0000000..cdbb052 --- /dev/null +++ b/pitiupi/test/pitiupi/presentation/shell/LocalUserIdentityTest.java @@ -0,0 +1,20 @@ +package pitiupi.presentation.shell; + +import org.junit.jupiter.api.Test; +import pitiupi.control.ConnectionManager; +import pitiupi.control.plugins.PluginManager; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LocalUserIdentityTest { + + @Test + void isLocalSenderMatchesUserNameAndLoopback() { + ConnectionManager cm = new ConnectionManager("alice", new PluginManager(), 5000); + assertTrue(LocalUserIdentity.isLocalSender(cm, "alice", "127.0.0.1")); + assertTrue(LocalUserIdentity.isLocalSender(cm, "alice", LocalNetworkInfo.bestLocalIpv4())); + assertFalse(LocalUserIdentity.isLocalSender(cm, "bob", "127.0.0.1")); + assertFalse(LocalUserIdentity.isLocalSender(cm, "alice", "8.8.8.8")); + } +} diff --git a/pitiupi/test/pitiupi/presentation/shell/SessionPeerLabelsTest.java b/pitiupi/test/pitiupi/presentation/shell/SessionPeerLabelsTest.java new file mode 100644 index 0000000..bfbfe95 --- /dev/null +++ b/pitiupi/test/pitiupi/presentation/shell/SessionPeerLabelsTest.java @@ -0,0 +1,28 @@ +package pitiupi.presentation.shell; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SessionPeerLabelsTest { + + @Test + void sessionBannerShowsIpOnceWhenNameEqualsIp() { + String html = SessionPeerLabels.sessionBannerHtml("192.168.1.10", "192.168.1.10"); + assertEquals("Sessão: IP 192.168.1.10", html); + assertTrue(!html.contains("(")); + } + + @Test + void sessionBannerShowsNameAndIp() { + String html = SessionPeerLabels.sessionBannerHtml("Alice", "192.168.1.10"); + assertEquals("Sessão: Alice · IP 192.168.1.10", html); + } + + @Test + void sessionBannerEscapesHtml() { + String html = SessionPeerLabels.sessionBannerHtml("A & B", "10.0.0.1"); + assertTrue(html.contains("A & B")); + } +} diff --git a/pitiupi/test/pitiupi/presentation/shell/SessionTabBarStripTest.java b/pitiupi/test/pitiupi/presentation/shell/SessionTabBarStripTest.java new file mode 100644 index 0000000..4488540 --- /dev/null +++ b/pitiupi/test/pitiupi/presentation/shell/SessionTabBarStripTest.java @@ -0,0 +1,29 @@ +package pitiupi.presentation.shell; + +import org.junit.jupiter.api.Test; + +import javax.swing.JPanel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SessionTabBarStripTest { + + @Test + void stripStaysShorterThanLargeTabContentWouldBe() { + SessionTabBar bar = new SessionTabBar(); + bar.addOrSelectTab(PluginSessionKeys.ROOM, "Sala geral", new JPanel(), false); + int stripHeight = bar.getPanel().getPreferredSize().height; + assertTrue(stripHeight < 80, "faixa de abas deve ser baixa, obteve " + stripHeight); + } + + @Test + void secondTabDoesNotCollapseStrip() { + SessionTabBar bar = new SessionTabBar(); + bar.addOrSelectTab(PluginSessionKeys.ROOM, "Sala geral", new JPanel(), false); + bar.addOrSelectTab(PluginSessionKeys.PEER_DRAFT, "Nova conversa", new JPanel(), true); + assertEquals(2, bar.getTabCount()); + bar.selectTab(PluginSessionKeys.PEER_DRAFT); + assertEquals(PluginSessionKeys.PEER_DRAFT, bar.selectedKey()); + } +} diff --git a/pitiupi/test/pitiupi/presentation/shell/TabWorkspacePanelTest.java b/pitiupi/test/pitiupi/presentation/shell/TabWorkspacePanelTest.java new file mode 100644 index 0000000..92e59f2 --- /dev/null +++ b/pitiupi/test/pitiupi/presentation/shell/TabWorkspacePanelTest.java @@ -0,0 +1,30 @@ +package pitiupi.presentation.shell; + +import org.junit.jupiter.api.Test; + +import javax.swing.JPanel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TabWorkspacePanelTest { + + @Test + void openAndCloseDynamicTabKeepsFixedTabs() { + TabWorkspacePanel workspace = new TabWorkspacePanel(new JPanel(), new JPanel()); + assertEquals(2, workspace.getTabCount()); + + workspace.openOrFocusTab("plugin.a", "Plugin A", new JPanel()); + assertEquals(3, workspace.getTabCount()); + assertTrue(workspace.isPluginTabOpen("plugin.a")); + + workspace.closePluginTab("plugin.a"); + assertEquals(2, workspace.getTabCount()); + assertFalse(workspace.isPluginTabOpen("plugin.a")); + assertEquals(TabWorkspacePanel.TAB_PLUGINS, + workspace.getTabbedPane().getTitleAt(0)); + assertEquals(TabWorkspacePanel.TAB_SETTINGS, + workspace.getTabbedPane().getTitleAt(1)); + } +} diff --git a/pitiupi/test/pitiupi/presentation/shell/UiGeometryTest.java b/pitiupi/test/pitiupi/presentation/shell/UiGeometryTest.java new file mode 100644 index 0000000..d319e75 --- /dev/null +++ b/pitiupi/test/pitiupi/presentation/shell/UiGeometryTest.java @@ -0,0 +1,47 @@ +package pitiupi.presentation.shell; + +import org.junit.jupiter.api.Test; + +import java.awt.Dimension; +import java.awt.Point; +import java.awt.Rectangle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UiGeometryTest { + + @Test + void fractionOfScreenRespectsMinimum() { + Dimension d = UiGeometry.fractionOfScreen(0.3, 0.3); + assertTrue(d.width >= 320); + assertTrue(d.height >= 240); + } + + @Test + void centerLocationWithinBounds() { + Rectangle bounds = new Rectangle(100, 50, 800, 600); + Dimension window = new Dimension(200, 100); + Point p = UiGeometry.centerLocation(bounds, window); + assertEquals(400, p.x); + assertEquals(300, p.y); + } + + @Test + void centerLocationWithOffsetMonitor() { + Rectangle bounds = new Rectangle(1920, 0, 1920, 1080); + Dimension window = new Dimension(400, 300); + Point p = UiGeometry.centerLocation(bounds, window); + assertEquals(1920 + (1920 - 400) / 2, p.x); + assertEquals(0 + (1080 - 300) / 2, p.y); + } + + @Test + void effectiveSizeUsesPreferredWhenSizeZero() { + Dimension size = new Dimension(0, 0); + Dimension preferred = new Dimension(480, 200); + Dimension effective = UiGeometry.effectiveSize(size, preferred); + assertEquals(480, effective.width); + assertEquals(200, effective.height); + } +} diff --git a/plugins.registry.json b/plugins.registry.json index 622c33a..94da93f 100644 --- a/plugins.registry.json +++ b/plugins.registry.json @@ -2,24 +2,17 @@ "plugins": [ { "id": "chat", - "dir": "pitiupi.chat", + "dir": "plugins/chat", "build": "ant", "jar": "dist/pitiupi.chat.jar", "menuCategory": "Social" }, { "id": "screencast", - "dir": "pitiupi.screenCast", + "dir": "plugins/screencast", "build": "ant", "jar": "dist/pitiupi.screenCast.jar", "menuCategory": "Streaming" - }, - { - "id": "mpchat", - "dir": "pitiupi.mpChat", - "build": "make", - "jar": "dist/mpChat.jar", - "menuCategory": "Social" } ] } diff --git a/pitiupi.chat/.idea/.gitignore b/plugins/chat/.idea/.gitignore similarity index 100% rename from pitiupi.chat/.idea/.gitignore rename to plugins/chat/.idea/.gitignore diff --git a/pitiupi.chat/.idea/libraries/pitiupi_chat.xml b/plugins/chat/.idea/libraries/pitiupi_chat.xml similarity index 100% rename from pitiupi.chat/.idea/libraries/pitiupi_chat.xml rename to plugins/chat/.idea/libraries/pitiupi_chat.xml diff --git a/pitiupi.chat/.idea/misc.xml b/plugins/chat/.idea/misc.xml similarity index 100% rename from pitiupi.chat/.idea/misc.xml rename to plugins/chat/.idea/misc.xml diff --git a/pitiupi.chat/.idea/modules.xml b/plugins/chat/.idea/modules.xml similarity index 100% rename from pitiupi.chat/.idea/modules.xml rename to plugins/chat/.idea/modules.xml diff --git a/pitiupi.chat/.idea/vcs.xml b/plugins/chat/.idea/vcs.xml similarity index 100% rename from pitiupi.chat/.idea/vcs.xml rename to plugins/chat/.idea/vcs.xml diff --git a/pitiupi.chat/build.xml b/plugins/chat/build.xml similarity index 100% rename from pitiupi.chat/build.xml rename to plugins/chat/build.xml diff --git a/pitiupi.chat/nbproject/build-impl.xml b/plugins/chat/nbproject/build-impl.xml similarity index 100% rename from pitiupi.chat/nbproject/build-impl.xml rename to plugins/chat/nbproject/build-impl.xml diff --git a/pitiupi.chat/nbproject/genfiles.properties b/plugins/chat/nbproject/genfiles.properties similarity index 100% rename from pitiupi.chat/nbproject/genfiles.properties rename to plugins/chat/nbproject/genfiles.properties diff --git a/pitiupi.chat/nbproject/project.properties b/plugins/chat/nbproject/project.properties similarity index 99% rename from pitiupi.chat/nbproject/project.properties rename to plugins/chat/nbproject/project.properties index b87aa66..45f36e7 100644 --- a/pitiupi.chat/nbproject/project.properties +++ b/plugins/chat/nbproject/project.properties @@ -77,7 +77,7 @@ jlink.launcher.name=pitiupi.chat meta.inf.dir=${src.dir}/META-INF mkdist.disabled=true platform.active=default_platform -project.pitiupi=../pitiupi +project.pitiupi=../../pitiupi reference.pitiupi.jar=${project.pitiupi}/dist/pitiupi.jar run.classpath=\ ${javac.classpath}:\ diff --git a/pitiupi.chat/nbproject/project.xml b/plugins/chat/nbproject/project.xml similarity index 100% rename from pitiupi.chat/nbproject/project.xml rename to plugins/chat/nbproject/project.xml diff --git a/pitiupi.chat/pitiupi.chat.iml b/plugins/chat/pitiupi.chat.iml similarity index 88% rename from pitiupi.chat/pitiupi.chat.iml rename to plugins/chat/pitiupi.chat.iml index 4e2cbb3..24afdef 100644 --- a/pitiupi.chat/pitiupi.chat.iml +++ b/plugins/chat/pitiupi.chat.iml @@ -10,7 +10,7 @@ - + diff --git a/pitiupi.chat/src/META-INF/services/pitiupi.models.Plugin b/plugins/chat/src/META-INF/services/pitiupi.models.Plugin similarity index 100% rename from pitiupi.chat/src/META-INF/services/pitiupi.models.Plugin rename to plugins/chat/src/META-INF/services/pitiupi.models.Plugin diff --git a/pitiupi.chat/src/pitiupi.chat.iml b/plugins/chat/src/pitiupi.chat.iml similarity index 100% rename from pitiupi.chat/src/pitiupi.chat.iml rename to plugins/chat/src/pitiupi.chat.iml diff --git a/pitiupi.chat/src/pitiupi/chat/GUI/ChatMenu.java b/plugins/chat/src/pitiupi/chat/GUI/ChatMenu.java similarity index 100% rename from pitiupi.chat/src/pitiupi/chat/GUI/ChatMenu.java rename to plugins/chat/src/pitiupi/chat/GUI/ChatMenu.java diff --git a/pitiupi.chat/src/pitiupi/chat/GUI/ChatMulticastMenu.java b/plugins/chat/src/pitiupi/chat/GUI/ChatMulticastMenu.java similarity index 100% rename from pitiupi.chat/src/pitiupi/chat/GUI/ChatMulticastMenu.java rename to plugins/chat/src/pitiupi/chat/GUI/ChatMulticastMenu.java diff --git a/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java b/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java new file mode 100644 index 0000000..02c1476 --- /dev/null +++ b/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java @@ -0,0 +1,633 @@ +package pitiupi.chat.GUI; + +import pitiupi.application.messaging.MessageDispatcher; +import pitiupi.application.messaging.PluginTransportCatalog; +import pitiupi.application.messaging.SendTarget; +import pitiupi.application.messaging.TransportMode; +import pitiupi.application.presence.UserPeerView; +import pitiupi.chat.control.PrivateChatController; +import pitiupi.chat.control.PrivateChatUiSink; +import pitiupi.chat.net.ChatMessage; +import pitiupi.chat.net.LegacyMpChatMessage; +import pitiupi.control.ConnectionManager; +import pitiupi.models.User; +import pitiupi.presentation.shell.LocalNetworkInfo; +import pitiupi.presentation.shell.LocalUserIdentity; +import pitiupi.presentation.shell.PeerDraftPanel; +import pitiupi.presentation.shell.PluginRoutingPanel; +import pitiupi.presentation.shell.PluginSessionKeys; +import pitiupi.presentation.shell.PluginSessionWorkspace; +import pitiupi.presentation.shell.SessionPeerLabels; +import pitiupi.presentation.shell.SessionTabBar; +import pitiupi.presentation.shell.ui.ShellUi; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JEditorPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTextArea; +import javax.swing.AbstractAction; +import javax.swing.KeyStroke; +import javax.swing.ScrollPaneConstants; +import javax.swing.SwingUtilities; +import java.awt.Dimension; +import java.awt.event.HierarchyEvent; +import javax.swing.text.html.HTMLEditorKit; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.FlowLayout; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; +import java.net.InetAddress; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +public class ChatPanel extends JPanel implements PrivateChatUiSink { + + private static final String ROOM_KEY = "room"; + private static final String CARD_MAIN = "main"; + private static final String CARD_DRAFT = "draft"; + + private final JEditorPane transcript = new JEditorPane(); + private final JTextArea input = new JTextArea(); + private final ConnectionManager connectionManager; + private final PluginSessionWorkspace workspace; + private final PluginRoutingPanel routingPanel; + private final SessionTabBar sessionTabs; + private final CardLayout bodyLayout = new CardLayout(); + private final JPanel bodyCards = new JPanel(bodyLayout); + private String lastSessionKey = PluginSessionKeys.ROOM; + private final Map transcripts = new HashMap<>(); + private final Map peerDisplayNames = new HashMap<>(); + private String activeConversationKey = ROOM_KEY; + private PrivateChatController privateChatController; + private PeerDraftPanel draftPanel; + private Runnable pendingTranscriptFlush; + + public ChatPanel(ConnectionManager connectionManager) { + this.connectionManager = connectionManager; + this.routingPanel = new PluginRoutingPanel( + PluginTransportCatalog.PluginKind.CHAT, + "Use o IP do peer ou escolha alguém na lista lateral → Conversar. " + + "Envios usam a porta do login (barra de status); na sala, mesma porta para todos.", + true + ); + routingPanel.setToolTipText( + "UDP/TCP unicast usa o IP do peer e a porta " + connectionManager.getPort() + + " (a que você informou ao entrar)." + ); + this.workspace = new PluginSessionWorkspace(routingPanel, null); + this.sessionTabs = workspace.getSessionTabBar(); + setLayout(new BorderLayout(5, 5)); + setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + buildUi(); + initSessionTabs(); + routingPanel.addRoutingListener(s -> applyConversationKeyFromRouting()); + routingPanel.setPrivateTcpListener(selected -> { + if (selected) { + User user = resolveUserForSingle(); + if (user != null && privateChatController != null) { + privateChatController.openChat(user); + } + } + applyConversationKeyFromRouting(); + }); + selectRoomSession(); + } + + private void initSessionTabs() { + sessionTabs.addOrSelectTab( + PluginSessionKeys.ROOM, + "Sala geral", + "Sala geral (Todos)", + new JPanel(), + false + ); + sessionTabs.setCloseFallbackKey(PluginSessionKeys.ROOM); + sessionTabs.setOnTabSelected(this::onSessionTabSelected); + sessionTabs.setOnAddPeerRequested(this::openDraftPeerTab); + sessionTabs.setOnTabClosed(this::onSessionTabClosed); + } + + private void onSessionTabClosed(String key) { + if (PluginSessionKeys.PEER_DRAFT.equals(key)) { + if (draftPanel != null) { + draftPanel.clearIpField(); + } + SwingUtilities.invokeLater(this::restoreSessionAfterDraftClosed); + } + } + + private void applyConversationKeyFromRouting() { + String next = resolveConversationKey(); + if (!next.equals(activeConversationKey)) { + switchActiveConversation(next); + } + } + + private void onSessionTabSelected(String key) { + if (key == null) { + return; + } + lastSessionKey = key; + if (PluginSessionKeys.PEER_DRAFT.equals(key)) { + showBodyCard(CARD_DRAFT); + if (draftPanel != null) { + draftPanel.focusIpField(); + } + return; + } + showBodyCard(CARD_MAIN); + if (PluginSessionKeys.ROOM.equals(key)) { + routingPanel.bindRoomSession(); + switchActiveConversation(ROOM_KEY); + return; + } + if (key.startsWith("peer:") && !PluginSessionKeys.PEER_DRAFT.equals(key)) { + String ip = key.substring("peer:".length()); + String name = peerDisplayNames.getOrDefault(ip, ip); + routingPanel.bindPeerSession(ip, name); + switchActiveConversation(resolveConversationKey()); + } + } + + private void openDraftPeerTab() { + String previous = sessionTabs.selectedKey(); + if (previous != null && !PluginSessionKeys.PEER_DRAFT.equals(previous)) { + lastSessionKey = previous; + } + if (!sessionTabs.hasTab(PluginSessionKeys.PEER_DRAFT)) { + sessionTabs.addOrSelectTab( + PluginSessionKeys.PEER_DRAFT, + "Nova conversa", + "Informe o IP do peer", + new JPanel(), + true + ); + } else { + sessionTabs.selectTab(PluginSessionKeys.PEER_DRAFT); + } + showBodyCard(CARD_DRAFT); + if (draftPanel != null) { + draftPanel.focusIpField(); + } + } + + private void commitDraftIp(String ip) { + if (draftPanel != null) { + draftPanel.clearIpField(); + } + if (sessionTabs.hasTab(PluginSessionKeys.PEER_DRAFT)) { + sessionTabs.closeTab(PluginSessionKeys.PEER_DRAFT); + } + showBodyCard(CARD_MAIN); + openPeerTab(canonicalPeerIp(ip), ip); + } + + private void syncRoutingFromActiveSessionTab() { + String key = sessionTabs.selectedKey(); + if (PluginSessionKeys.ROOM.equals(key)) { + routingPanel.bindRoomSession(); + if (!ROOM_KEY.equals(activeConversationKey)) { + switchActiveConversation(ROOM_KEY); + } + return; + } + if (key != null && key.startsWith("peer:") && !PluginSessionKeys.PEER_DRAFT.equals(key)) { + String ip = key.substring("peer:".length()); + String name = peerDisplayNames.getOrDefault(ip, ip); + routingPanel.bindPeerSession(ip, name); + String conv = resolveConversationKey(); + if (!conv.equals(activeConversationKey)) { + switchActiveConversation(conv); + } + } + } + + private void restoreSessionAfterDraftClosed() { + showBodyCard(CARD_MAIN); + String restore = lastSessionKey != null && sessionTabs.hasTab(lastSessionKey) + ? lastSessionKey + : PluginSessionKeys.ROOM; + if (PluginSessionKeys.PEER_DRAFT.equals(restore)) { + restore = PluginSessionKeys.ROOM; + } + sessionTabs.selectTabSilently(restore); + if (PluginSessionKeys.ROOM.equals(restore)) { + routingPanel.bindRoomSession(); + switchActiveConversation(ROOM_KEY); + } else if (restore.startsWith("peer:")) { + String ip = restore.substring("peer:".length()); + String name = peerDisplayNames.getOrDefault(ip, ip); + routingPanel.bindPeerSession(ip, name); + switchActiveConversation(resolveConversationKey()); + } + } + + private void openPeerTab(String ip, String displayName) { + String canonical = canonicalPeerIp(ip); + String resolved = SessionPeerLabels.resolveDisplayName(connectionManager, canonical, displayName); + peerDisplayNames.put(canonical, resolved); + String tabKey = PluginSessionKeys.peer(canonical); + if (sessionTabs.hasTab(tabKey)) { + sessionTabs.setTabTitle(tabKey, resolved, canonical); + sessionTabs.selectTab(tabKey); + } else { + sessionTabs.addOrSelectTab(tabKey, resolved, canonical, new JPanel(), true); + } + routingPanel.bindPeerSession(canonical, resolved); + switchActiveConversation(resolveConversationKey()); + showBodyCard(CARD_MAIN); + } + + private static String canonicalPeerIp(String ip) { + if (ip == null || ip.isBlank()) { + return ""; + } + try { + return InetAddress.getByName(ip.trim()).getHostAddress(); + } catch (Exception ex) { + return ip.trim(); + } + } + + private void selectRoomSession() { + routingPanel.bindRoomSession(); + switchActiveConversation(ROOM_KEY); + showBodyCard(CARD_MAIN); + } + + public void setPrivateChatController(PrivateChatController privateChatController) { + this.privateChatController = privateChatController; + } + + public void focusGeneralRoom() { + sessionTabs.selectTab(PluginSessionKeys.ROOM); + } + + public void focusPeer(UserPeerView peer) { + if (peer == null || peer.isBroadcastTarget() || peer.isSelf()) { + focusGeneralRoom(); + return; + } + peer.getInetAddress().ifPresent(addr -> + openPeerTab(addr.getHostAddress(), peer.getDisplayName())); + } + + public void enablePrivateTcpForPeer(UserPeerView peer) { + focusPeer(peer); + routingPanel.setPrivateTcpOn(true); + switchActiveConversation(resolveConversationKey()); + } + + public void deliverIncomingMessage(ChatMessage message) { + if (isEchoFromLocalUser(message)) { + return; + } + String bucket = bucketForIncoming(message); + if (!ROOM_KEY.equals(bucket) && message.getSenderIp() != null && !message.getSenderIp().isBlank()) { + String canonical = canonicalPeerIp(message.getSenderIp()); + if (!sessionTabs.hasTab(PluginSessionKeys.peer(canonical))) { + openPeerTab(canonical, message.getName()); + } + bucket = peerKey(canonical); + } + appendChatLine(bucket, message); + } + + @Override + public void onPrivateChannelReady(String peerIp) { + SwingUtilities.invokeLater(() -> { + appendSystemLine(privateKey(peerIp), "Canal TCP privado conectado com " + peerIp + "."); + if (privateKey(peerIp).equals(activeConversationKey)) { + flushTranscriptToPane(); + } + }); + } + + @Override + public void onPrivateChannelClosed(String peerIp) { + SwingUtilities.invokeLater(() -> { + appendSystemLine(privateKey(peerIp), "Canal TCP privado encerrado (" + peerIp + ")."); + if (privateKey(peerIp).equals(activeConversationKey)) { + flushTranscriptToPane(); + } + }); + } + + @Override + public void appendPrivateLine(String peerIp, LegacyMpChatMessage message, boolean outgoing) { + SwingUtilities.invokeLater(() -> appendLegacyLine(privateKey(peerIp), message)); + } + + private void buildUi() { + JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + split.setResizeWeight(0.84); + split.setOneTouchExpandable(true); + transcript.setContentType("text/html"); + transcript.setEditable(false); + + input.setColumns(20); + input.setRows(2); + input.setLineWrap(true); + input.setWrapStyleWord(true); + input.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "send"); + input.getActionMap().put("send", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + sendMessage(); + } + }); + + JButton send = new JButton("Enviar"); + send.setFocusPainted(false); + send.addActionListener(e -> sendMessage()); + + JScrollPane inputScroll = new JScrollPane(input); + inputScroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + inputScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + ShellUi.borderlessScroll(inputScroll); + + JPanel compose = new JPanel(new BorderLayout(0, 4)); + compose.setPreferredSize(new Dimension(0, 72)); + compose.add(inputScroll, BorderLayout.CENTER); + JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + actions.add(send); + compose.add(actions, BorderLayout.SOUTH); + + split.setTopComponent(new JScrollPane(transcript)); + split.setBottomComponent(compose); + split.addHierarchyListener(e -> { + if ((e.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) != 0 + && split.isShowing() + && split.getHeight() > 0) { + split.setDividerLocation(0.84); + } + }); + + draftPanel = new PeerDraftPanel( + "Nova conversa por IP", + "Informe o endereço IP do peer. A aba será atualizada com o nome quando a conversa abrir.", + this::commitDraftIp + ); + + bodyCards.add(split, CARD_MAIN); + bodyCards.add(draftPanel, CARD_DRAFT); + workspace.getBodyHost().add(bodyCards, BorderLayout.CENTER); + + add(workspace, BorderLayout.CENTER); + } + + private void showBodyCard(String card) { + bodyLayout.show(bodyCards, card); + } + + private void sendMessage() { + if (input.getText().trim().isEmpty()) { + return; + } + syncRoutingFromActiveSessionTab(); + String text = input.getText(); + PluginRoutingPanel.RoutingSnapshot routing = routingPanel.snapshot(); + try { + if (routing.privateTcpOn() && !routing.everyoneInRoom()) { + String peerIp = canonicalPeerIp(routing.peerIp()); + if (LocalNetworkInfo.isLocalIpv4(peerIp)) { + appendSystemLine( + activeConversationKey, + "Canal TCP privado com você mesmo não está disponível; use sala geral ou UDP unicast." + ); + return; + } + User user = resolveUserForSingle(); + if (user == null) { + appendSystemLine(activeConversationKey, "Informe o IP do peer para o canal TCP privado."); + return; + } + if (privateChatController != null && privateChatController.sendText(user, text)) { + input.setText(""); + return; + } + if (privateChatController != null) { + privateChatController.openChat(user); + } + appendSystemLine(privateKey(user.getAddress().getHostAddress()), + "Canal TCP privado conectando… envie de novo em instantes."); + return; + } + + TransportMode mode = routing.transport(); + SendTarget target = resolveSendTarget(routing); + if (target == null || mode == null) { + appendSystemLine(activeConversationKey, "Informe um IP válido para enviar a um peer."); + return; + } + String ip = LocalNetworkInfo.bestLocalIpv4(); + ChatMessage message = new ChatMessage( + connectionManager.getUserName(), + text, + mode.getLabel(), + ip + ); + int sent = MessageDispatcher.send(connectionManager, message, target, mode); + appendChatLine(activeConversationKey, message); + if (!routing.everyoneInRoom()) { + String peerIp = canonicalPeerIp(routing.peerIp()); + if (LocalNetworkInfo.isLocalIpv4(peerIp) && sent == 0) { + if (mode == TransportMode.TCP) { + appendSystemLine( + activeConversationKey, + "TCP para o próprio host não é suportado; use sala geral ou UDP unicast." + ); + } else { + appendSystemLine( + activeConversationKey, + "Mensagem registrada aqui; eco pela rede pode não voltar no mesmo PC." + ); + } + } + } + input.setText(""); + } catch (Exception ex) { + appendSystemLine(activeConversationKey, "Erro ao enviar: " + ex.getMessage()); + } + } + + private SendTarget resolveSendTarget(PluginRoutingPanel.RoutingSnapshot routing) throws Exception { + if (routing.everyoneInRoom()) { + return SendTarget.broadcastAll(); + } + String ip = canonicalPeerIp(routing.peerIp()); + if (ip == null || ip.isEmpty()) { + return null; + } + return SendTarget.singlePeer(InetAddress.getByName(ip)); + } + + private User resolveUserForSingle() { + try { + String ip = canonicalPeerIp(routingPanel.snapshot().peerIp()); + if (ip == null || ip.isEmpty()) { + return null; + } + InetAddress address = InetAddress.getByName(ip); + String displayName = ip; + for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) { + if (view.getInetAddress().isPresent() + && view.getInetAddress().get().equals(address)) { + displayName = view.getDisplayName(); + return new User(displayName, address, System.currentTimeMillis(), view.getInstanceId()); + } + } + return new User(displayName, address, System.currentTimeMillis(), null); + } catch (Exception ignored) { + return null; + } + } + + public void receiveChatMessage(ChatMessage message) { + runOnEdt(() -> deliverIncomingMessage(message)); + } + + public void receiveLegacyMessage(String tipo, String nome, String ip, String text) { + runOnEdt(() -> { + LegacyMpChatMessage legacy = new LegacyMpChatMessage(nome, ip, tipo, text); + String bucket = ip != null && !ip.isBlank() ? privateKey(canonicalPeerIp(ip)) : ROOM_KEY; + appendLegacyLine(bucket, legacy); + }); + } + + private static void runOnEdt(Runnable action) { + if (SwingUtilities.isEventDispatchThread()) { + action.run(); + } else { + SwingUtilities.invokeLater(action); + } + } + + private void appendChatLine(String bucket, ChatMessage message) { + SimpleDateFormat dt = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss"); + String tag = message.getTransportLabel() != null ? message.getTransportLabel() : "—"; + String ip = message.getSenderIp() != null ? message.getSenderIp() : "—"; + String line = dt.format(new Date()) + " " + ChatTranscriptHtml.coloredTransportTag(tag) + + " " + ChatTranscriptHtml.escape(message.getName()) + " (" + + ChatTranscriptHtml.escape(ip) + "): " + ChatTranscriptHtml.escape(message.getText()); + prependLine(bucket, line); + } + + private void appendLegacyLine(String bucket, LegacyMpChatMessage message) { + SimpleDateFormat dt = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss"); + String tag = message.getTipoMensagem() != null ? message.getTipoMensagem() : "Legado"; + String line = dt.format(new Date()) + " " + ChatTranscriptHtml.coloredTransportTag(tag) + + " " + ChatTranscriptHtml.escape(message.getNome()) + " (" + + ChatTranscriptHtml.escape(message.getIp()) + "): " + + ChatTranscriptHtml.escape(message.getMessage()); + prependLine(bucket, line); + } + + private void appendSystemLine(String bucket, String text) { + prependLine(bucket, "" + ChatTranscriptHtml.escape(text) + ""); + } + + private void prependLine(String bucket, String htmlLine) { + StringBuilder buf = transcripts.computeIfAbsent(bucket, k -> new StringBuilder()); + buf.insert(0, htmlLine + "
"); + if (bucket.equals(activeConversationKey)) { + scheduleFlushTranscriptToPane(); + } + } + + private void scheduleFlushTranscriptToPane() { + if (pendingTranscriptFlush != null) { + return; + } + pendingTranscriptFlush = () -> { + pendingTranscriptFlush = null; + flushTranscriptToPane(); + }; + SwingUtilities.invokeLater(pendingTranscriptFlush); + } + + private void flushTranscriptToPane() { + StringBuilder buf = transcripts.get(activeConversationKey); + String inner = buf != null ? buf.toString() : ""; + String html = ChatTranscriptHtml.wrapDocument(inner); + try { + transcript.setText(html); + } catch (RuntimeException ex) { + resetTranscriptEditor(); + try { + transcript.setText(html); + } catch (RuntimeException ignored) { + // evita quebrar a EDT se o HTML continuar inválido + } + } + } + + private void resetTranscriptEditor() { + transcript.setEditorKit(new HTMLEditorKit()); + transcript.setContentType("text/html"); + transcript.setEditable(false); + } + + private void switchActiveConversation(String newKey) { + if (newKey == null) { + newKey = ROOM_KEY; + } + if (newKey.equals(activeConversationKey)) { + return; + } + activeConversationKey = newKey; + flushTranscriptToPane(); + } + + private String resolveConversationKey() { + PluginRoutingPanel.RoutingSnapshot routing = routingPanel.snapshot(); + if (routing.everyoneInRoom()) { + return ROOM_KEY; + } + String ip = canonicalPeerIp(routing.peerIp()); + if (ip == null || ip.isEmpty()) { + return ROOM_KEY; + } + if (routing.privateTcpOn()) { + return privateKey(ip); + } + return peerKey(ip); + } + + private static String peerKey(String ip) { + return "peer:" + ip; + } + + private static String privateKey(String ip) { + return "private:" + ip; + } + + private String bucketForIncoming(ChatMessage message) { + String tag = message.getTransportLabel(); + if (tag != null && tag.toLowerCase().contains("multicast")) { + return ROOM_KEY; + } + String ip = message.getSenderIp(); + if (ip == null || ip.isBlank()) { + return ROOM_KEY; + } + return peerKey(canonicalPeerIp(ip)); + } + + private boolean isEchoFromLocalUser(ChatMessage message) { + if (message == null) { + return false; + } + return LocalUserIdentity.isLocalSender( + connectionManager, + message.getName(), + message.getSenderIp() + ); + } +} diff --git a/plugins/chat/src/pitiupi/chat/GUI/ChatTranscriptHtml.java b/plugins/chat/src/pitiupi/chat/GUI/ChatTranscriptHtml.java new file mode 100644 index 0000000..9b14232 --- /dev/null +++ b/plugins/chat/src/pitiupi/chat/GUI/ChatTranscriptHtml.java @@ -0,0 +1,50 @@ +package pitiupi.chat.GUI; + +import pitiupi.application.messaging.TransportMode; + +/** + * HTML do histórico com cores por transporte. + */ +public final class ChatTranscriptHtml { + + private ChatTranscriptHtml() { + } + + public static String coloredTransportTag(String transportLabel) { + if (transportLabel == null || transportLabel.isBlank()) { + return "[—]"; + } + String normalized = transportLabel.trim().toLowerCase(); + String color; + if (normalized.contains("privado")) { + color = "#6a1b9a"; + } else if (normalized.contains("multicast")) { + color = "#2e7d32"; + } else if (normalized.contains("udp")) { + color = "#1565c0"; + } else if (normalized.contains("tcp")) { + color = "#e65100"; + } else { + color = "#616161"; + } + return "[" + escape(transportLabel) + "]"; + } + + public static String tagForMode(TransportMode mode) { + return coloredTransportTag(mode != null ? mode.getLabel() : "—"); + } + + public static String escape(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + public static String wrapDocument(String innerHtml) { + if (innerHtml == null || innerHtml.isBlank()) { + return ""; + } + return "" + innerHtml + ""; + } +} diff --git a/pitiupi.chat/src/pitiupi/chat/GUI/ChatWindow.java b/plugins/chat/src/pitiupi/chat/GUI/ChatWindow.java similarity index 100% rename from pitiupi.chat/src/pitiupi/chat/GUI/ChatWindow.java rename to plugins/chat/src/pitiupi/chat/GUI/ChatWindow.java diff --git a/plugins/chat/src/pitiupi/chat/GUI/PrivateChatWindow.java b/plugins/chat/src/pitiupi/chat/GUI/PrivateChatWindow.java new file mode 100644 index 0000000..8319925 --- /dev/null +++ b/plugins/chat/src/pitiupi/chat/GUI/PrivateChatWindow.java @@ -0,0 +1,96 @@ +package pitiupi.chat.GUI; + +import pitiupi.chat.net.LegacyMpChatMessage; +import pitiupi.control.ConnectionManager; +import pitiupi.net.tcp.TcpConnection; + +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import java.awt.BorderLayout; +import java.net.InetAddress; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class PrivateChatWindow extends JFrame { + + private final ConnectionManager connectionManager; + private final TcpConnection connection; + private final JTextArea receivedMessagesArea; + private final JTextField messageField; + + public PrivateChatWindow(ConnectionManager connectionManager, TcpConnection connection) { + this.connectionManager = connectionManager; + this.connection = connection; + + setTitle("Chat privado (TCP)"); + setSize(700, 500); + setLocationRelativeTo(null); + setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE); + + JPanel mainPanel = new JPanel(new BorderLayout(10, 10)); + mainPanel.setBorder(javax.swing.BorderFactory.createEmptyBorder(10, 10, 10, 10)); + + receivedMessagesArea = new JTextArea(); + receivedMessagesArea.setEditable(false); + receivedMessagesArea.setLineWrap(true); + receivedMessagesArea.setWrapStyleWord(true); + + JScrollPane messagesScrollPane = new JScrollPane(receivedMessagesArea); + messagesScrollPane.setBorder(javax.swing.BorderFactory.createTitledBorder("Mensagens")); + mainPanel.add(messagesScrollPane, BorderLayout.CENTER); + + messageField = new JTextField(); + JButton tcpButton = new JButton("Enviar TCP"); + tcpButton.addActionListener(e -> sendTCP()); + + JPanel bottomPanel = new JPanel(new BorderLayout(10, 10)); + bottomPanel.add(messageField, BorderLayout.CENTER); + bottomPanel.add(tcpButton, BorderLayout.EAST); + mainPanel.add(bottomPanel, BorderLayout.SOUTH); + + setContentPane(mainPanel); + } + + public void sendChatLine(String text) { + if (text == null || text.isBlank()) { + return; + } + messageField.setText(text); + sendTCP(); + } + + private void sendTCP() { + try { + LegacyMpChatMessage message = new LegacyMpChatMessage( + connectionManager.getUserName(), + pitiupi.presentation.shell.LocalNetworkInfo.bestLocalIpv4(), + "TCP privado", + messageField.getText() + ); + connection.send(message.toByteArray()); + receiveMessage(message); + messageField.setText(""); + } catch (Exception ex) { + Logger.getLogger(PrivateChatWindow.class.getName()).log(Level.SEVERE, null, ex); + } + } + + public void receiveMessage(LegacyMpChatMessage message) { + SwingUtilities.invokeLater(() -> { + String texto = String.format( + "[%s] %s (%s): %s%n", + message.getTipoMensagem(), + message.getNome(), + message.getIp(), + message.getMessage() + ); + receivedMessagesArea.append(texto); + receivedMessagesArea.setCaretPosition(receivedMessagesArea.getDocument().getLength()); + }); + } +} diff --git a/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java b/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java new file mode 100644 index 0000000..013d323 --- /dev/null +++ b/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java @@ -0,0 +1,109 @@ +package pitiupi.chat.control; + +import pitiupi.chat.net.LegacyMpChatMessage; +import pitiupi.control.ConnectionManager; +import pitiupi.models.User; +import pitiupi.net.tcp.TcpConnection; +import pitiupi.presentation.shell.LocalNetworkInfo; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class PrivateChatController { + + private final ConnectionManager connectionManager; + private final PrivateChatHost host; + private final Map connections = new HashMap<>(); + private final Set pendingConnections = new HashSet<>(); + private PrivateChatUiSink uiSink; + + public PrivateChatController(ConnectionManager connectionManager, PrivateChatHost host) { + this.connectionManager = connectionManager; + this.host = host; + } + + public void setUiSink(PrivateChatUiSink uiSink) { + this.uiSink = uiSink; + } + + private static String addressKey(TcpConnection connection) { + return connection.getAddress().getHostAddress(); + } + + public void openChat(User user) { + if (user == null || user.getAddress() == null) { + return; + } + String address = user.getAddress().getHostAddress(); + if (connections.containsKey(address)) { + if (uiSink != null) { + uiSink.onPrivateChannelReady(address); + } + return; + } + if (pendingConnections.contains(address)) { + return; + } + pendingConnections.add(address); + host.openConnection(address); + } + + public void receiveMessage(TcpConnection connection, LegacyMpChatMessage message) { + if (uiSink != null) { + uiSink.appendPrivateLine(addressKey(connection), message, false); + } + } + + public void connectionReceived(TcpConnection connection) { + String address = addressKey(connection); + pendingConnections.remove(address); + connections.put(address, connection); + if (uiSink != null) { + uiSink.onPrivateChannelReady(address); + } + } + + public void closeChat(TcpConnection connection) { + String address = addressKey(connection); + connections.remove(address); + if (uiSink != null) { + uiSink.onPrivateChannelClosed(address); + } + } + + public boolean sendText(User user, String text) { + if (user == null || user.getAddress() == null || text == null || text.isBlank()) { + return false; + } + String address = user.getAddress().getHostAddress(); + TcpConnection connection = connections.get(address); + if (connection == null) { + return false; + } + try { + LegacyMpChatMessage message = new LegacyMpChatMessage( + connectionManager.getUserName(), + LocalNetworkInfo.bestLocalIpv4(), + "TCP privado", + text + ); + connection.send(message.toByteArray()); + if (uiSink != null) { + uiSink.appendPrivateLine(address, message, true); + } + return true; + } catch (Exception ex) { + System.out.println("Error sending private TCP: " + ex.getMessage()); + return false; + } + } + + public boolean hasOpenChannel(User user) { + if (user == null || user.getAddress() == null) { + return false; + } + return connections.containsKey(user.getAddress().getHostAddress()); + } +} diff --git a/plugins/chat/src/pitiupi/chat/control/PrivateChatHost.java b/plugins/chat/src/pitiupi/chat/control/PrivateChatHost.java new file mode 100644 index 0000000..337ba4b --- /dev/null +++ b/plugins/chat/src/pitiupi/chat/control/PrivateChatHost.java @@ -0,0 +1,7 @@ +package pitiupi.chat.control; + +public interface PrivateChatHost { + String getPluginIdentifier(); + + void openConnection(String ip); +} diff --git a/plugins/chat/src/pitiupi/chat/control/PrivateChatUiSink.java b/plugins/chat/src/pitiupi/chat/control/PrivateChatUiSink.java new file mode 100644 index 0000000..bf9d45c --- /dev/null +++ b/plugins/chat/src/pitiupi/chat/control/PrivateChatUiSink.java @@ -0,0 +1,15 @@ +package pitiupi.chat.control; + +import pitiupi.chat.net.LegacyMpChatMessage; + +/** + * TCP privado integrado ao painel principal (sem janela extra). + */ +public interface PrivateChatUiSink { + + void onPrivateChannelReady(String peerIp); + + void onPrivateChannelClosed(String peerIp); + + void appendPrivateLine(String peerIp, LegacyMpChatMessage message, boolean outgoing); +} diff --git a/plugins/chat/src/pitiupi/chat/net/ChatMessage.java b/plugins/chat/src/pitiupi/chat/net/ChatMessage.java new file mode 100644 index 0000000..2143de3 --- /dev/null +++ b/plugins/chat/src/pitiupi/chat/net/ChatMessage.java @@ -0,0 +1,58 @@ +package pitiupi.chat.net; + +import pitiupi.application.messaging.TransportMode; +import pitiupi.models.Messages.Message; + +public class ChatMessage extends Message { + + private String text; + private String name; + private String transportLabel; + private String senderIp; + + public ChatMessage() { + } + + public ChatMessage(String name, String text) { + this(name, text, TransportMode.MULTICAST_UDP.getLabel(), null); + } + + public ChatMessage(String name, String text, String transportLabel, String senderIp) { + this.name = name; + this.text = text; + this.transportLabel = transportLabel; + this.senderIp = senderIp; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public String getTransportLabel() { + return transportLabel; + } + + public void setTransportLabel(String transportLabel) { + this.transportLabel = transportLabel; + } + + public String getSenderIp() { + return senderIp; + } + + public void setSenderIp(String senderIp) { + this.senderIp = senderIp; + } +} diff --git a/pitiupi.mpChat/src/net/MpChatMessage.java b/plugins/chat/src/pitiupi/chat/net/LegacyMpChatMessage.java similarity index 64% rename from pitiupi.mpChat/src/net/MpChatMessage.java rename to plugins/chat/src/pitiupi/chat/net/LegacyMpChatMessage.java index f84346f..19c5023 100644 --- a/pitiupi.mpChat/src/net/MpChatMessage.java +++ b/plugins/chat/src/pitiupi/chat/net/LegacyMpChatMessage.java @@ -1,22 +1,26 @@ -package net; +package pitiupi.chat.net; import pitiupi.models.Messages.Message; -public class MpChatMessage extends Message { +/** + * Formato legado do antigo plugin mpChat (TCP privado e salas antigas). + */ +public class LegacyMpChatMessage extends Message { private String nome; private String ip; private String tipoMensagem; private String message; - public MpChatMessage(String nome, String ip, String tipoMensagem, String message) { + public LegacyMpChatMessage() { + } + + public LegacyMpChatMessage(String nome, String ip, String tipoMensagem, String message) { this.nome = nome; this.ip = ip; this.tipoMensagem = tipoMensagem; this.message = message; } - public MpChatMessage(){} - public String getNome() { return nome; } diff --git a/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java b/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java new file mode 100644 index 0000000..a4706b4 --- /dev/null +++ b/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java @@ -0,0 +1,235 @@ +package pitiupi.chat.plugin; + +import pitiupi.api.plugin.PeerActionsRegistrar; +import pitiupi.api.plugin.PluginWorkspace; +import pitiupi.application.activity.PeerActivityInbox; +import pitiupi.application.peeractions.PeerAction; +import pitiupi.application.peeractions.PeerActionTarget; +import pitiupi.application.peeractions.PeerTarget; +import pitiupi.application.presence.UserPeerView; +import pitiupi.chat.GUI.ChatPanel; +import pitiupi.chat.control.PrivateChatController; +import pitiupi.chat.control.PrivateChatHost; +import pitiupi.chat.net.ChatMessage; +import pitiupi.chat.net.LegacyMpChatMessage; +import pitiupi.control.ConnectionManager; +import pitiupi.presentation.shell.LocalUserIdentity; +import pitiupi.control.GuiManager; +import pitiupi.models.Messages.Message; +import pitiupi.models.Plugin; +import pitiupi.models.User; +import pitiupi.net.tcp.TcpConnection; + +import javax.swing.SwingUtilities; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.net.InetAddress; +import java.util.List; + +public class ChatPlugin extends Plugin implements PrivateChatHost { + + private ChatPanel chatPanel; + private PluginWorkspace workspace; + private ConnectionManager connectionManager; + private PrivateChatController privateChatController; + + @Override + public void createPlugin(ConnectionManager connectionManager, PluginWorkspace workspace) { + this.connectionManager = connectionManager; + this.workspace = workspace; + this.privateChatController = new PrivateChatController(connectionManager, this); + this.chatPanel = new ChatPanel(connectionManager); + chatPanel.setPrivateChatController(privateChatController); + privateChatController.setUiSink(chatPanel); + bindShellWorkspaceContent(chatPanel); + installPeerActions(workspace.peerActions()); + } + + @Override + public void createPlugin(ConnectionManager connectionManager, GuiManager guiManager) { + } + + @Override + public void installPeerActions(PeerActionsRegistrar registrar) { + registrar.register(PeerAction.of( + "chat.focus", + "Abrir sala geral", + PeerActionTarget.ALL_PEERS, + target -> { + chatPanel.focusGeneralRoom(); + workspace.openOrFocusTab(getIdentifier(), getDisplayName()); + } + )); + registrar.register(PeerAction.of( + "chat.peer", + "Conversar com este peer", + PeerActionTarget.SINGLE_PEER, + this::openChatForPeer + )); + registrar.register(PeerAction.of( + "chat.private.tcp", + "Ativar TCP privado", + PeerActionTarget.SINGLE_PEER, + target -> { + workspace.openOrFocusTab(getIdentifier(), getDisplayName()); + if (target instanceof PeerTarget.Single single) { + chatPanel.enablePrivateTcpForPeer(single.peer()); + } + } + )); + } + + private void openChatForPeer(PeerTarget target) { + if (!(target instanceof PeerTarget.Single single)) { + return; + } + chatPanel.focusPeer(single.peer()); + workspace.openOrFocusTab(getIdentifier(), getDisplayName()); + } + + private static User toUser(UserPeerView peer) { + if (peer.isBroadcastTarget() || peer.isSelf()) { + return null; + } + return peer.getInetAddress() + .map(addr -> new User(peer.getDisplayName(), addr, System.currentTimeMillis(), peer.getInstanceId())) + .orElse(null); + } + + @Override + public List> getMessages() { + return List.of(ChatMessage.class, LegacyMpChatMessage.class); + } + + @Override + public void receiveMessage(Message message) { + if (message instanceof ChatMessage msg && chatPanel != null) { + recordChatActivity(msg); + SwingUtilities.invokeLater(() -> chatPanel.receiveChatMessage(msg)); + } else if (message instanceof LegacyMpChatMessage legacy && chatPanel != null) { + SwingUtilities.invokeLater(() -> chatPanel.receiveLegacyMessage( + legacy.getTipoMensagem(), + legacy.getNome(), + legacy.getIp(), + legacy.getMessage() + )); + } + } + + private void recordChatActivity(ChatMessage msg) { + if (LocalUserIdentity.isLocalSender(connectionManager, msg.getName(), msg.getSenderIp())) { + return; + } + PeerActivityInbox inbox = connectionManager.getPeerActivityInbox(); + String tag = msg.getTransportLabel(); + boolean multicast = tag != null && tag.toLowerCase().contains("multicast"); + if (multicast) { + inbox.recordBroadcast(getIdentifier(), "chat.focus"); + return; + } + String instanceId = peerInstanceIdForActivity(msg.getSenderIp()); + if (instanceId != null) { + inbox.record(instanceId, getIdentifier(), "chat.peer"); + } + } + + private String peerInstanceIdForActivity(String ip) { + if (ip == null || ip.isBlank() || connectionManager == null) { + return null; + } + try { + InetAddress address = InetAddress.getByName(ip); + for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) { + if (view.isSelf()) { + continue; + } + if (view.getInetAddress().isPresent() && view.getInetAddress().get().equals(address)) { + return view.getInstanceId(); + } + } + } catch (Exception ignored) { + } + return null; + } + + @Override + public boolean receivePrivateConnection(TcpConnection connection) { + SwingUtilities.invokeLater(() -> privateChatController.connectionReceived(connection)); + return true; + } + + @Override + public boolean receiveBytes(TcpConnection connection, byte[] bytes) { + LegacyMpChatMessage legacy = parseLegacy(bytes); + if (legacy == null) { + return true; + } + privateChatController.receiveMessage(connection, legacy); + String instanceId = peerInstanceIdForActivity(connection.getAddress().getHostAddress()); + if (instanceId != null) { + connectionManager.getPeerActivityInbox().record(instanceId, getIdentifier(), "chat.private.tcp"); + } + return true; + } + + @Override + public boolean onSocketClosed(TcpConnection connection) { + privateChatController.closeChat(connection); + return true; + } + + @Override + public void openConnection(String ip) { + try { + connectionManager.connectPluginTcp( + InetAddress.getByName(ip), + getIdentifier(), + getIdentifier() + ); + } catch (Exception e) { + System.out.println("Error opening connection: " + e.getMessage()); + } + } + + @Override + public String getPluginIdentifier() { + return getIdentifier(); + } + + private LegacyMpChatMessage parseLegacy(byte[] bytes) { + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) { + if (ois.readObject() instanceof LegacyMpChatMessage msg) { + return msg; + } + } catch (IOException | ClassNotFoundException ex) { + System.out.println(ex.toString()); + } + return null; + } + + @Override + public String getName() { + return "Chat"; + } + + @Override + public String getDisplayName() { + return "Conversa na rede"; + } + + @Override + public String getAuthor() { + return "Flávio Schiavoni"; + } + + @Override + public String getVersion() { + return "1"; + } + + @Override + public String getIdentifier() { + return "Pitiupi.Chat.Example"; + } +} diff --git a/plugins/ping/.gitignore b/plugins/ping/.gitignore new file mode 100644 index 0000000..5c3a65d --- /dev/null +++ b/plugins/ping/.gitignore @@ -0,0 +1,5 @@ +/dist/ +/build/ +/out/ +/nbproject/private/ +*.jar diff --git a/plugins/ping/README.md b/plugins/ping/README.md new file mode 100644 index 0000000..7395fda --- /dev/null +++ b/plugins/ping/README.md @@ -0,0 +1,22 @@ +# Plugin Ping (exercício de aula) + +Material didático para o aluno **implementar** um plugin de Ping/Pong com medição de RTT. Este diretório no repositório oficial contém apenas planejamento e referências — **não** inclua código Java ou JAR da solução no commit. + +## Onde está o tutorial + +Tutorial canônico (passo a passo): [docs/documentation/17_tutorial_ping_aula.md](../../docs/documentation/17_tutorial_ping_aula.md). + +## Como o aluno trabalha + +1. Copiar a estrutura de [`plugins/chat`](../chat) para um projeto local (ou criar `plugins/ping` com `src/` apenas no fork pessoal). +2. Implementar `PingPlugin`, mensagens e `PingPanel` (`JPanel` + `bindShellWorkspaceContent`). +3. Build: `ant jar` → `dist/pitiupi.ping.jar`. +4. Instalar em `PITIUPI_HOME/plugins/` e reiniciar o Pitiupi. +5. Abrir o plugin pela aba **Plugins** na galeria (aba não abre sozinha no boot). + +Quando o JAR estiver pronto para o time, registre uma entrada em [`plugins.registry.json`](../../plugins.registry.json) na raiz do clone (id `ping`, `dir`: `plugins/ping`, etc.). + +## Pré-requisitos + +- Núcleo compilado: `../../pitiupi/dist/pitiupi.jar` +- Leitura: [09_sistema_de_plugins.md](../../docs/documentation/09_sistema_de_plugins.md) diff --git a/pitiupi.screenCast/.idea/ant.xml b/plugins/screencast/.idea/ant.xml similarity index 100% rename from pitiupi.screenCast/.idea/ant.xml rename to plugins/screencast/.idea/ant.xml diff --git a/pitiupi.screenCast/.idea/misc.xml b/plugins/screencast/.idea/misc.xml similarity index 100% rename from pitiupi.screenCast/.idea/misc.xml rename to plugins/screencast/.idea/misc.xml diff --git a/pitiupi.screenCast/.idea/pitiupi.screenCast.iml b/plugins/screencast/.idea/pitiupi.screenCast.iml similarity index 100% rename from pitiupi.screenCast/.idea/pitiupi.screenCast.iml rename to plugins/screencast/.idea/pitiupi.screenCast.iml diff --git a/pitiupi.mpChat/.idea/vcs.xml b/plugins/screencast/.idea/vcs.xml similarity index 100% rename from pitiupi.mpChat/.idea/vcs.xml rename to plugins/screencast/.idea/vcs.xml diff --git a/pitiupi.screenCast/.idea/workspace.xml b/plugins/screencast/.idea/workspace.xml similarity index 100% rename from pitiupi.screenCast/.idea/workspace.xml rename to plugins/screencast/.idea/workspace.xml diff --git a/pitiupi.screenCast/build.xml b/plugins/screencast/build.xml similarity index 100% rename from pitiupi.screenCast/build.xml rename to plugins/screencast/build.xml diff --git a/pitiupi.screenCast/manifest.mf b/plugins/screencast/manifest.mf similarity index 100% rename from pitiupi.screenCast/manifest.mf rename to plugins/screencast/manifest.mf diff --git a/pitiupi.screenCast/nbproject/build-impl.xml b/plugins/screencast/nbproject/build-impl.xml similarity index 100% rename from pitiupi.screenCast/nbproject/build-impl.xml rename to plugins/screencast/nbproject/build-impl.xml diff --git a/pitiupi.screenCast/nbproject/genfiles.properties b/plugins/screencast/nbproject/genfiles.properties similarity index 100% rename from pitiupi.screenCast/nbproject/genfiles.properties rename to plugins/screencast/nbproject/genfiles.properties diff --git a/pitiupi.screenCast/nbproject/project.properties b/plugins/screencast/nbproject/project.properties similarity index 99% rename from pitiupi.screenCast/nbproject/project.properties rename to plugins/screencast/nbproject/project.properties index 59c1b5f..ef9e717 100644 --- a/pitiupi.screenCast/nbproject/project.properties +++ b/plugins/screencast/nbproject/project.properties @@ -77,7 +77,7 @@ manifest.file=manifest.mf meta.inf.dir=${src.dir}/META-INF mkdist.disabled=false platform.active=default_platform -project.pitiupi=../pitiupi +project.pitiupi=../../pitiupi reference.pitiupi.jar=${project.pitiupi}/dist/pitiupi.jar run.classpath=\ ${javac.classpath}:\ diff --git a/pitiupi.screenCast/nbproject/project.xml b/plugins/screencast/nbproject/project.xml similarity index 100% rename from pitiupi.screenCast/nbproject/project.xml rename to plugins/screencast/nbproject/project.xml diff --git a/pitiupi.screenCast/src/META-INF/services/pitiupi.models.Plugin b/plugins/screencast/src/META-INF/services/pitiupi.models.Plugin similarity index 100% rename from pitiupi.screenCast/src/META-INF/services/pitiupi.models.Plugin rename to plugins/screencast/src/META-INF/services/pitiupi.models.Plugin diff --git a/pitiupi.screenCast/src/pitiupi.screenCast.iml b/plugins/screencast/src/pitiupi.screenCast.iml similarity index 100% rename from pitiupi.screenCast/src/pitiupi.screenCast.iml rename to plugins/screencast/src/pitiupi.screenCast.iml diff --git a/pitiupi.screenCast/src/pitiupi/screenCast/GUI/ScreenCastMenu.java b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastMenu.java similarity index 74% rename from pitiupi.screenCast/src/pitiupi/screenCast/GUI/ScreenCastMenu.java rename to plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastMenu.java index 1f87dc5..6e25c4c 100644 --- a/pitiupi.screenCast/src/pitiupi/screenCast/GUI/ScreenCastMenu.java +++ b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastMenu.java @@ -18,8 +18,8 @@ public class ScreenCastMenu extends MulticastSubMenu { private final ScreenCastShowMenu showMenu; - public ScreenCastMenu(ScreenCastWindow screenCastWindow) { - this.showMenu = new ScreenCastShowMenu(screenCastWindow); + public ScreenCastMenu(ScreenCastPanel screenCastPanel) { + this.showMenu = new ScreenCastShowMenu(screenCastPanel); } @Override @@ -33,10 +33,10 @@ public class ScreenCastMenu extends MulticastSubMenu { } private static final class ScreenCastShowMenu extends MulticastActionMenu { - private final ScreenCastWindow screenCastWindow; + private final ScreenCastPanel screenCastPanel; - ScreenCastShowMenu(ScreenCastWindow screenCastWindow) { - this.screenCastWindow = screenCastWindow; + ScreenCastShowMenu(ScreenCastPanel screenCastPanel) { + this.screenCastPanel = screenCastPanel; } @Override @@ -46,7 +46,7 @@ public class ScreenCastMenu extends MulticastSubMenu { @Override public void onClick() { - screenCastWindow.setVisible(true); + screenCastPanel.requestFocusInWindow(); } } } diff --git a/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastOutboundTabs.java b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastOutboundTabs.java new file mode 100644 index 0000000..107a0be --- /dev/null +++ b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastOutboundTabs.java @@ -0,0 +1,25 @@ +package pitiupi.screenCast.GUI; + +import pitiupi.presentation.shell.PluginSessionKeys; + +/** + * Abas em que o usuário pode ligar envio de tela (outbound). + */ +final class ScreenCastOutboundTabs { + + private ScreenCastOutboundTabs() { + } + + static boolean isOutboundShareTabKey(String key) { + if (key == null || key.isBlank()) { + return false; + } + if (PluginSessionKeys.PEER_DRAFT.equals(key)) { + return false; + } + if (key.startsWith("view:")) { + return false; + } + return PluginSessionKeys.ROOM.equals(key) || key.startsWith("peer:"); + } +} diff --git a/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java new file mode 100644 index 0000000..250301a --- /dev/null +++ b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java @@ -0,0 +1,591 @@ +package pitiupi.screenCast.GUI; + +import pitiupi.application.messaging.MessageDispatcher; +import pitiupi.application.messaging.PluginTransportCatalog; +import pitiupi.application.messaging.SendTarget; +import pitiupi.application.messaging.TransportMode; +import pitiupi.application.presence.UserPeerView; +import pitiupi.control.ConnectionManager; +import pitiupi.presentation.shell.PeerDraftPanel; +import pitiupi.presentation.shell.PluginRoutingPanel; +import pitiupi.presentation.shell.PluginSessionKeys; +import pitiupi.presentation.shell.PluginSessionWorkspace; +import pitiupi.presentation.shell.SessionPeerLabels; +import pitiupi.presentation.shell.SessionTabBar; +import pitiupi.presentation.shell.ui.ShellUi; + +import pitiupi.screenCast.control.AlarmClock; +import pitiupi.screenCast.control.ImageTransformation; +import pitiupi.screenCast.control.ScreenCaptureDiagnostics; +import pitiupi.screenCast.control.ScreenShot; +import pitiupi.screenCast.net.ScreenCastMessage; + +import java.awt.AWTException; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.FlowLayout; +import java.awt.event.HierarchyEvent; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.util.HashMap; +import java.util.Map; +import java.util.Timer; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.imageio.ImageIO; +import javax.swing.ImageIcon; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JToggleButton; +import javax.swing.SwingUtilities; + +/** + * Painel de compartilhamento de tela. + * Backlog UX: prévia dividida (recebendo + enviando) na mesma aba peer; multi-share simultâneo. + */ +public class ScreenCastPanel extends JPanel { + + public static final int TIME = 1000; + private static final int MAX_WIDTH = 800; + private static final int MAX_HEIGHT = 600; + private static final String CARD_DRAFT = "__draft__"; + private static final String IDLE_ROOM_HTML = + "

Ative Compartilhamento da sua tela para enviar para todos na rede (multicast).

"; + private static final String IDLE_PEER_HTML = + "

Ative o compartilhamento para enviar só para este peer (unicast).

"; + + private AlarmClock alarmClock; + private Timer timer; + private float compressRatio; + private final ConnectionManager connectionManager; + private final PluginSessionWorkspace workspace; + private final PluginRoutingPanel routingPanel; + private final SessionTabBar sessionTabs; + private final JToggleButton shareToggle = new JToggleButton("Compartilhamento da sua tela (desligado)"); + private final Map sharingByTabKey = new HashMap<>(); + private boolean suppressShareToggleEvents; + private String shareContextTabKey; + private String lastSessionKey = PluginSessionKeys.ROOM; + private final Map peerIpsByViewKey = new HashMap<>(); + private final Map peerDisplayNames = new HashMap<>(); + private final Map receiveScopes = new HashMap<>(); + private final CardLayout bodyLayout = new CardLayout(); + private final JPanel bodyCards = new JPanel(bodyLayout); + private final Map bodyPanels = new HashMap<>(); + private PeerDraftPanel draftPanel; + + public ScreenCastPanel(ConnectionManager connectionManager) { + this.connectionManager = connectionManager; + this.compressRatio = 0.55f; + this.routingPanel = new PluginRoutingPanel( + PluginTransportCatalog.PluginKind.SCREEN_CAST, + "Receba pela lista lateral (notificação) ou use + Nova para um IP. " + + "A aba Todos envia em multicast; abas de peer usam unicast.", + false + ); + this.workspace = new PluginSessionWorkspace(routingPanel, buildShareToolbar()); + this.sessionTabs = workspace.getSessionTabBar(); + sessionTabs.setCloseFallbackKey(PluginSessionKeys.ROOM); + initComponents(); + } + + private JPanel buildShareToolbar() { + shareToggle.setFocusPainted(false); + shareToggle.addActionListener(e -> { + if (suppressShareToggleEvents) { + return; + } + String key = sessionTabs.selectedKey(); + if (!ScreenCastOutboundTabs.isOutboundShareTabKey(key)) { + return; + } + sharingByTabKey.put(key, shareToggle.isSelected()); + updateShareToggleLabel(); + applySharingForActiveTab(); + }); + JPanel shareRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 4)); + shareRow.add(shareToggle); + return shareRow; + } + + private void initComponents() { + setLayout(new BorderLayout()); + draftPanel = new PeerDraftPanel( + "Nova sessão por IP", + "Informe o IP para enviar ou receber unicast. O nome na aba atualiza quando o peer for conhecido.", + this::commitDraftIp + ); + bodyCards.add(draftPanel, CARD_DRAFT); + workspace.getBodyHost().add(bodyCards, BorderLayout.CENTER); + add(workspace, BorderLayout.CENTER); + timer = new Timer(); + addHierarchyListener(e -> { + if ((e.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) != 0 && isShowing()) { + onSessionTabSelected(sessionTabs.selectedKey()); + } + }); + initSessionTabs(); + } + + private void initSessionTabs() { + sessionTabs.addOrSelectTab( + PluginSessionKeys.ROOM, + "Todos", + "Transmissão para todos na rede (multicast)", + new JPanel(), + false + ); + sessionTabs.setOnTabSelected(this::onSessionTabSelected); + sessionTabs.setOnAddPeerRequested(this::openDraftPeerTab); + sessionTabs.setOnTabClosed(this::onSessionTabClosed); + ensureBodyPanel(PluginSessionKeys.ROOM); + setBodyMessage(PluginSessionKeys.ROOM, ShellUi.mutedLabel(IDLE_ROOM_HTML)); + sessionTabs.selectTab(PluginSessionKeys.ROOM); + } + + private void onSessionTabClosed(String key) { + if (PluginSessionKeys.PEER_DRAFT.equals(key)) { + if (draftPanel != null) { + draftPanel.clearIpField(); + } + SwingUtilities.invokeLater(this::restoreSessionAfterDraftClosed); + } + sharingByTabKey.remove(key); + } + + private void restoreSessionAfterDraftClosed() { + String restore = lastSessionKey != null && sessionTabs.hasTab(lastSessionKey) + ? lastSessionKey + : PluginSessionKeys.ROOM; + if (PluginSessionKeys.PEER_DRAFT.equals(restore)) { + restore = PluginSessionKeys.ROOM; + } + sessionTabs.selectTabSilently(restore); + onSessionTabSelected(restore); + } + + private void onSessionTabSelected(String key) { + persistShareStateForContextTab(); + if (key != null && !PluginSessionKeys.PEER_DRAFT.equals(key)) { + lastSessionKey = key; + } + if (PluginSessionKeys.PEER_DRAFT.equals(key)) { + cancelShareTimer(); + applyShareToggleForTab(key); + showBodyCard(CARD_DRAFT); + draftPanel.focusIpField(); + return; + } + if (key == null) { + key = PluginSessionKeys.ROOM; + } + showBodyCard(key); + applyRoutingForSessionKey(key); + applyShareToggleForTab(key); + applySharingForActiveTab(); + if (!isSharingForTab(key)) { + refreshOutboundBodyForTab(key); + } + } + + private void persistShareStateForContextTab() { + if (shareContextTabKey != null && ScreenCastOutboundTabs.isOutboundShareTabKey(shareContextTabKey)) { + sharingByTabKey.put(shareContextTabKey, shareToggle.isSelected()); + } + } + + private void applyShareToggleForTab(String key) { + shareContextTabKey = ScreenCastOutboundTabs.isOutboundShareTabKey(key) ? key : null; + suppressShareToggleEvents = true; + try { + if (shareContextTabKey == null) { + shareToggle.setEnabled(false); + shareToggle.setSelected(false); + } else { + shareToggle.setEnabled(true); + shareToggle.setSelected(sharingByTabKey.getOrDefault(shareContextTabKey, false)); + } + updateShareToggleLabel(); + } finally { + suppressShareToggleEvents = false; + } + } + + private void setShareToggleSelectedSilently(boolean selected) { + suppressShareToggleEvents = true; + try { + shareToggle.setSelected(selected); + updateShareToggleLabel(); + } finally { + suppressShareToggleEvents = false; + } + } + + private boolean isSharingForTab(String key) { + return ScreenCastOutboundTabs.isOutboundShareTabKey(key) + && sharingByTabKey.getOrDefault(key, false); + } + + private void applySharingForActiveTab() { + cancelShareTimer(); + String key = sessionTabs.selectedKey(); + if (!ScreenCastOutboundTabs.isOutboundShareTabKey(key)) { + return; + } + boolean on = sharingByTabKey.getOrDefault(key, shareToggle.isSelected()); + sharingByTabKey.put(key, on); + if (!on) { + refreshOutboundBodyForTab(key); + return; + } + syncRoutingFromActiveSessionTab(); + timer = new Timer(); + alarmClock = new AlarmClock(this); + timer.scheduleAtFixedRate(alarmClock, 0, TIME); + String previewKey = previewTabKey(); + ensureBodyPanel(previewKey); + setBodyMessage(previewKey, ShellUi.mutedLabel("Capturando tela…")); + showBodyCard(previewKey); + } + + private void refreshOutboundBodyForTab(String key) { + if (key == null || PluginSessionKeys.PEER_DRAFT.equals(key) || key.startsWith("view:")) { + return; + } + if (isSharingForTab(key)) { + return; + } + if (PluginSessionKeys.ROOM.equals(key)) { + setBodyMessage(key, ShellUi.mutedLabel(IDLE_ROOM_HTML)); + } else if (key.startsWith("peer:")) { + setBodyMessage(key, ShellUi.mutedLabel(IDLE_PEER_HTML)); + } + } + + private void syncRoutingFromActiveSessionTab() { + applyRoutingForSessionKey(sessionTabs.selectedKey()); + } + + private void applyRoutingForSessionKey(String key) { + if (key == null) { + routingPanel.bindRoomSession(); + routingPanel.setReceiveScopeHint(null); + return; + } + if (PluginSessionKeys.PEER_DRAFT.equals(key)) { + return; + } + if (PluginSessionKeys.ROOM.equals(key)) { + routingPanel.bindRoomSession(); + routingPanel.setReceiveScopeHint(null); + return; + } + if (key.startsWith("peer:")) { + String ip = key.substring("peer:".length()); + String name = peerDisplayNames.getOrDefault(ip, ip); + routingPanel.bindPeerSession(ip, name); + routingPanel.setReceiveScopeHint(null); + return; + } + if (key.startsWith("view:")) { + String ip = peerIpsByViewKey.get(key); + String title = key.substring("view:".length()); + ScreenCastReceiveScope scope = receiveScopes.getOrDefault(key, ScreenCastReceiveScope.BROADCAST); + if (ip != null && !ip.isBlank()) { + routingPanel.bindPeerSession(ip, title, scope.bannerHtml()); + } else { + routingPanel.bindPeerSession("", title, scope.bannerHtml()); + } + } + } + + private void openDraftPeerTab() { + String previous = sessionTabs.selectedKey(); + if (previous != null && !PluginSessionKeys.PEER_DRAFT.equals(previous)) { + lastSessionKey = previous; + } + if (!sessionTabs.hasTab(PluginSessionKeys.PEER_DRAFT)) { + sessionTabs.addOrSelectTab( + PluginSessionKeys.PEER_DRAFT, + "Nova conversa", + "Informe o IP do peer", + new JPanel(), + true + ); + } else { + sessionTabs.selectTab(PluginSessionKeys.PEER_DRAFT); + } + showBodyCard(CARD_DRAFT); + draftPanel.focusIpField(); + } + + private void commitDraftIp(String ip) { + if (draftPanel != null) { + draftPanel.clearIpField(); + } + if (sessionTabs.hasTab(PluginSessionKeys.PEER_DRAFT)) { + sessionTabs.closeTab(PluginSessionKeys.PEER_DRAFT); + } + openPeerTab(ip, ip); + } + + private void openPeerTab(String ip, String displayName) { + String canonical = canonicalPeerIp(ip); + String resolved = SessionPeerLabels.resolveDisplayName(connectionManager, canonical, displayName); + String key = PluginSessionKeys.peer(canonical); + peerDisplayNames.put(canonical, resolved); + ensureBodyPanel(key); + setBodyMessage(key, ShellUi.mutedLabel(IDLE_PEER_HTML)); + if (sessionTabs.hasTab(key)) { + sessionTabs.setTabTitle(key, resolved, canonical); + sessionTabs.selectTab(key); + } else { + sessionTabs.addOrSelectTab(key, resolved, canonical, new JPanel(), true); + } + routingPanel.bindPeerSession(canonical, resolved); + showBodyCard(key); + } + + private static String canonicalPeerIp(String ip) { + if (ip == null || ip.isBlank()) { + return ""; + } + try { + return InetAddress.getByName(ip.trim()).getHostAddress(); + } catch (Exception ex) { + return ip.trim(); + } + } + + public void focusMulticastSharing() { + ensureTodosTab(); + sharingByTabKey.put(PluginSessionKeys.ROOM, true); + sessionTabs.selectTab(PluginSessionKeys.ROOM); + routingPanel.bindRoomSession(); + routingPanel.selectTransport(TransportMode.MULTICAST_UDP); + setShareToggleSelectedSilently(true); + applySharingForActiveTab(); + } + + public void focusPeerSharing(UserPeerView peer) { + if (peer == null || peer.isBroadcastTarget() || peer.isSelf()) { + return; + } + peer.getInetAddress().ifPresent(addr -> { + String ip = addr.getHostAddress(); + openPeerTab(ip, peer.getDisplayName()); + String key = PluginSessionKeys.peer(canonicalPeerIp(ip)); + sharingByTabKey.put(key, true); + routingPanel.selectTransport(TransportMode.UNICAST_UDP); + setShareToggleSelectedSilently(true); + applySharingForActiveTab(); + }); + } + + public void focusPeerAddress(UserPeerView peer) { + if (peer == null || peer.isBroadcastTarget() || peer.isSelf()) { + return; + } + peer.getInetAddress().ifPresent(addr -> openPeerTab(addr.getHostAddress(), peer.getDisplayName())); + } + + private void ensureTodosTab() { + if (!sessionTabs.hasTab(PluginSessionKeys.ROOM)) { + sessionTabs.addOrSelectTab( + PluginSessionKeys.ROOM, + "Todos", + "Transmissão para todos na rede (multicast)", + new JPanel(), + false + ); + ensureBodyPanel(PluginSessionKeys.ROOM); + setBodyMessage(PluginSessionKeys.ROOM, ShellUi.mutedLabel(IDLE_ROOM_HTML)); + } + } + + private void updateShareToggleLabel() { + shareToggle.setText(shareToggle.isSelected() + ? "Compartilhamento da sua tela (ligado)" + : "Compartilhamento da sua tela (desligado)"); + } + + private void cancelShareTimer() { + if (timer != null) { + timer.cancel(); + timer = null; + } + } + + public void stopSharing() { + sharingByTabKey.replaceAll((k, v) -> false); + setShareToggleSelectedSilently(false); + cancelShareTimer(); + refreshOutboundBodyForTab(sessionTabs.selectedKey()); + } + + public void sendScreenCastMessage() { + String activeKey = sessionTabs.selectedKey(); + if (!isSharingForTab(activeKey)) { + return; + } + syncRoutingFromActiveSessionTab(); + try { + BufferedImage image = ScreenShot.takeAShot(); + if (image == null || image.getWidth() < 1 || image.getHeight() < 1) { + Logger.getLogger(ScreenCastPanel.class.getName()).log(Level.WARNING, "Captura de tela vazia"); + showCaptureWarning(); + return; + } + boolean black = ScreenCaptureDiagnostics.isLikelyBlackFrame(image); + BufferedImage rescaled = ImageTransformation.resize(image, MAX_WIDTH, MAX_HEIGHT); + byte[] byteImage = ImageTransformation.compress(rescaled, compressRatio); + if (byteImage == null || byteImage.length < 32) { + return; + } + ScreenCastMessage message = new ScreenCastMessage(connectionManager.getUserName(), byteImage); + + PluginRoutingPanel.RoutingSnapshot routing = routingPanel.snapshot(); + TransportMode mode = routing.transport(); + SendTarget target = resolveSendTarget(routing); + if (target == null || mode == null) { + return; + } + int sent = MessageDispatcher.send(connectionManager, message, target, mode); + if (sent == 0 && mode == TransportMode.MULTICAST_UDP) { + compressRatio = Math.max(0.25f, compressRatio - 0.1f); + } + if (black) { + showCaptureWarning(); + } else { + showLocalPreview(rescaled); + } + } catch (AWTException | IOException ex) { + Logger.getLogger(AlarmClock.class.getName()).log(Level.SEVERE, null, ex); + showCaptureWarning(); + } catch (Exception ex) { + Logger.getLogger(ScreenCastPanel.class.getName()).log(Level.SEVERE, null, ex); + } + } + + private void showCaptureWarning() { + SwingUtilities.invokeLater(() -> { + JLabel label = new JLabel(ScreenCaptureDiagnostics.blackFrameUserMessage()); + String key = previewTabKey(); + setBodyContent(key, label); + showBodyCard(key); + }); + } + + private void showLocalPreview(BufferedImage image) { + SwingUtilities.invokeLater(() -> { + String key = previewTabKey(); + JLabel label = new JLabel(new ImageIcon(image)); + setBodyContent(key, new JScrollPane(label)); + showBodyCard(key); + }); + } + + private SendTarget resolveSendTarget(PluginRoutingPanel.RoutingSnapshot routing) throws Exception { + if (routing.everyoneInRoom()) { + return SendTarget.broadcastAll(); + } + String ip = routing.peerIp(); + if (ip == null || ip.isEmpty()) { + return null; + } + return SendTarget.singlePeer(InetAddress.getByName(ip)); + } + + public void receiveScreenCastMessage(ScreenCastMessage message, ScreenCastReceiveScope scope) { + if (isOwnOutboundFrame(message)) { + return; + } + byte[] bytes = message.getImageByte(); + String viewKey = "view:" + message.getName(); + if (bytes == null || bytes.length < 32) { + SwingUtilities.invokeLater(() -> updateTabError(viewKey, message.getName(), "Frame vazio ou corrompido.", scope)); + return; + } + SwingUtilities.invokeLater(() -> { + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + if (image == null) { + updateTabError(viewKey, message.getName(), "Não foi possível decodificar a imagem.", scope); + return; + } + receiveScopes.put(viewKey, scope != null ? scope : ScreenCastReceiveScope.BROADCAST); + resolveSenderIp(message.getName(), viewKey); + sessionTabs.addOrSelectTab(viewKey, message.getName(), message.getName(), new JPanel(), true); + setBodyContent(viewKey, new JScrollPane(new JLabel(new ImageIcon(image)))); + sessionTabs.selectTab(viewKey); + showBodyCard(viewKey); + } catch (IOException ex) { + updateTabError(viewKey, message.getName(), "Erro ao ler imagem: " + ex.getMessage(), scope); + } + }); + } + + private void resolveSenderIp(String senderName, String viewKey) { + for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) { + if (view.isBroadcastTarget()) { + continue; + } + if (senderName != null && senderName.equals(view.getDisplayName())) { + view.getInetAddress().ifPresent(addr -> peerIpsByViewKey.put(viewKey, addr.getHostAddress())); + return; + } + } + } + + private void updateTabError(String tabKey, String title, String error, ScreenCastReceiveScope scope) { + receiveScopes.put(tabKey, scope != null ? scope : ScreenCastReceiveScope.BROADCAST); + JLabel label = new JLabel("

" + error + "

"); + setBodyContent(tabKey, label); + sessionTabs.addOrSelectTab(tabKey, title, title, new JPanel(), true); + showBodyCard(tabKey); + } + + private void ensureBodyPanel(String key) { + if (!bodyPanels.containsKey(key)) { + JPanel panel = new JPanel(new BorderLayout()); + bodyPanels.put(key, panel); + bodyCards.add(panel, key); + } + } + + private void setBodyContent(String key, java.awt.Component content) { + ensureBodyPanel(key); + JPanel panel = bodyPanels.get(key); + panel.removeAll(); + panel.add(content, BorderLayout.CENTER); + panel.revalidate(); + panel.repaint(); + } + + private void setBodyMessage(String key, JLabel label) { + setBodyContent(key, label); + } + + private String previewTabKey() { + String selected = sessionTabs.selectedKey(); + if (selected != null && !PluginSessionKeys.PEER_DRAFT.equals(selected)) { + return selected; + } + return PluginSessionKeys.ROOM; + } + + private boolean isOwnOutboundFrame(ScreenCastMessage message) { + if (message == null || message.getName() == null) { + return false; + } + String localName = connectionManager.getUserName(); + return localName != null && localName.equals(message.getName().trim()); + } + + private void showBodyCard(String card) { + bodyLayout.show(bodyCards, card); + } +} diff --git a/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastReceiveScope.java b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastReceiveScope.java new file mode 100644 index 0000000..3aeb863 --- /dev/null +++ b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastReceiveScope.java @@ -0,0 +1,13 @@ +package pitiupi.screenCast.GUI; + +public enum ScreenCastReceiveScope { + BROADCAST, + DIRECT; + + public String bannerHtml() { + if (this == BROADCAST) { + return "Transmissão para todos na rede"; + } + return "Transmissão direcionada a você"; + } +} diff --git a/plugins/screencast/src/pitiupi/screenCast/control/AlarmClock.java b/plugins/screencast/src/pitiupi/screenCast/control/AlarmClock.java new file mode 100644 index 0000000..4cd2aaa --- /dev/null +++ b/plugins/screencast/src/pitiupi/screenCast/control/AlarmClock.java @@ -0,0 +1,18 @@ +package pitiupi.screenCast.control; + +import java.util.TimerTask; +import pitiupi.screenCast.GUI.ScreenCastPanel; + +public class AlarmClock extends TimerTask { + + private final ScreenCastPanel screenCastPanel; + + public AlarmClock(ScreenCastPanel screenCastPanel) { + this.screenCastPanel = screenCastPanel; + } + + @Override + public void run() { + screenCastPanel.sendScreenCastMessage(); + } +} diff --git a/pitiupi.screenCast/src/pitiupi/screenCast/control/ImageTransformation.java b/plugins/screencast/src/pitiupi/screenCast/control/ImageTransformation.java similarity index 91% rename from pitiupi.screenCast/src/pitiupi/screenCast/control/ImageTransformation.java rename to plugins/screencast/src/pitiupi/screenCast/control/ImageTransformation.java index 1dcda37..87ad026 100644 --- a/pitiupi.screenCast/src/pitiupi/screenCast/control/ImageTransformation.java +++ b/plugins/screencast/src/pitiupi/screenCast/control/ImageTransformation.java @@ -5,6 +5,7 @@ */ package pitiupi.screenCast.control; +import java.awt.Graphics2D; import java.awt.Image; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; @@ -59,9 +60,10 @@ public class ImageTransformation { } Image resized = img.getScaledInstance(scaledWidth, scaledHeight, Image.SCALE_SMOOTH); - BufferedImage buffered = new BufferedImage(scaledWidth, scaledHeight, Image.SCALE_REPLICATE); - buffered.getGraphics().drawImage(resized, 0, 0, null); - + BufferedImage buffered = new BufferedImage(scaledWidth, scaledHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = buffered.createGraphics(); + graphics.drawImage(resized, 0, 0, null); + graphics.dispose(); return buffered; } } diff --git a/plugins/screencast/src/pitiupi/screenCast/control/ScreenCaptureDiagnostics.java b/plugins/screencast/src/pitiupi/screenCast/control/ScreenCaptureDiagnostics.java new file mode 100644 index 0000000..0e7a5bd --- /dev/null +++ b/plugins/screencast/src/pitiupi/screenCast/control/ScreenCaptureDiagnostics.java @@ -0,0 +1,74 @@ +package pitiupi.screenCast.control; + +import java.awt.GraphicsEnvironment; +import java.awt.image.BufferedImage; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class ScreenCaptureDiagnostics { + + private static final Logger LOG = Logger.getLogger(ScreenCaptureDiagnostics.class.getName()); + private static boolean environmentLogged; + + private ScreenCaptureDiagnostics() { + } + + public static boolean isLikelyBlackFrame(BufferedImage image) { + if (image == null || image.getWidth() < 8 || image.getHeight() < 8) { + return true; + } + int stepX = Math.max(1, image.getWidth() / 16); + int stepY = Math.max(1, image.getHeight() / 16); + int dark = 0; + int samples = 0; + for (int y = 0; y < image.getHeight(); y += stepY) { + for (int x = 0; x < image.getWidth(); x += stepX) { + int rgb = image.getRGB(x, y); + int r = (rgb >> 16) & 0xff; + int g = (rgb >> 8) & 0xff; + int b = rgb & 0xff; + if (r < 12 && g < 12 && b < 12) { + dark++; + } + samples++; + } + } + return samples > 0 && dark >= samples * 0.92; + } + + public static String blackFrameUserMessage() { + logEnvironmentOnce(); + String wayland = System.getenv("WAYLAND_DISPLAY"); + String display = System.getenv("DISPLAY"); + return "

Captura retornou tela preta.

" + + "

Em WSL/WSLg o java.awt.Robot costuma não capturar o desktop.

" + + "
    " + + "
  • Teste o Pitiupi no Windows nativo (fora do WSL) para compartilhar a tela real.
  • " + + "
  • No WSL, confira WSLg: WAYLAND_DISPLAY=" + + (wayland != null ? wayland : "—") + + ", DISPLAY=" + + (display != null ? display : "—") + + ".
  • " + + "
  • Se os frames ainda saem pretos, o envio na rede pode ocorrer, mas o preview local não reflete sua tela.
  • " + + "
"; + } + + private static void logEnvironmentOnce() { + if (environmentLogged) { + return; + } + environmentLogged = true; + try { + GraphicsEnvironment env = GraphicsEnvironment.getLocalGraphicsEnvironment(); + LOG.log(Level.INFO, "Screen capture env: {0}, displays={1}, DISPLAY={2}, WAYLAND_DISPLAY={3}", + new Object[]{ + env.getClass().getSimpleName(), + env.getScreenDevices().length, + System.getenv("DISPLAY"), + System.getenv("WAYLAND_DISPLAY") + }); + } catch (Exception ex) { + LOG.log(Level.WARNING, "Screen capture env unavailable", ex); + } + } +} diff --git a/pitiupi.screenCast/src/pitiupi/screenCast/control/ScreenShot.java b/plugins/screencast/src/pitiupi/screenCast/control/ScreenShot.java similarity index 100% rename from pitiupi.screenCast/src/pitiupi/screenCast/control/ScreenShot.java rename to plugins/screencast/src/pitiupi/screenCast/control/ScreenShot.java diff --git a/pitiupi.screenCast/src/pitiupi/screenCast/net/ScreenCastMessage.java b/plugins/screencast/src/pitiupi/screenCast/net/ScreenCastMessage.java similarity index 100% rename from pitiupi.screenCast/src/pitiupi/screenCast/net/ScreenCastMessage.java rename to plugins/screencast/src/pitiupi/screenCast/net/ScreenCastMessage.java diff --git a/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java b/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java new file mode 100644 index 0000000..b459343 --- /dev/null +++ b/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java @@ -0,0 +1,156 @@ +package pitiupi.screenCast.plugin; + +import java.util.List; + +import pitiupi.api.plugin.PeerActionsRegistrar; +import pitiupi.api.plugin.PluginWorkspace; +import pitiupi.application.activity.PeerActivityInbox; +import pitiupi.application.peeractions.PeerAction; +import pitiupi.application.presence.UserPeerView; +import pitiupi.application.peeractions.PeerActionTarget; +import pitiupi.application.peeractions.PeerTarget; +import pitiupi.control.ConnectionManager; +import pitiupi.control.GuiManager; +import pitiupi.models.Menus.MenuCategory; +import pitiupi.models.Messages.Message; +import pitiupi.models.Plugin; +import pitiupi.screenCast.GUI.ScreenCastMenu; +import pitiupi.screenCast.GUI.ScreenCastPanel; +import pitiupi.screenCast.GUI.ScreenCastReceiveScope; +import pitiupi.screenCast.net.ScreenCastMessage; + +public class ScreenCastPlugin extends Plugin { + + private ScreenCastPanel screenCastPanel; + private PluginWorkspace workspace; + private ConnectionManager connectionManager; + + @Override + public String getName() { + return "ScreenCast"; + } + + @Override + public String getDisplayName() { + return "Compartilhar tela"; + } + + @Override + public String getAuthor() { + return "Flávio Schiavoni"; + } + + @Override + public String getVersion() { + return "0.1"; + } + + @Override + public String getIdentifier() { + return "Pitiupi.ScreenCast.Example"; + } + + @Override + public void createPlugin(ConnectionManager connectionManager, PluginWorkspace workspace) { + this.connectionManager = connectionManager; + this.workspace = workspace; + this.screenCastPanel = new ScreenCastPanel(connectionManager); + bindShellWorkspaceContent(screenCastPanel); + installPeerActions(workspace.peerActions()); + } + + @Override + public void createPlugin(ConnectionManager connectionManager, GuiManager guiManager) { + this.screenCastPanel = new ScreenCastPanel(connectionManager); + guiManager.createMenu(MenuCategory.Streaming, new ScreenCastMenu(screenCastPanel)); + } + + @Override + public void onShellTabClosed() { + if (screenCastPanel != null) { + screenCastPanel.stopSharing(); + } + } + + @Override + public void installPeerActions(PeerActionsRegistrar registrar) { + registrar.register(PeerAction.of( + "screencast.multicast", + "Compartilhar na rede (multicast)", + PeerActionTarget.ALL_PEERS, + target -> { + workspace.openOrFocusTab(getIdentifier(), getDisplayName()); + screenCastPanel.focusMulticastSharing(); + } + )); + registrar.register(PeerAction.of( + "screencast.peer", + "Compartilhar com este peer", + PeerActionTarget.SINGLE_PEER, + target -> { + workspace.openOrFocusTab(getIdentifier(), getDisplayName()); + if (target instanceof PeerTarget.Single single) { + screenCastPanel.focusPeerSharing(single.peer()); + } + } + )); + } + + @Override + public List> getMessages() { + return List.of(ScreenCastMessage.class); + } + + @Override + public void receiveMessage(Message message) { + if (message instanceof ScreenCastMessage msg && screenCastPanel != null) { + if (isOwnOutboundFrame(msg)) { + return; + } + ScreenCastReceiveScope scope = classifyIncoming(msg); + recordScreenCastActivity(msg, scope); + screenCastPanel.receiveScreenCastMessage(msg, scope); + } + } + + private boolean isOwnOutboundFrame(ScreenCastMessage msg) { + if (connectionManager == null || msg.getName() == null) { + return false; + } + String localName = connectionManager.getUserName(); + return localName != null && localName.equals(msg.getName().trim()); + } + + private ScreenCastReceiveScope classifyIncoming(ScreenCastMessage msg) { + if (connectionManager == null || msg.getName() == null) { + return ScreenCastReceiveScope.BROADCAST; + } + for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) { + if (!view.isBroadcastTarget() + && msg.getName().equals(view.getDisplayName()) + && view.getInstanceId() != null) { + return ScreenCastReceiveScope.DIRECT; + } + } + return ScreenCastReceiveScope.BROADCAST; + } + + private void recordScreenCastActivity(ScreenCastMessage msg, ScreenCastReceiveScope scope) { + if (connectionManager == null) { + return; + } + PeerActivityInbox inbox = connectionManager.getPeerActivityInbox(); + if (scope == ScreenCastReceiveScope.DIRECT) { + for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) { + if (!view.isBroadcastTarget() + && msg.getName() != null + && msg.getName().equals(view.getDisplayName()) + && view.getInstanceId() != null) { + inbox.record(view.getInstanceId(), getIdentifier(), "screencast.peer"); + return; + } + } + } + inbox.recordBroadcast(getIdentifier(), "screencast.multicast"); + } +} diff --git a/scripts/pitiupi.sh b/scripts/pitiupi.sh index 15144c2..6ad53fd 100755 --- a/scripts/pitiupi.sh +++ b/scripts/pitiupi.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash -# Build, instala plugins em pitiupi/plugins/ e executa o núcleo (catálogo: plugins.registry.json). +# Build, instala plugins em PITIUPI_HOME/plugins e executa o núcleo (catálogo: plugins.registry.json). set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CORE="${REPO_ROOT}/pitiupi" -PLUGINS_DIR="${CORE}/plugins" +PITIUPI_HOME="${PITIUPI_HOME:-$HOME/pitiupi}" +PLUGINS_DIR="${PITIUPI_HOME}/plugins" REGISTRY="${REPO_ROOT}/plugins.registry.json" +export PITIUPI_HOME usage() { cat <<'EOF' @@ -14,7 +16,7 @@ Uso: scripts/pitiupi.sh [opções] Comandos: run Compila, atualiza plugins/ e abre a aplicação (padrão: --all) build Compila e atualiza plugins/ - link Só symlinks em pitiupi/plugins/ (JARs já buildados) + link Só symlinks em PITIUPI_HOME/plugins (JARs já buildados) list Lista plugins do catálogo (id, categoria, JAR presente) discover Sugere entradas para o catálogo (pastas pitiupi.* com SPI) check Ferramentas no PATH + valida plugins.registry.json @@ -74,15 +76,28 @@ if mode == "list": if mode == "discover": known_dirs = {p["dir"] for p in plugins} + candidates = [] + plugins_root = repo / "plugins" + if plugins_root.is_dir(): + candidates.extend(sorted(plugins_root.iterdir())) for child in sorted(repo.glob("pitiupi.*")): + if child.is_dir(): + candidates.append(child) + seen = set() + for child in candidates: if not child.is_dir(): continue + key = child.relative_to(repo).as_posix() + if key in seen: + continue + seen.add(key) spi = list(child.rglob("META-INF/services/pitiupi.models.Plugin")) if not spi: continue - status = "registrado" if child.name in known_dirs else "sugerido" + rel = key + status = "registrado" if rel in known_dirs else "sugerido" build = "make" if (child / "Makefile").is_file() else "ant" - print(f"{status}\t{child.name}\tbuild:{build}") + print(f"{status}\t{rel}\tbuild:{build}") sys.exit(0) if mode == "resolve": @@ -182,7 +197,7 @@ build_plugins_from_selection() { } link_plugins_from_selection() { - local id dir jar_rel link_name + local id dir jar_rel link_name src_abs mkdir -p "${PLUGINS_DIR}" rm -f "${PLUGINS_DIR}/"*.jar @@ -191,7 +206,8 @@ link_plugins_from_selection() { dir="$(registry_python field "$id" dir)" jar_rel="$(registry_python field "$id" jar)" link_name="${id}.jar" - ln -sf "../../${dir}/${jar_rel}" "${PLUGINS_DIR}/${link_name}" + src_abs="${REPO_ROOT}/${dir}/${jar_rel}" + ln -sf "${src_abs}" "${PLUGINS_DIR}/${link_name}" done < <(selected_ids) local count @@ -201,6 +217,9 @@ link_plugins_from_selection() { else printf 'Plugins ativos em %s:\n' "${PLUGINS_DIR}" ls -1 "${PLUGINS_DIR}"/*.jar + for jar in "${PLUGINS_DIR}"/*.jar; do + [[ -L "$jar" ]] && printf ' -> %s\n' "$(readlink -f "$jar")" + done fi } @@ -284,6 +303,7 @@ case "${CMD}" in ;; link) link_plugins_from_selection + printf '\nPara abrir o Pitiupi: %s run --all\n' "${REPO_ROOT}/scripts/pitiupi.sh" ;; build) check_tools || exit 1 @@ -296,7 +316,6 @@ case "${CMD}" in build_core build_plugins_from_selection link_plugins_from_selection - cd "${CORE}" - exec java -jar dist/pitiupi.jar + exec java -jar "${CORE}/dist/pitiupi.jar" ;; esac