Entrega v1 aula: shell com abas, PITIUPI_HOME e plugins de referência.

Consolida núcleo (presença, peer actions, MessageDispatcher), chat e screencast na galeria, documentação PT-BR e testes JUnit; remove mpChat do catálogo em favor do chat unificado.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gabriel Felipe Souza SantosandCursor committed 2026-10-27 11:08:21 -03:00
1 parent b10c196fc9
commit eeb1936f6d
178 files changed
+7641 -1481

No files matched your search

+9 -11
View File
@@ -1,13 +1,11 @@
/pitiupi/build/ /pitiupi/build/
/pitiupi/dist/ /pitiupi/dist/
/pitiupi/plugins/*.jar /pitiupi/nbproject/private/
/pitiupi.chat/build/ /plugins/chat/build/
/pitiupi.chat/dist/ /plugins/chat/dist/
/pitiupi.chat/nbproject/private/ /plugins/chat/nbproject/private/
/pitiupi.screenCast/build/ /plugins/screencast/build/
/pitiupi.screenCast/dist/ /plugins/screencast/dist/
/pitiupi.mpChat/out/ /plugins/screencast/nbproject/private/
/pitiupi.mpChat/dist/ /plugins/mpchat/out/
**/built-jar.properties /plugins/mpchat/dist/
.idea/
+23 -21
View File
@@ -1,6 +1,6 @@
# pitiupi # 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) **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 ./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`). 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`). 3. P2P no mesmo PC: segunda instância com **porta diferente** (ex.: `5001`).
```bash ```bash
@@ -35,38 +35,37 @@ Novos módulos entram no repositório adicionando uma entrada no JSON (não é p
| Campo | Significado | | Campo | Significado |
| --- | --- | | --- | --- |
| `id` | Usado em `--plugins chat,meuJogo` | | `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` | | `build` | `ant` ou `make` |
| `jar` | Caminho do JAR relativo a `dir` | | `jar` | Caminho do JAR relativo a `dir` |
| `menuCategory` | `Social`, `Jogos` ou `Streaming` — filtro CLI e documentação | | `menuCategory` | `Social`, `Jogos` ou `Streaming` — filtro CLI e documentação |
Plugins de referência hoje: Plugins de referência hoje:
| id | Categoria (menu) | Pasta | | id | Uso didático | Pasta |
| --- | --- | --- | | --- | --- | --- |
| `chat` | Social | `pitiupi.chat` | | `chat` | Sala geral, peer por IP, TCP privado | `plugins/chat` |
| `mpchat` | Social | `pitiupi.mpChat` | | `screencast` | Multicast ou unicast de tela | `plugins/screencast` |
| `screencast` | Streaming | `pitiupi.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 ```bash
./scripts/pitiupi.sh run --all ./scripts/pitiupi.sh run --all
./scripts/pitiupi.sh run --category Social ./scripts/pitiupi.sh run --category Social
./scripts/pitiupi.sh run --plugins chat,screencast ./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) ## Novo plugin (resumo)
1. Copiar estrutura de `pitiupi.chat` → `pitiupi.meuPlugin` ([tutorial](docs/documentation/10_tutorial_plugin.md)). 1. Copiar estrutura de `plugins/chat` → `plugins/meuPlugin` ([tutorial](docs/documentation/10_tutorial_plugin.md)).
2. Em `createPlugin`, registrar menu: `guiManager.createMenu(MenuCategory.Jogos, ...)` (ou Social / Streaming). 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). 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 | | 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 | | **Apache Ant** | Módulos NetBeans |
| **GNU Make** | `pitiupi.mpChat` |
| **python3** | Script `scripts/pitiupi.sh` (ler o catálogo) | | **python3** | Script `scripts/pitiupi.sh` (ler o catálogo) |
```bash ```bash
sudo apt install openjdk-25-jdk ant make sudo apt install openjdk-25-jdk ant
./scripts/pitiupi.sh check ./scripts/pitiupi.sh check
cd pitiupi && ant test # testes JUnit do núcleo
``` ```
**GUI:** Swing — WSLg / `DISPLAY=:0`. **GUI:** Swing — WSLg / `DISPLAY=:0`.
@@ -92,11 +91,11 @@ sudo apt install openjdk-25-jdk ant make
| Comando | Ação | | Comando | Ação |
| --- | --- | | --- | --- |
| `run` | Build + `plugins/` + app | | `run` | Build + link + app |
| `build` | Build + `plugins/` | | `build` | Build + link |
| `link` | Só symlinks (JARs já existem) | | `link` | Só symlinks (JARs já existem) |
| `list` | Catálogo e status do JAR | | `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 | | `check` | PATH + JSON válido |
Opções: `--all` (padrão), `--plugins ids`, `--category Social|Jogos|Streaming`. 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 ## 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).
--- ---
+9
View File
@@ -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) | | 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) | | 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) | | 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. Novas decisões: adicionar linha nesta tabela e detalhar no arquivo técnico correspondente.
+16 -32
View File
@@ -3,61 +3,45 @@
## Pré-requisitos ## Pré-requisitos
- **JDK completo** (`java` e `javac`) — OpenJDK 25 recomendado; o núcleo usa `javac.source/target=17`, plugins Ant usam 25. - **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`) - Apache Ant (`pitiupi`, `plugins/chat`, `plugins/screencast`)
- GNU Make (`pitiupi.mpChat`)
Ubuntu / WSL: Ubuntu / WSL:
```bash ```bash
sudo apt install openjdk-25-jdk ant make sudo apt install openjdk-25-jdk ant
which javac ant make which javac ant
``` ```
Se `ant jar` falhar com *Cannot run program "javac"*, você instalou apenas JRE — instale `openjdk-*-jdk`.
## Build ## Build
Na raiz do clone: Na raiz do clone:
```bash ```bash
cd pitiupi && ant jar ./scripts/pitiupi.sh build --all
cd ../pitiupi.chat && ant jar
cd ../pitiupi.screenCast && ant jar
cd ../pitiupi.mpChat && make
``` ```
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 ## 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). Runtime: JARs em **`PITIUPI_HOME/plugins`** (padrão `~/pitiupi/plugins`).
Symlinks em desenvolvimento (Linux):
```bash ```bash
cd pitiupi/plugins ./scripts/pitiupi.sh link --all
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
``` ```
## Executar ## Executar
```bash ```bash
cd pitiupi ./scripts/pitiupi.sh run --all
java -jar dist/pitiupi.jar
``` ```
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 Validação rápida do núcleo: `cd pitiupi && ant test`.
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).
@@ -25,9 +25,9 @@ Exemplos de tema: chat temático, tabuleiro (xadrez/damas), compartilhamento de
| Módulo | Ideia pedagógica | | Módulo | Ideia pedagógica |
| --- | --- | | --- | --- |
| `pitiupi.chat` | Mensagens UDP + UI simples | | `plugins/chat` | Mensagens UDP + UI simples |
| `pitiupi.screenCast` | Payload maior / streaming de imagem | | `plugins/screencast` | Payload maior / streaming de imagem |
| `pitiupi.mpChat` | Multicast + TCP privado no mesmo plugin | | `plugins/chat` | Multicast, unicast, TCP e canal TCP privado |
Detalhes: [11_plugins_referencia.md](11_plugins_referencia.md). Detalhes: [11_plugins_referencia.md](11_plugins_referencia.md).
+15 -23
View File
@@ -1,36 +1,28 @@
# Módulos do repositório # Módulos do repositório
``` ```
pitiupi/ # clone (este repositório) pitiupi/ # clone (este repositório)
├── plugins.registry.json # catálogo de módulos (build/link via scripts/pitiupi.sh) ├── plugins.registry.json
├── scripts/pitiupi.sh ├── scripts/pitiupi.sh
├── pitiupi/ # núcleo P2P (executável) ├── pitiupi/ # núcleo P2P (executável)
├── pitiupi.chat/ # plugin referência — chat ├── plugins/
├── pitiupi.screenCast/ # plugin referência — captura de tela │ ├── chat/ # chat unificado (UDP/multicast/TCP + TCP privado)
├── pitiupi.mpChat/ # plugin referência — chat multi-protocolo │ ├── screencast/ # compartilhamento de tela
└── docs/ # documentação canônica │ └── ping/ # exercício de aula (só doc)
└── docs/
``` ```
## Núcleo (`pitiupi/`) ## Núcleo (`pitiupi/`)
- **Build:** Apache Ant (`build.xml`, `nbproject/`). - **Build:** Apache Ant.
- **Entrada:** `pitiupi.Main` → login → `MainWindow`. - **Entrada:** `pitiupi.Main` → login → `MainShellFrame`.
- **Pacotes principais:** `pitiupi.net`, `pitiupi.control`, `pitiupi.GUI`, `pitiupi.models`. - **Messaging:** `pitiupi.application.messaging.MessageDispatcher`.
## Plugins de referência ## 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` | | `plugins/chat` | `ant jar` | `dist/pitiupi.chat.jar` | `pitiupi.chat.plugin.ChatPlugin` |
| `pitiupi.screenCast` | `ant jar` | `dist/pitiupi.screenCast.jar` | `pitiupi.screenCast.plugin.ScreenCastPlugin` | | `plugins/screencast` | `ant jar` | `dist/pitiupi.screenCast.jar` | `pitiupi.screenCast.plugin.ScreenCastPlugin` |
| `pitiupi.mpChat` | `make` | `dist/mpChat.jar` | `plugin.MultiProtocolChat` |
Todos dependem do JAR do núcleo no classpath de compilação. Ver [10_tutorial_plugin.md](10_tutorial_plugin.md) e [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md).
## 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).
+8 -40
View File
@@ -1,49 +1,17 @@
# Build — Ant, NetBeans e Make # Build — Ant e NetBeans
## Pré-requisitos e versões Java | Módulo | Build | Java |
| Módulo | Build | `javac.source` / `target` (NetBeans) |
| --- | --- | --- | | --- | --- | --- |
| `pitiupi` | Ant | 17 | | `pitiupi` | Ant | 17 |
| `pitiupi.chat` | Ant | 25 | | `plugins/chat` | Ant | 25 |
| `pitiupi.screenCast` | Ant | 25 | | `plugins/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`:
```bash ```bash
cd pitiupi && ant jar cd pitiupi && ant jar
cd pitiupi.chat && ant jar cd plugins/chat && ant jar
cd pitiupi.screenCast && 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). Catálogo: [`plugins.registry.json`](../../plugins.registry.json).
## 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).
+56 -16
View File
@@ -20,38 +20,78 @@ distribuídas e desenvolvimento de projetos acadêmicos.
A aplicação é dividida em três grupos principais: A aplicação é dividida em três grupos principais:
- **Núcleo da aplicação**: a `MainWindow` coordena GUI, rede e plugins. - **Núcleo da aplicação**: a `MainShellFrame` coordena GUI, rede e plugins.
- **Sistema de plugins**: extensões em JAR carregadas de `pitiupi/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`). - **Comunicação de rede**: UDP (`SocketUDP`) e TCP (`SocketTCP`, `TcpConnection`).
## Inicialização da aplicação ## Inicialização da aplicação
1. A `MainWindow` é criada após o diálogo de login (nome e porta). 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. O `SocketUDP` associa-se à porta configurada e ingressa no grupo multicast. 2. Após confirmar o login, a `MainShellFrame` é exibida (1024×768, centralizada).
3. O `SocketTCP` cria um `ServerSocket` na mesma porta para conexões TCP. 3. O `SocketUDP` associa-se à porta configurada e ingressa no grupo multicast.
4. Os mecanismos de comunicação iniciam threads de recepção. 4. O `SocketTCP` cria um `ServerSocket` na mesma porta para conexões TCP.
5. O `HeartbeatManager` envia heartbeats periodicamente. 5. Os mecanismos de comunicação iniciam threads de recepção.
6. O painel de usuários online registra-se como listener do heartbeat. 6. O `HeartbeatManager` envia heartbeats periodicamente; o `NetworkUsersPanel` lista peers via `PresenceService`.
7. O `PluginManager` carrega os JARs em `plugins/` via SPI. 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). 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 # Interface gráfica
A interface gráfica da aplicação é baseada em Swing e é centralizada na A interface gráfica é Swing, organizada em shell (`pitiupi.presentation.shell`):
classe `MainWindow`.
Durante a inicialização, cada plugin recebe `ConnectionManager` e `GuiManager` | Região | Componente |
no método `createPlugin()`. |--------|------------|
| 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 Plugins expõem `getName()` (técnico), `getIdentifier()` (SPI) e `getDisplayName()` (texto na galeria e nas abas).
[12_menus_unicast_multicast.md](12_menus_unicast_multicast.md).
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 ## Diagrama de comunicação
``` ```
+------------------+ +------------------+
| MainWindow | | MainShellFrame |
+--------+---------+ +--------+---------+
| |
+--------------------+--------------------+ +--------------------+--------------------+
+24 -5
View File
@@ -10,14 +10,17 @@ Métodos obrigatórios:
| Método | Função | | Método | Função |
| --- | --- | | --- | --- |
| `getName()`, `getAuthor()`, `getVersion()`, `getIdentifier()` | Identificação (o `identifier` distingue plugins em conexões TCP dedicadas) | | `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 | | `getMessages()` | Lista de classes `Message` que o plugin desserializa |
| `receiveMessage(Message)` | Processamento após o `PluginManager` identificar o tipo | | `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 | | 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 | | `receiveAllTypes()` | Receber todas as mensagens públicas |
| `receivePrivateConnection(TcpConnection)` | Aceitar conexão TCP direcionada ao plugin | | `receivePrivateConnection(TcpConnection)` | Aceitar conexão TCP direcionada ao plugin |
| `receiveBytes(TcpConnection, byte[])` | Ler payload bruto na conexão privada | | `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 ### 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)`. 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()`. 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`. 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.
+3 -3
View File
@@ -1,10 +1,10 @@
# Tutorial — criar um plugin # 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 ## 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. - Ajuste `project.properties` / `build.xml` com o nome do JAR de saída.
- Mantenha `javac.classpath` apontando para `../pitiupi/dist/pitiupi.jar`. - 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) ## 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 ## Erros frequentes
+20 -49
View File
@@ -1,60 +1,31 @@
# Plugins de referência # 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 ## Comparativo
| | pitiupi.chat | pitiupi.screenCast | pitiupi.mpChat | | | plugins/chat | plugins/screencast |
| --- | --- | --- | --- | | --- | --- | --- |
| Build | Ant | Ant | Make | | Build | Ant | Ant |
| id (`plugins.registry.json`) | `chat` | `screencast` | `mpchat` | | id | `chat` | `screencast` |
| Classe | `ChatPlugin` | `ScreenCastPlugin` | `MultiProtocolChat` | | Classe | `ChatPlugin` | `ScreenCastPlugin` |
| Categoria menu | `Social` | `Streaming` | `Social` (multicast + unicast) | | Mensagem | `ChatMessage`, `LegacyMpChatMessage` | `ScreenCastMessage` |
| Mensagem | `ChatMessage` | `ScreenCastMessage` | `MpChatMessage` | | UI | `ChatPanel` (destino + transporte) | `ScreenCastPanel` (destino + transporte) |
| Transporte principal | UDP (via `ConnectionManager`) | UDP / payloads maiores | UDP público + **TCP privado** | | TCP privado | Toggle no `ChatPanel` + hooks `receiveBytes` (histórico `private:<IP>`) | — |
| TCP avançado | Não | Depende da versão do módulo | `receivePrivateConnection`, `receiveBytes` |
## pitiupi.chat ## plugins/chat
- **Referência canônica** alinhada ao contrato atual `Plugin` + `ConnectionManager` / `GuiManager`. - Sala geral ou um peer (lista + IP).
- `createPlugin` registra `ChatMenu` em `MenuCategory.Social`. - Transportes: multicast UDP, unicast UDP, TCP via `MessageDispatcher`.
- `getMessages()` retorna `ChatMessage.class`; `receiveMessage` atualiza `ChatWindow`. - 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`). ## Exercício Ping
- Útil para discutir **tamanho de datagramas**, compressão (`ImageTransformation`) e limites UDP.
Caminhos: `pitiupi.screenCast/src/pitiupi/screenCast/`. Alinhado ao contrato `Plugin` + `ConnectionManager` / `GuiManager` (como `ChatPlugin`). Material em [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md) e pasta `plugins/ping/` (sem JAR no repositório).
## 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).
@@ -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 | - **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.
| `Jogos` | Plugins de jogos entre peers | - Dentro da aba do plugin, **`PluginRoutingPanel`** padroniza destino (sala geral vs IP), forma de envio (multicast UDP, UDP unicast, TCP) e texto de ajuda.
| `Social` | Chat, presença, mensagens sociais |
| `Streaming` | Screen cast, mídia |
## 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 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(...)`.
guiManager.createMenu(MenuCategory.Social, new ChatMenu(chatWindow));
```
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 ## Unicast vs multicast na aplicação
| Modo | API típica | Semântica | | 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 | | 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). 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).
+9 -6
View File
@@ -1,5 +1,7 @@
# Guia para desenvolvedor e agente # 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 ## Leitura mínima antes de mudar código
1. [00_quickstart.md](00_quickstart.md) — build e execução 1. [00_quickstart.md](00_quickstart.md) — build e execução
@@ -19,16 +21,17 @@
## Gate de build ## 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 ```bash
cd pitiupi && ant jar cd pitiupi && ant jar && ant test
cd ../pitiupi.chat && ant jar cd plugins/chat && ant jar
cd ../pitiupi.screenCast && ant jar cd plugins/screencast && ant jar
cd ../pitiupi.mpChat && make
``` ```
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 ## Convenções
@@ -4,12 +4,13 @@
| Comportamento | Pacote / classe | | Comportamento | Pacote / classe |
| --- | --- | | --- | --- |
| Login, porta, nome | `pitiupi.GUI.Login` | | Login, porta, nome | `pitiupi.presentation.shell.LoginDialog` |
| Orquestração GUI + plugins | `pitiupi.GUI.MainWindow` | | Shell principal (abas, galeria) | `pitiupi.presentation.shell.MainShellFrame`, `TabWorkspacePanel` |
| Barra de menus base | `pitiupi.GUI.MenuBar` | | Menu Ajuda | `pitiupi.presentation.shell.ShellMenuBar` |
| Usuários online | `pitiupi.GUI.OnlineUsersPanel`, `HeartbeatManager` | | Usuários online, peer actions | `pitiupi.presentation.shell.NetworkUsersPanel`, `PeerActionBroker` |
| Facade rede para plugins | `pitiupi.control.ConnectionManager` | | 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` | | UDP | `pitiupi.net.udp.SocketUDP` |
| TCP servidor e mapa de conexões | `pitiupi.net.tcp.SocketTCP`, `TcpConnectionManager` | | TCP servidor e mapa de conexões | `pitiupi.net.tcp.SocketTCP`, `TcpConnectionManager` |
| Conexão TCP por peer | `pitiupi.net.tcp.TcpConnection` | | Conexão TCP por peer | `pitiupi.net.tcp.TcpConnection` |
@@ -24,7 +25,7 @@
``` ```
pitiupi (núcleo) pitiupi (núcleo)
↑ classpath ↑ classpath
pitiupi.chat | pitiupi.screenCast | pitiupi.mpChat plugins/chat | plugins/screencast
``` ```
Plugins **não** devem ser dependência do núcleo. Plugins **não** devem ser dependência do núcleo.
+35 -17
View File
@@ -1,15 +1,18 @@
# Testes e gate # 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. ```bash
2. **Teste manual** com duas instâncias na rede. 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 ```bash
./scripts/pitiupi.sh build --all ./scripts/pitiupi.sh build --all
@@ -19,26 +22,41 @@ Equivalente manual:
```bash ```bash
cd pitiupi && ant jar cd pitiupi && ant jar
cd ../pitiupi.chat && ant jar cd plugins/chat && ant jar
cd ../pitiupi.screenCast && ant jar cd plugins/screencast && ant jar
cd ../pitiupi.mpChat && make
``` ```
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 ## Checklist manual — uma máquina (5000 / 5001)
- [ ] Nomes visíveis no painel de usuários em até ~15 s
- [ ] Plugin de referência troca mensagens entre os dois - [ ] Duas instâncias com portas distintas; nomes diferentes
- [ ] Encerrar uma instância remove o peer da lista após timeout de heartbeat - [ ] 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 ## 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 - [ ] 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 - [ ] Demonstração mínima professor ↔ aluno ou aluno ↔ aluno
## Melhorias futuras ## 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).
+134
View File
@@ -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)
+3 -2
View File
@@ -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 | | 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 | | 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 | | 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 | | 4 | [04_inicializacao_e_gui.md](04_inicializacao_e_gui.md) | Boot, MainWindow, diagrama |
| 5 | [05_udp_multicast.md](05_udp_multicast.md) | SocketUDP | | 5 | [05_udp_multicast.md](05_udp_multicast.md) | SocketUDP |
| 6 | [06_tcp_conexoes.md](06_tcp_conexoes.md) | SocketTCP, TcpConnection, framing | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 14 | [14_arquitetura_e_manutencao.md](14_arquitetura_e_manutencao.md) | Onde editar cada comportamento |
+3 -3
View File
@@ -2,10 +2,10 @@
| Item | Prioridade | Notas | | 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 | | 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. Ao fechar um item, atualizar esta tabela e o doc técnico relacionado.
Whitespace-only changes.
@@ -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
@@ -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;
}
}
@@ -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<Class<? extends Message>> 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";
}
}
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-5
View File
@@ -1,5 +0,0 @@
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_X" default="true" project-jdk-name="openjdk-26" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/pitiupi.mpChat.iml" filepath="$PROJECT_DIR$/pitiupi.mpChat.iml" />
</modules>
</component>
</project>
-16
View File
@@ -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)"/*
@@ -1 +0,0 @@
plugin.MultiProtocolChat
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module-library">
<library>
<CLASSES>
<root url="jar://$MODULE_DIR$/../pitiupi/dist/pitiupi.jar!/" />
</CLASSES>
<JAVADOC />
<SOURCES />
</library>
</orderEntry>
</component>
</module>
@@ -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";
}
}
@@ -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()
);
});
}
}
@@ -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);
}
}
}
@@ -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";
}
}
@@ -1 +0,0 @@
plugin.MultiProtocolChat
@@ -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<String, PrivateChatWindow> windows;
/*
* Endereços para os quais uma conexão já foi solicitada,
* mas ainda não chegou.
*/
private final Set<String> 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();
}
}
}
@@ -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<Class<? extends Message>> 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;
}
}
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
@@ -1,2 +0,0 @@
compile.on.save=true
user.properties.file=/home/flavio/snap/netbeans/145/build.properties
@@ -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());
}
}
@@ -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();
}
}
@@ -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<Class<? extends Message>> getMessages() {
return List.of(ScreenCastMessage.class);
}
@Override
public void receiveMessage(Message message) {
if (message instanceof ScreenCastMessage msg) {
this.screenCastWindow.receiveScreenCastMessage(msg);
}
}
}
+22
View File
@@ -70,4 +70,26 @@
nbproject/build-impl.xml file. nbproject/build-impl.xml file.
--> -->
<property name="junit.standalone" location="test/lib/junit-platform-console-standalone-1.10.2.jar"/>
<target name="test" depends="jar" description="JUnit 5 (console launcher)">
<mkdir dir="${build.test.classes.dir}"/>
<javac srcdir="test"
destdir="${build.test.classes.dir}"
classpath="${run.classpath}:${junit.standalone}"
includeantruntime="false"
source="17"
target="17"
encoding="UTF-8"/>
<java classname="org.junit.platform.console.ConsoleLauncher" fork="true" failonerror="true">
<classpath>
<pathelement path="${build.test.classes.dir}"/>
<pathelement path="${run.classpath}"/>
<pathelement location="${junit.standalone}"/>
</classpath>
<arg value="--scan-classpath"/>
<arg value="${build.test.classes.dir}"/>
</java>
</target>
</project> </project>
Whitespace-only changes.
@@ -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
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project-private xmlns="http://www.netbeans.org/ns/project-private/1">
<editor-bookmarks xmlns="http://www.netbeans.org/ns/editor-bookmarks/2" lastBookmarkId="0"/>
<open-files xmlns="http://www.netbeans.org/ns/projectui-open-files/2">
<group>
<file>file:/home/flavio/NetBeansProjects/pitiupi/src/pitiupi/control/PluginManager.java</file>
<file>file:/home/flavio/NetBeansProjects/pitiupi/src/pitiupi/control/ImageTransformation.java</file>
</group>
</open-files>
</project-private>
+6 -15
View File
@@ -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/`: 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`.
```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.
## Instalação automática (recomendado) ## Instalação automática (recomendado)
Na raiz do repositório, o catálogo [`plugins.registry.json`](../../plugins.registry.json) lista módulos buildáveis: Na raiz do repositório, o catálogo [`plugins.registry.json`](../../plugins.registry.json) lista módulos buildáveis:
```bash ```bash
./scripts/pitiupi.sh build --all
./scripts/pitiupi.sh link --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 ## Manual
```bash ```bash
cd pitiupi/plugins ln -sf /caminho/ao/clone/plugins/chat/dist/pitiupi.chat.jar ~/pitiupi/plugins/chat.jar
ln -sf ../../pitiupi.chat/dist/pitiupi.chat.jar 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). Novo módulo: registrar no catálogo e ver [docs/documentation/10_tutorial_plugin.md](../../docs/documentation/10_tutorial_plugin.md).
+41 -57
View File
@@ -8,97 +8,76 @@ package pitiupi.GUI;
import pitiupi.control.ConnectionManager; import pitiupi.control.ConnectionManager;
import pitiupi.control.GuiManager; import pitiupi.control.GuiManager;
import pitiupi.control.plugins.PluginManager; import pitiupi.control.plugins.PluginManager;
import pitiupi.presentation.shell.NetworkUsersPanel;
import pitiupi.presentation.shell.ShellHost;
import pitiupi.presentation.shell.ShellMenuBar;
import javax.swing.*; import javax.swing.*;
import java.awt.*; import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
/** /**
* Janela principal da aplicação. * @deprecated Substituído por {@link pitiupi.presentation.shell.MainShellFrame}.
*
* <p>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.</p>
*
* <p>A instância desta classe é compartilhada com os plugins para permitir
* integração com a aplicação principal.</p>
*
* @author flavio
* @author tony
* @author Gustavo
*/ */
public class MainWindow extends JFrame { @Deprecated
public class MainWindow extends JFrame implements ShellHost {
private MenuBar menuBar;
private JLabel status; private JLabel status;
OnlineUsersPanel onlineUsersPanel; private final NetworkUsersPanel networkUsersPanel;
private final ConnectionManager connectionManager; private final ConnectionManager connectionManager;
private final PluginManager pluginManager; private final PluginManager pluginManager;
private final GuiManager guiManager; 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) { public MainWindow(String userName, int port) {
initMenu();
pluginManager = new PluginManager(); 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(); initStatusPanel();
guiManager = new GuiManager(this, menuBar, onlineUsersPanel); guiManager = new GuiManager(this, menuBar, networkUsersPanel);
loadPlugins(); loadPlugins();
this.pack(); addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
exit();
}
});
this.setSize(new java.awt.Dimension(1024, 768)); pack();
} setSize(new 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);
} }
private void initStatusPanel() { private void initStatusPanel() {
JPanel statusPanel = new JPanel(); JPanel statusPanel = new JPanel();
status = new JLabel(); status = new JLabel();
statusPanel.setLayout(new FlowLayout(FlowLayout.RIGHT)); statusPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
updateStatus(); updateStatus();
statusPanel.add(status); statusPanel.add(status);
getContentPane().add(statusPanel, BorderLayout.PAGE_END);
this.getContentPane().add(statusPanel, BorderLayout.PAGE_END);
} }
@Override
public void exit() { public void exit() {
this.setVisible(false); connectionManager.shutdownGracefully();
this.dispose(); setVisible(false);
dispose();
System.exit(0); System.exit(0);
} }
@Override
public ConnectionManager getConnectionManager() { public ConnectionManager getConnectionManager() {
return connectionManager; return connectionManager;
} }
@@ -107,13 +86,18 @@ public class MainWindow extends JFrame {
pluginManager.loadPlugins(connectionManager, guiManager); pluginManager.loadPlugins(connectionManager, guiManager);
} }
@Override
public void setUserName(String userName) { public void setUserName(String userName) {
connectionManager.setUserName(userName); connectionManager.setUserName(userName);
updateStatus(); updateStatus();
} }
private void updateStatus() { @Override
this.status.setText("Connected on port " + connectionManager.getPort() + " name " + connectionManager.getUserName()); public void setStatusMessage(String message) {
status.setText(message);
} }
private void updateStatus() {
status.setText("Connected on port " + connectionManager.getPort() + " name " + connectionManager.getUserName());
}
} }
@@ -33,7 +33,7 @@ public class OnlineUsersPanel extends JPanel implements PeerListener {
initMenu(); initMenu();
setLayout(new BorderLayout()); setLayout(new BorderLayout());
setBorder(BorderFactory.createTitledBorder("Online")); setBorder(BorderFactory.createTitledBorder("Usuários nesta rede"));
setPreferredSize(new Dimension(160, 0)); setPreferredSize(new Dimension(160, 0));
listModel = new DefaultListModel<>(); listModel = new DefaultListModel<>();
@@ -64,6 +64,10 @@ public class OnlineUsersPanel extends JPanel implements PeerListener {
} }
userList.setSelectedIndex(index); userList.setSelectedIndex(index);
User selected = listModel.getElementAt(index);
if (selected != null && selected.isBroadcastTarget()) {
return;
}
menu.show(userList, e.getX(), e.getY()); menu.show(userList, e.getX(), e.getY());
} }
}); });
@@ -73,7 +77,7 @@ public class OnlineUsersPanel extends JPanel implements PeerListener {
menuItem.addActionListener(e -> { menuItem.addActionListener(e -> {
User user = userList.getSelectedValue(); User user = userList.getSelectedValue();
if (user != null) { if (user != null && !user.isBroadcastTarget() && user.getAddress() != null) {
action.accept(user); action.accept(user);
} }
}); });
@@ -92,6 +96,7 @@ public class OnlineUsersPanel extends JPanel implements PeerListener {
public void onPeersChanged(List<User> peers) { public void onPeersChanged(List<User> peers) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
listModel.clear(); listModel.clear();
listModel.addElement(User.broadcastAll());
for (User peer : peers) { for (User peer : peers) {
listModel.addElement(peer); listModel.addElement(peer);
} }
+17 -6
View File
@@ -5,7 +5,9 @@
*/ */
package pitiupi; package pitiupi;
import java.net.UnknownHostException; import pitiupi.platform.LegacyPluginMigration;
import pitiupi.platform.PitiupiHome;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
@@ -44,12 +46,21 @@ public class Main {
java.awt.EventQueue.invokeLater(new Runnable() { java.awt.EventQueue.invokeLater(new Runnable() {
@Override @Override
public void run() { public void run() {
pitiupi.GUI.Login login = new pitiupi.GUI.Login(); try {
login.setVisible(true); 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; pitiupi.presentation.shell.LoginDialog login = new pitiupi.presentation.shell.LoginDialog();
main = new pitiupi.GUI.MainWindow(login.getName(), login.getPort()); if (!login.showLogin()) {
main.setVisible(true); return;
}
pitiupi.presentation.shell.MainShellFrame shell =
new pitiupi.presentation.shell.MainShellFrame(login.getUserName(), login.getPort());
shell.showOnScreen();
} }
}); });
} }
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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<String, Map<String, Integer>> 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<String, Integer> 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<String, Integer> 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;
}
}
@@ -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<String, Integer> notificationCountByFromInstance = new ConcurrentHashMap<>();
private final Map<String, SessionInviteMessage> pendingByInviteId = new ConcurrentHashMap<>();
private final Map<String, Consumer<InviteDetails>> 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<InviteDetails> 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<InviteDetails> handler = handlersByPluginId.get(message.getPluginId());
if (handler != null) {
handler.accept(details);
}
onNotificationsChanged.run();
return true;
}
public String acceptFirstInviteFrom(String fromInstanceId) {
for (Map.Entry<String, SessionInviteMessage> 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);
}
}
@@ -0,0 +1,8 @@
package pitiupi.application.invites;
public enum SessionInviteKind {
CHAT,
SCREEN,
PING,
CUSTOM
}
@@ -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<InetAddress> 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<InetAddress> onlinePeerAddresses(ConnectionManager connectionManager) {
List<InetAddress> addresses = new ArrayList<>();
for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) {
if (view.isBroadcastTarget() || view.isSelf()) {
continue;
}
view.getInetAddress().ifPresent(addresses::add);
}
return addresses;
}
}
@@ -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);
}
}
@@ -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<TransportMode> 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<TransportMode> forSinglePeer(PluginKind kind) {
return switch (kind) {
case CHAT, SCREEN_CAST -> List.of(
TransportMode.UNICAST_UDP,
TransportMode.TCP
);
};
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<PeerAction> actions) {
}
@@ -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<PeerActionTarget> targets;
private final PeerActionHandler handler;
public PeerAction(String id, String label, Set<PeerActionTarget> 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<PeerActionTarget> getTargets() {
return targets;
}
public PeerActionHandler getHandler() {
return handler;
}
public boolean supports(PeerActionTarget target) {
return targets.contains(target);
}
}
@@ -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<String, List<PeerAction>> actionsByPlugin = new ConcurrentHashMap<>();
private final Map<String, String> 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<PeerAction> getActionsFor(PeerActionTarget target) {
if (activePluginId == null) {
return List.of();
}
return filterActions(actionsByPlugin.getOrDefault(activePluginId, List.of()), target);
}
public List<GroupedPeerActions> getAllActionsFor(PeerActionTarget target) {
List<GroupedPeerActions> groups = new ArrayList<>();
List<String> pluginIds = new ArrayList<>(actionsByPlugin.keySet());
pluginIds.sort(Comparator.comparing(id -> displayNameByPlugin.getOrDefault(id, id)));
for (String pluginId : pluginIds) {
List<PeerAction> 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<PeerAction> filterActions(List<PeerAction> registered, PeerActionTarget target) {
List<PeerAction> 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);
}
}
}
@@ -0,0 +1,7 @@
package pitiupi.application.peeractions;
@FunctionalInterface
public interface PeerActionHandler {
void run(PeerTarget target);
}
@@ -0,0 +1,9 @@
package pitiupi.application.peeractions;
/**
* Público-alvo de uma peer action.
*/
public enum PeerActionTarget {
SINGLE_PEER,
ALL_PEERS
}
@@ -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 {
}
}
@@ -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);
}
}
@@ -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<UserPeerView> listPeerViews() {
List<UserPeerView> 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();
}
}
@@ -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<InetAddress> 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);
}
}
@@ -1,7 +1,12 @@
package pitiupi.control; 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.control.plugins.PluginManager;
import pitiupi.models.Messages.GoodbyeMessage;
import pitiupi.models.Messages.HeartbeatMessage; import pitiupi.models.Messages.HeartbeatMessage;
import pitiupi.models.Messages.Message; import pitiupi.models.Messages.Message;
import pitiupi.net.tcp.SocketTCP; import pitiupi.net.tcp.SocketTCP;
@@ -9,6 +14,7 @@ import pitiupi.net.tcp.TcpConnection;
import pitiupi.net.udp.SocketUDP; import pitiupi.net.udp.SocketUDP;
import java.net.InetAddress; import java.net.InetAddress;
import java.util.function.Consumer;
public class ConnectionManager { public class ConnectionManager {
private final String pitiupiIdentifier; private final String pitiupiIdentifier;
@@ -16,13 +22,20 @@ public class ConnectionManager {
private final PluginManager pluginManager; private final PluginManager pluginManager;
private final HeartbeatManager heartbeatManager; private final HeartbeatManager heartbeatManager;
private final PresenceService presenceService;
private final PeerActionBroker peerActionBroker;
private final InviteInboxService inviteInboxService;
private final PeerActivityInbox peerActivityInbox;
private SocketTCP socketTCP; private SocketTCP socketTCP;
private SocketUDP socketUDP; private SocketUDP socketUDP;
private String username; private String username;
private int port; private int port;
private volatile boolean shutdownComplete;
private Consumer<String> inviteAcceptedUiListener = pluginId -> {
};
public ConnectionManager(MainWindow main, String username, PluginManager pluginManager, int port) { public ConnectionManager(String username, PluginManager pluginManager, int port) {
this.pluginManager = pluginManager; this.pluginManager = pluginManager;
this.username = username; this.username = username;
@@ -33,7 +46,64 @@ public class ConnectionManager {
reconnect(); reconnect();
heartbeatManager = new HeartbeatManager(this); 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(); 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<String> 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) { public TcpConnection connectPluginTcp(InetAddress destinationAdress, String senderId, String receiverId) {
@@ -72,7 +142,11 @@ public class ConnectionManager {
} }
public void receiveHeartbeat(HeartbeatMessage message, InetAddress destinationAddress) { 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) { public void setPort(int port) {
+10 -25
View File
@@ -1,8 +1,7 @@
package pitiupi.control; package pitiupi.control;
import pitiupi.GUI.MainWindow; import pitiupi.presentation.shell.NetworkUsersPanel;
import pitiupi.GUI.MenuBar; import pitiupi.presentation.shell.ShellHost;
import pitiupi.GUI.OnlineUsersPanel;
import pitiupi.models.Menus.*; import pitiupi.models.Menus.*;
import pitiupi.models.Menus.MulticastMenus.MulticastActionMenu; import pitiupi.models.Menus.MulticastMenus.MulticastActionMenu;
import pitiupi.models.Menus.MulticastMenus.MulticastMenu; import pitiupi.models.Menus.MulticastMenus.MulticastMenu;
@@ -16,39 +15,25 @@ import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
public class GuiManager { public class GuiManager {
private MainWindow mainWindow; private final ShellHost shellHost;
private MenuBar menuBar; private final NetworkUsersPanel networkUsersPanel;
private OnlineUsersPanel onlineUsersPanel;
private Logger logger; private Logger logger;
public GuiManager(MainWindow mainWindow, MenuBar menuBar, OnlineUsersPanel onlineUsersPanel) { public GuiManager(ShellHost shellHost, javax.swing.JMenuBar menuBar, NetworkUsersPanel networkUsersPanel) {
this.mainWindow = mainWindow; this.shellHost = shellHost;
this.menuBar = menuBar; this.networkUsersPanel = networkUsersPanel;
this.onlineUsersPanel = onlineUsersPanel;
this.logger = Logger.getLogger(GuiManager.class.getName()); this.logger = Logger.getLogger(GuiManager.class.getName());
} }
public void createMenu(MenuCategory category, Menu menu) { 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 { try {
JMenuItem newMenu = buildMenu(menu); JMenuItem newMenu = buildMenu(menu);
if (menu instanceof MulticastMenu) { if (menu instanceof MulticastMenu) {
menuBar.addPluginMenu(category, newMenu); // Onda D: ações multicast migram para peer actions / abas (Onda E).
} }
if (menu instanceof UnicastMenu) { if (menu instanceof UnicastMenu) {
onlineUsersPanel.addUnicastMenu(category, newMenu); networkUsersPanel.addUnicastMenu(category, newMenu);
} }
} }
catch (IllegalArgumentException e) { catch (IllegalArgumentException e) {
@@ -86,7 +71,7 @@ public class GuiManager {
if (menu instanceof UnicastActionMenu unicastActionMenu) { if (menu instanceof UnicastActionMenu unicastActionMenu) {
JMenuItem newMenu = new JMenuItem(menu.getText()); JMenuItem newMenu = new JMenuItem(menu.getText());
onlineUsersPanel.initUnicastMenu(newMenu, unicastActionMenu::onClick); networkUsersPanel.initUnicastMenu(newMenu, unicastActionMenu::onClick);
return newMenu; return newMenu;
} }
@@ -11,6 +11,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import pitiupi.models.User; import pitiupi.models.User;
import pitiupi.models.Messages.GoodbyeMessage;
import pitiupi.models.Messages.HeartbeatMessage; import pitiupi.models.Messages.HeartbeatMessage;
import java.util.UUID; import java.util.UUID;
/** /**
@@ -40,6 +41,17 @@ public class HeartbeatManager {
this.connectionManager = connectionManager; 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. * Inicia o mecanismo de heartbeat.
*/ */
@@ -59,10 +71,31 @@ public class HeartbeatManager {
} }
private void announce() { private void announce() {
if (connectionManager == null) {
return;
}
HeartbeatMessage msg = new HeartbeatMessage(connectionManager.getUserName(), instanceId); HeartbeatMessage msg = new HeartbeatMessage(connectionManager.getUserName(), instanceId);
connectionManager.sendUdpMulticast(msg); 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 // Chamado pelo Socket quando um pacote é identificado como heartbeat
/** /**
* Processa uma mensagem de heartbeat recebida de outro usuário. * Processa uma mensagem de heartbeat recebida de outro usuário.
@@ -85,7 +118,7 @@ public class HeartbeatManager {
String key = msg.getInstanceId(); String key = msg.getInstanceId();
boolean isNew = !peers.containsKey(key); 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()); peer.touch(System.currentTimeMillis());
if (isNew) { if (isNew) {
@@ -138,4 +171,11 @@ public class HeartbeatManager {
public List<User> getPeers() { public List<User> getPeers() {
return new ArrayList<>(peers.values()); return new ArrayList<>(peers.values());
} }
/**
* Re-notifica listeners (ex.: contadores de convite na lista).
*/
public void notifyPeerListeners() {
notifyListeners();
}
} }
@@ -1,12 +1,16 @@
package pitiupi.control.plugins; package pitiupi.control.plugins;
import pitiupi.Main; import pitiupi.Main;
import pitiupi.api.plugin.LegacyPluginWorkspace;
import pitiupi.api.plugin.PluginWorkspace;
import pitiupi.models.Plugin;
import pitiupi.control.ConnectionManager; import pitiupi.control.ConnectionManager;
import pitiupi.control.GuiManager; import pitiupi.control.GuiManager;
import pitiupi.errors.ErrorCode;
import pitiupi.models.Messages.Message; import pitiupi.models.Messages.Message;
import pitiupi.platform.PitiupiHome;
import pitiupi.plugins.store.PluginMetadataWriter;
import pitiupi.net.tcp.TcpConnection; import pitiupi.net.tcp.TcpConnection;
import pitiupi.models.Plugin;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@@ -15,6 +19,7 @@ import java.net.URLClassLoader;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.function.Function;
import java.util.ServiceLoader; import java.util.ServiceLoader;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
@@ -24,12 +29,14 @@ public class PluginManager {
private final HashSet<String> loadedJars; private final HashSet<String> loadedJars;
private final Logger logger; private final Logger logger;
private final PluginMetadataWriter metadataWriter;
public PluginManager() { public PluginManager() {
this.loadedPlugins = new ArrayList<>(); this.loadedPlugins = new ArrayList<>();
this.loadedJars = new HashSet<>(); this.loadedJars = new HashSet<>();
logger = Logger.getLogger(getClass().getName()); logger = Logger.getLogger(getClass().getName());
this.metadataWriter = new PluginMetadataWriter();
} }
public void handleMessage(byte[] msg) { public void handleMessage(byte[] msg) {
@@ -90,8 +97,39 @@ public class PluginManager {
plugin.onSocketClosed(connection); plugin.onSocketClosed(connection);
} }
public List<Plugin> 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) { 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<Plugin, PluginWorkspace> workspaceFactory
) {
File pluginDir = PitiupiHome.get().pluginsDirectory().toFile();
File[] jarFiles = pluginDir.listFiles((dir, name) -> name.endsWith(".jar")); File[] jarFiles = pluginDir.listFiles((dir, name) -> name.endsWith(".jar"));
if (jarFiles == null || jarFiles.length == 0) { if (jarFiles == null || jarFiles.length == 0) {
@@ -118,13 +156,20 @@ public class PluginManager {
for (Plugin plugin : serviceLoader) { for (Plugin plugin : serviceLoader) {
LoadedPlugin loadedPlugin = new LoadedPlugin(plugin, classLoader); LoadedPlugin loadedPlugin = new LoadedPlugin(plugin, classLoader);
plugin.createPlugin(connectionManager, guiManager); PluginWorkspace workspace = workspaceFactory.apply(plugin);
plugin.createPlugin(connectionManager, workspace);
loadedPlugins.add(loadedPlugin); loadedPlugins.add(loadedPlugin);
loadedJars.add(file.getName()); 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) { } 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);
} }
} }
} }
+21
View File
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
+50
View File
@@ -4,11 +4,16 @@
*/ */
package pitiupi.models; 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.ConnectionManager;
import pitiupi.control.GuiManager; import pitiupi.control.GuiManager;
import pitiupi.models.Messages.Message; import pitiupi.models.Messages.Message;
import pitiupi.net.tcp.TcpConnection; import pitiupi.net.tcp.TcpConnection;
import javax.swing.JComponent;
import java.util.List; import java.util.List;
/** /**
@@ -20,11 +25,34 @@ import java.util.List;
* @author flavio * @author flavio
*/ */
public abstract class Plugin { public abstract class Plugin {
private JComponent shellWorkspaceContent;
public abstract String getName(); 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 getAuthor();
public abstract String getVersion(); public abstract String getVersion();
public abstract String getIdentifier(); 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. * Inicializa o plugin.
* *
@@ -35,6 +63,28 @@ public abstract class Plugin {
*/ */
public abstract void createPlugin(ConnectionManager connectionManager, GuiManager guiManager); 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. * Retorna uma mensagem utilizada pelo plugin.
* *
+23
View File
@@ -12,14 +12,34 @@ import java.net.InetAddress;
*/ */
public class User { public class User {
public static final String BROADCAST_INSTANCE_ID = "__broadcast__";
private final String userName; private final String userName;
private final InetAddress address; private final InetAddress address;
private volatile long lastSeen; private volatile long lastSeen;
private final String instanceId;
public User(String userName, InetAddress address, long lastSeen) { 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.userName = userName;
this.address = address; this.address = address;
this.lastSeen = lastSeen; 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() { public String getUserName() {
@@ -45,6 +65,9 @@ public class User {
@Override @Override
public String toString() { public String toString() {
if (isBroadcastTarget() || address == null) {
return userName;
}
return userName + " (" + address.getHostAddress() + ")"; return userName + " (" + address.getHostAddress() + ")";
} }
} }
+20 -5
View File
@@ -15,7 +15,9 @@ import java.util.logging.Logger;
import pitiupi.control.ConnectionManager; import pitiupi.control.ConnectionManager;
import pitiupi.control.plugins.PluginManager; import pitiupi.control.plugins.PluginManager;
import pitiupi.models.Messages.GoodbyeMessage;
import pitiupi.models.Messages.HeartbeatMessage; import pitiupi.models.Messages.HeartbeatMessage;
import pitiupi.models.Messages.SessionInviteMessage;
import pitiupi.models.Messages.Message; import pitiupi.models.Messages.Message;
/** /**
@@ -151,11 +153,19 @@ public class SocketUDP extends Thread {
throw ex; throw ex;
} }
HeartbeatMessage heartbeat = tryParseHeartbeat(msgPacket.getData()); Object coreMessage = tryParseCoreUdpMessage(msgPacket.getData());
if (heartbeat != null) { if (coreMessage instanceof HeartbeatMessage heartbeat) {
connectionManager.receiveHeartbeat(heartbeat, msgPacket.getAddress()); connectionManager.receiveHeartbeat(heartbeat, msgPacket.getAddress());
continue; continue;
} }
if (coreMessage instanceof GoodbyeMessage goodbye) {
connectionManager.receiveGoodbye(goodbye);
continue;
}
if (coreMessage instanceof SessionInviteMessage invite) {
connectionManager.receiveSessionInvite(invite);
continue;
}
pluginManager.handleMessage(msgPacket.getData()); pluginManager.handleMessage(msgPacket.getData());
} }
@@ -168,14 +178,19 @@ public class SocketUDP extends Thread {
* @return a mensagem de heartbeat caso os dados correspondam a uma; * @return a mensagem de heartbeat caso os dados correspondam a uma;
* {@code null} caso contrário. * {@code null} caso contrário.
*/ */
private HeartbeatMessage tryParseHeartbeat(byte[] data) { private Object tryParseCoreUdpMessage(byte[] data) {
try { try {
ByteArrayInputStream bais = new ByteArrayInputStream(data); ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInput in = new ObjectInputStream(bais); ObjectInput in = new ObjectInputStream(bais);
Object obj = in.readObject(); 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) { } catch (IOException | ClassNotFoundException | ClassCastException ex) {
return null; return null;
} }
} }
@@ -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);
}
}
@@ -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<Path> 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<Path> 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();
}
}
}
@@ -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();
}
}
@@ -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<String> arquivoId;
private final Optional<String> version;
private final Optional<String> name;
private final Optional<String> displayName;
private final Optional<String> identifier;
private final Optional<String> author;
public InstalledPluginRef(
Path jarPath,
String fileName,
Optional<String> arquivoId,
Optional<String> version,
Optional<String> name,
Optional<String> displayName,
Optional<String> identifier,
Optional<String> 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<String> getArquivoId() {
return arquivoId;
}
public Optional<String> getVersion() {
return version;
}
public Optional<String> getName() {
return name;
}
public Optional<String> getDisplayName() {
return displayName;
}
public Optional<String> getIdentifier() {
return identifier;
}
public Optional<String> getAuthor() {
return author;
}
}
@@ -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();
}
}
@@ -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<InstalledPluginRef> listInstalled() throws IOException {
if (!Files.isDirectory(pluginsDir)) {
return List.of();
}
List<InstalledPluginRef> result = new ArrayList<>();
try (Stream<Path> 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<Path> 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<String> 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;
}
}
@@ -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<String> 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<String> currentJvmArgs() {
return List.copyOf(java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments());
}
private static String locateRunningJar() {
return pitiupi.Main.class.getProtectionDomain().getCodeSource().getLocation().getPath();
}
}
}
@@ -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<String> orderedKeys = new ArrayList<>();
private final Map<String, Integer> keyToIndex = new HashMap<>();
private Consumer<String> onTabSelected = key -> {
};
private Consumer<String> 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<String> onTabSelected) {
this.onTabSelected = onTabSelected != null ? onTabSelected : key -> {
};
}
public void setOnTabClosed(Consumer<String> 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<Integer> 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);
}
}
}
@@ -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<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
while (interfaces.hasMoreElements()) {
NetworkInterface ni = interfaces.nextElement();
if (!ni.isUp() || ni.isLoopback() || ni.isVirtual()) {
continue;
}
Enumeration<InetAddress> 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<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
while (interfaces.hasMoreElements()) {
NetworkInterface ni = interfaces.nextElement();
if (!ni.isUp()) {
continue;
}
Enumeration<InetAddress> 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;
}
}
@@ -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();
}
}
}
@@ -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();
}
}
@@ -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<Plugin> 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);
}
}
@@ -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<DisplayPeer> listModel = new DefaultListModel<>();
private final JList<DisplayPeer> userList = new JList<>(listModel);
private final JPopupMenu menu = new JPopupMenu();
private final Map<MenuCategory, JMenu> 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<User> 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<User> 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()
);
}
}
}
@@ -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<String> onCommit;
public PeerDraftPanel(String title, String hint, Consumer<String> 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());
}
}
}
@@ -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<String, String> onPluginSelected;
public PluginGalleryPanel(BiConsumer<String, String> onPluginSelected) {
this.onPluginSelected = onPluginSelected;
setLayout(new BorderLayout());
setBorder(new EmptyBorder(6, 6, 6, 6));
}
public void setOpenHandler(BiConsumer<String, String> onPluginSelected) {
this.onPluginSelected = onPluginSelected;
}
public void setPlugins(List<Plugin> plugins) {
removeAll();
if (plugins.isEmpty()) {
add(new JLabel(
"<html><center>Escolha um plugin para abrir aqui.<br/>"
+ "Tudo acontece nesta área — sem janelas extras.</center></html>",
SwingConstants.CENTER
), BorderLayout.CENTER);
return;
}
JPanel grid = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
List<Plugin> 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 "<html>"
+ plugin.getDisplayName()
+ "<br/><i>" + plugin.getName() + "</i>"
+ "<br/>ID: " + plugin.getIdentifier()
+ "<br/>v" + plugin.getVersion() + " · " + plugin.getAuthor()
+ "</html>";
}
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;
}
}
@@ -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<TransportMode> 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<Consumer<RoutingSnapshot>> listeners = new ArrayList<>();
private Consumer<Boolean> 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<RoutingSnapshot> listener) {
if (listener != null) {
listeners.add(listener);
}
}
public void setPrivateTcpListener(Consumer<Boolean> 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("<html><b>Sessão:</b> Sala geral (Todos)</html>");
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<TransportMode> modes = everyoneInRoom
? PluginTransportCatalog.forBroadcast(pluginKind)
: PluginTransportCatalog.forSinglePeer(pluginKind);
DefaultComboBoxModel<TransportMode> 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("<html><i>— " + line + "</i></html>");
}
private void updatePrivateToggleLabel() {
privateTcpToggle.setText(privateTcpToggle.isSelected()
? "TCP privado (ligado)"
: "TCP privado (desligado)");
}
private void fireRoutingChanged() {
RoutingSnapshot snap = snapshot();
for (Consumer<RoutingSnapshot> 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("&", "&amp;").replace("<", "&lt;");
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more