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/dist/
/pitiupi/plugins/*.jar
/pitiupi.chat/build/
/pitiupi.chat/dist/
/pitiupi.chat/nbproject/private/
/pitiupi.screenCast/build/
/pitiupi.screenCast/dist/
/pitiupi.mpChat/out/
/pitiupi.mpChat/dist/
**/built-jar.properties
.idea/
/pitiupi/nbproject/private/
/plugins/chat/build/
/plugins/chat/dist/
/plugins/chat/nbproject/private/
/plugins/screencast/build/
/plugins/screencast/dist/
/plugins/screencast/nbproject/private/
/plugins/mpchat/out/
/plugins/mpchat/dist/
+23 -21
View File
@@ -1,6 +1,6 @@
# pitiupi
Ferramenta **P2P** para práticas de Redes de Computadores: núcleo em Java com arquitetura de **plugins** (chat, compartilhamento de tela, chat multi-protocolo).
Ferramenta **P2P** para práticas de Redes de Computadores: núcleo em Java com arquitetura de **plugins** (conversa na rede e compartilhamento de tela).
**Documentação completa:** [docs/README.md](docs/README.md) · **Quickstart:** [docs/documentation/00_quickstart.md](docs/documentation/00_quickstart.md)
@@ -15,10 +15,10 @@ chmod +x scripts/pitiupi.sh # só na primeira vez
./scripts/pitiupi.sh run --all
```
Compila o núcleo, os plugins do [catálogo](plugins.registry.json), instala JARs em `pitiupi/plugins/` e abre a aplicação.
Compila o núcleo, os plugins do [catálogo](plugins.registry.json), cria symlinks em **`PITIUPI_HOME/plugins`** (padrão `~/pitiupi/plugins`) e abre a aplicação.
1. Login: **nome** e **porta** (ex.: `5000`).
2. Menus dos plugins: barra **Plugin → Social | Jogos | Streaming** (cada plugin registra a categoria no código).
2. Abra plugins na aba **Plugins** (galeria) ou pelas ações na lista **Usuários nesta rede** (peer actions).
3. P2P no mesmo PC: segunda instância com **porta diferente** (ex.: `5001`).
```bash
@@ -35,38 +35,37 @@ Novos módulos entram no repositório adicionando uma entrada no JSON (não é p
| Campo | Significado |
| --- | --- |
| `id` | Usado em `--plugins chat,meuJogo` |
| `dir` | Pasta do módulo (ex.: `pitiupi.xadrez`) |
| `dir` | Pasta do módulo (ex.: `plugins/xadrez`) |
| `build` | `ant` ou `make` |
| `jar` | Caminho do JAR relativo a `dir` |
| `menuCategory` | `Social`, `Jogos` ou `Streaming` — filtro CLI e documentação |
Plugins de referência hoje:
| id | Categoria (menu) | Pasta |
| id | Uso didático | Pasta |
| --- | --- | --- |
| `chat` | Social | `pitiupi.chat` |
| `mpchat` | Social | `pitiupi.mpChat` |
| `screencast` | Streaming | `pitiupi.screenCast` |
| `chat` | Sala geral, peer por IP, TCP privado | `plugins/chat` |
| `screencast` | Multicast ou unicast de tela | `plugins/screencast` |
A **categoria na interface** vem do `MenuCategory` no Java (`guiManager.createMenu(...)`), não do nome do JAR. O campo `menuCategory` no JSON alinha build/link com a doc.
Na interface, o texto das abas vem de `getDisplayName()` em cada plugin. O campo `menuCategory` no JSON alinha build/link com a documentação.
```bash
./scripts/pitiupi.sh run --all
./scripts/pitiupi.sh run --category Social
./scripts/pitiupi.sh run --plugins chat,screencast
./scripts/pitiupi.sh discover # pastas pitiupi.* com SPI ainda não no catálogo
./scripts/pitiupi.sh discover # pastas em plugins/ com SPI ainda não no catálogo
```
---
## Novo plugin (resumo)
1. Copiar estrutura de `pitiupi.chat` → `pitiupi.meuPlugin` ([tutorial](docs/documentation/10_tutorial_plugin.md)).
2. Em `createPlugin`, registrar menu: `guiManager.createMenu(MenuCategory.Jogos, ...)` (ou Social / Streaming).
1. Copiar estrutura de `plugins/chat` → `plugins/meuPlugin` ([tutorial](docs/documentation/10_tutorial_plugin.md)).
2. Em `createPlugin`, registrar painel com `PluginWorkspace` / `bindShellWorkspaceContent` e, se fizer sentido, **peer actions** na lista lateral.
3. Adicionar entrada em [`plugins.registry.json`](plugins.registry.json).
4. `./scripts/pitiupi.sh run --plugins meuId`.
4. `./scripts/pitiupi.sh link --all` e reiniciar o app.
O núcleo carrega **todos os `*.jar`** em `pitiupi/plugins/` (pasta única). Detalhes: [pitiupi/plugins/README.md](pitiupi/plugins/README.md).
O núcleo carrega **todos os `*.jar`** em `PITIUPI_HOME/plugins`. Detalhes: [pitiupi/plugins/README.md](pitiupi/plugins/README.md).
---
@@ -74,14 +73,14 @@ O núcleo carrega **todos os `*.jar`** em `pitiupi/plugins/` (pasta única). Det
| Ferramenta | Uso |
| --- | --- |
| **JDK** com `javac` | Compilação (OpenJDK **25** recomendado) |
| **JDK** com `javac` | Compilação (OpenJDK **25** recomendado; núcleo Java 17) |
| **Apache Ant** | Módulos NetBeans |
| **GNU Make** | `pitiupi.mpChat` |
| **python3** | Script `scripts/pitiupi.sh` (ler o catálogo) |
```bash
sudo apt install openjdk-25-jdk ant make
sudo apt install openjdk-25-jdk ant
./scripts/pitiupi.sh check
cd pitiupi && ant test # testes JUnit do núcleo
```
**GUI:** Swing — WSLg / `DISPLAY=:0`.
@@ -92,11 +91,11 @@ sudo apt install openjdk-25-jdk ant make
| Comando | Ação |
| --- | --- |
| `run` | Build + `plugins/` + app |
| `build` | Build + `plugins/` |
| `run` | Build + link + app |
| `build` | Build + link |
| `link` | Só symlinks (JARs já existem) |
| `list` | Catálogo e status do JAR |
| `discover` | Sugestão de módulos `pitiupi.*` |
| `discover` | Sugestão de módulos com SPI |
| `check` | PATH + JSON válido |
Opções: `--all` (padrão), `--plugins ids`, `--category Social|Jogos|Streaming`.
@@ -105,7 +104,10 @@ Opções: `--all` (padrão), `--plugins ids`, `--category Social|Jogos|Streaming
## Teste em rede
Duas instâncias, portas distintas, mesma LAN. Ver [docs/documentation/15_testes_e_gate.md](docs/documentation/15_testes_e_gate.md) e [appendix_portas_e_multicast.md](docs/documentation/appendix_portas_e_multicast.md).
- **Mesmo PC:** duas instâncias, portas distintas (ex. 5000 e 5001).
- **Dois PCs na mesma Wi‑Fi:** mesma porta (ex. 5000), nomes diferentes, mesmos JARs em `~/pitiupi/plugins` em cada máquina.
Ver [docs/documentation/15_testes_e_gate.md](docs/documentation/15_testes_e_gate.md) e [appendix_portas_e_multicast.md](docs/documentation/appendix_portas_e_multicast.md).
---
+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) |
| D7 | Plugins via JAR + Java SPI | Carregamento dinâmico sem recompilar o núcleo | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) |
| D8 | Classloader por JAR de plugin | Isolar dependências; desserialização com `PluginObjectInputStream` | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) |
| D9 | Plugins em `PITIUPI_HOME/plugins` | Dados do usuário fora do clone Git; `PITIUPI_HOME` ou `~/pitiupi` | [00_quickstart.md](documentation/00_quickstart.md) |
| D10 | Erros com `ErrorCode` | Mensagens PT na UI; `PitiupiException` no domínio | [13_guia_dev_e_agente.md](documentation/13_guia_dev_e_agente.md) |
| D11 | Reinício após instalar/remover JAR | Mutar `plugins/` exige reiniciar o app para recarregar SPI | [04_inicializacao_e_gui.md](documentation/04_inicializacao_e_gui.md) |
| D16 | UI em abas; sem `JFrame` de plugin | Conteúdo na `MainShellFrame`; galeria e peer actions abrem abas | [04_inicializacao_e_gui.md](documentation/04_inicializacao_e_gui.md) |
| D17 | Goodbye + timeout de presença | `GoodbyeMessage` em multicast ao fechar; timeout ~15 s mantido | [08_heartbeat_e_presenca.md](documentation/08_heartbeat_e_presenca.md) |
| D18 | Peer actions + convites | `PeerActionBroker`, `SessionInviteMessage`, badge (UI completa na shell) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) |
| D19 | Chat unificado | Um JAR `plugins/chat` com sala geral, peer/IP e TCP privado; mpChat removido do catálogo | [11_plugins_referencia.md](documentation/11_plugins_referencia.md) |
| D20 | Peer actions por plugin no popup | Lista lateral agrupa ações de todos os plugins (submenus), não só a aba ativa | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) |
| D21 | `onShellTabClosed` | Fechar aba do plugin na shell notifica o plugin (ex.: parar screencast) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) |
Novas decisões: adicionar linha nesta tabela e detalhar no arquivo técnico correspondente.
+16 -32
View File
@@ -3,61 +3,45 @@
## Pré-requisitos
- **JDK completo** (`java` e `javac`) — OpenJDK 25 recomendado; o núcleo usa `javac.source/target=17`, plugins Ant usam 25.
- Apache Ant (`pitiupi`, `pitiupi.chat`, `pitiupi.screenCast`)
- GNU Make (`pitiupi.mpChat`)
- Apache Ant (`pitiupi`, `plugins/chat`, `plugins/screencast`)
Ubuntu / WSL:
```bash
sudo apt install openjdk-25-jdk ant make
which javac ant make
sudo apt install openjdk-25-jdk ant
which javac ant
```
Se `ant jar` falhar com *Cannot run program "javac"*, você instalou apenas JRE — instale `openjdk-*-jdk`.
## Build
Na raiz do clone:
```bash
cd pitiupi && ant jar
cd ../pitiupi.chat && ant jar
cd ../pitiupi.screenCast && ant jar
cd ../pitiupi.mpChat && make
./scripts/pitiupi.sh build --all
```
O núcleo gera `pitiupi/dist/pitiupi.jar`. Cada plugin gera JAR em seu `dist/`.
Ou manualmente:
```bash
cd pitiupi && ant jar
cd plugins/chat && ant jar
cd plugins/screencast && ant jar
```
## Instalar plugins
A aplicação lê JARs de `pitiupi/plugins/` (relativo ao diretório de trabalho ao executar o núcleo). Detalhes: [pitiupi/plugins/README.md](../../pitiupi/plugins/README.md).
Symlinks em desenvolvimento (Linux):
Runtime: JARs em **`PITIUPI_HOME/plugins`** (padrão `~/pitiupi/plugins`).
```bash
cd pitiupi/plugins
ln -sf ../../pitiupi.chat/dist/pitiupi.chat.jar pitiupi.chat.jar
ln -sf ../../pitiupi.screenCast/dist/pitiupi.screenCast.jar pitiupi.screenCast.jar
ln -sf ../../pitiupi.mpChat/dist/mpChat.jar mpChat.jar
./scripts/pitiupi.sh link --all
```
## Executar
```bash
cd pitiupi
java -jar dist/pitiupi.jar
./scripts/pitiupi.sh run --all
```
No diálogo inicial informe **nome** e **porta** (UDP/TCP). Para duas instâncias no mesmo computador, use **portas diferentes** (ex.: 5000 e 5001).
Login com nome e porta; abra plugins pela aba **Plugins**. Clique em **Todos** ou em um peer na lista lateral para ver ações de cada plugin.
## Verificar P2P
1. Abra duas instâncias (mesma rede ou mesmo host com portas distintas).
2. Confirme que os nomes aparecem no painel de usuários online.
3. Teste um plugin de referência (ex.: chat em `MenuCategory.Social`).
Problemas de multicast: [appendix_portas_e_multicast.md](appendix_portas_e_multicast.md).
Gate de build (quatro módulos): [15_testes_e_gate.md](15_testes_e_gate.md).
Próximo passo para o TP: [10_tutorial_plugin.md](10_tutorial_plugin.md).
Validação rápida do núcleo: `cd pitiupi && ant test`.
@@ -25,9 +25,9 @@ Exemplos de tema: chat temático, tabuleiro (xadrez/damas), compartilhamento de
| Módulo | Ideia pedagógica |
| --- | --- |
| `pitiupi.chat` | Mensagens UDP + UI simples |
| `pitiupi.screenCast` | Payload maior / streaming de imagem |
| `pitiupi.mpChat` | Multicast + TCP privado no mesmo plugin |
| `plugins/chat` | Mensagens UDP + UI simples |
| `plugins/screencast` | Payload maior / streaming de imagem |
| `plugins/chat` | Multicast, unicast, TCP e canal TCP privado |
Detalhes: [11_plugins_referencia.md](11_plugins_referencia.md).
+15 -23
View File
@@ -1,36 +1,28 @@
# Módulos do repositório
```
pitiupi/ # clone (este repositório)
├── plugins.registry.json # catálogo de módulos (build/link via scripts/pitiupi.sh)
pitiupi/ # clone (este repositório)
├── plugins.registry.json
├── scripts/pitiupi.sh
├── pitiupi/ # núcleo P2P (executável)
├── pitiupi.chat/ # plugin referência — chat
├── pitiupi.screenCast/ # plugin referência — captura de tela
├── pitiupi.mpChat/ # plugin referência — chat multi-protocolo
└── docs/ # documentação canônica
├── pitiupi/ # núcleo P2P (executável)
├── plugins/
│ ├── chat/ # chat unificado (UDP/multicast/TCP + TCP privado)
│ ├── screencast/ # compartilhamento de tela
│ └── ping/ # exercício de aula (só doc)
└── docs/
```
## Núcleo (`pitiupi/`)
- **Build:** Apache Ant (`build.xml`, `nbproject/`).
- **Entrada:** `pitiupi.Main` → login → `MainWindow`.
- **Pacotes principais:** `pitiupi.net`, `pitiupi.control`, `pitiupi.GUI`, `pitiupi.models`.
- **Build:** Apache Ant.
- **Entrada:** `pitiupi.Main` → login → `MainShellFrame`.
- **Messaging:** `pitiupi.application.messaging.MessageDispatcher`.
## Plugins de referência
| Pasta | Build | JAR típico | Classe SPI |
| Pasta | Build | JAR típico | SPI |
| --- | --- | --- | --- |
| `pitiupi.chat` | `ant jar` | `dist/pitiupi.chat.jar` | `pitiupi.chat.plugin.ChatPlugin` |
| `pitiupi.screenCast` | `ant jar` | `dist/pitiupi.screenCast.jar` | `pitiupi.screenCast.plugin.ScreenCastPlugin` |
| `pitiupi.mpChat` | `make` | `dist/mpChat.jar` | `plugin.MultiProtocolChat` |
| `plugins/chat` | `ant jar` | `dist/pitiupi.chat.jar` | `pitiupi.chat.plugin.ChatPlugin` |
| `plugins/screencast` | `ant jar` | `dist/pitiupi.screenCast.jar` | `pitiupi.screenCast.plugin.ScreenCastPlugin` |
Todos dependem do JAR do núcleo no classpath de compilação.
## O que o aluno cria
Novo diretório irmão, por exemplo `pitiupi.xadrez/`, copiando a estrutura de `pitiupi.chat` (Ant + `META-INF/services`). Registre o módulo em `plugins.registry.json` e use `MenuCategory` adequado no código.
Não é obrigatório commitar o plugin do aluno neste repositório base; o professor pode definir fork ou entrega em zip.
Ver [03_build_ant_netbeans.md](03_build_ant_netbeans.md) e [10_tutorial_plugin.md](10_tutorial_plugin.md).
Ver [10_tutorial_plugin.md](10_tutorial_plugin.md) e [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md).
+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 | `javac.source` / `target` (NetBeans) |
| Módulo | Build | Java |
| --- | --- | --- |
| `pitiupi` | Ant | 17 |
| `pitiupi.chat` | Ant | 25 |
| `pitiupi.screenCast` | Ant | 25 |
| `pitiupi.mpChat` | Make | (sem `project.properties`; compila contra `pitiupi.jar`) |
Use um JDK que forneça `javac` (ex.: OpenJDK 25). Ant 1.10+ e Make no PATH.
## Núcleo e plugins NetBeans
Projetos com `nbproject/` e `build.xml`:
| `plugins/chat` | Ant | 25 |
| `plugins/screencast` | Ant | 25 |
```bash
cd pitiupi && ant jar
cd pitiupi.chat && ant jar
cd pitiupi.screenCast && ant jar
cd plugins/chat && ant jar
cd plugins/screencast && ant jar
```
Abrir no NetBeans: **File → Open Project** em cada pasta.
Classpath dos plugins: `../../pitiupi/dist/pitiupi.jar`.
O target `jar` produz o executável do núcleo e os JARs dos plugins. O classpath de compilação dos plugins aponta para `../pitiupi/dist/pitiupi.jar` (ver `project.properties` de cada módulo).
## pitiupi.mpChat (Makefile)
```bash
cd pitiupi.mpChat
make
```
O Makefile compila com `javac -cp ../pitiupi/dist/pitiupi.jar`, copia `META-INF` e gera `dist/mpChat.jar`. **Compile o núcleo antes.**
## Diretório `plugins/`
A pasta `pitiupi/plugins/` contém os JARs carregados em runtime. Apenas arquivos `.jar` são lidos. Ver [pitiupi/plugins/README.md](../../pitiupi/plugins/README.md).
## Ordem recomendada de build
1. `pitiupi` (núcleo)
2. Plugins em qualquer ordem
3. Symlink ou cópia para `pitiupi/plugins/`
## Gate automatizado (docentes)
Quem mantém o meta-workspace Cursor pode usar o script de gate que executa `ant jar` nos três módulos Ant e `make` no mpChat (requer Ant/Make no PATH). Detalhes em [15_testes_e_gate.md](15_testes_e_gate.md).
Catálogo: [`plugins.registry.json`](../../plugins.registry.json).
+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:
- **Núcleo da aplicação**: a `MainWindow` coordena GUI, rede e plugins.
- **Sistema de plugins**: extensões em JAR carregadas de `pitiupi/plugins/`.
- **Núcleo da aplicação**: a `MainShellFrame` coordena GUI, rede e plugins.
- **Sistema de plugins**: extensões em JAR carregadas de `PITIUPI_HOME/plugins` (dev: `./scripts/pitiupi.sh link --all`).
- **Comunicação de rede**: UDP (`SocketUDP`) e TCP (`SocketTCP`, `TcpConnection`).
## Inicialização da aplicação
1. A `MainWindow` é criada após o diálogo de login (nome e porta).
2. O `SocketUDP` associa-se à porta configurada e ingressa no grupo multicast.
3. O `SocketTCP` cria um `ServerSocket` na mesma porta para conexões TCP.
4. Os mecanismos de comunicação iniciam threads de recepção.
5. O `HeartbeatManager` envia heartbeats periodicamente.
6. O painel de usuários online registra-se como listener do heartbeat.
7. O `PluginManager` carrega os JARs em `plugins/` via SPI.
1. `Main` garante o layout em `PITIUPI_HOME` e abre o `LoginDialog` (nome e porta, PT-BR, ~30% da área útil do monitor, centralizado).
2. Após confirmar o login, a `MainShellFrame` é exibida (1024×768, centralizada).
3. O `SocketUDP` associa-se à porta configurada e ingressa no grupo multicast.
4. O `SocketTCP` cria um `ServerSocket` na mesma porta para conexões TCP.
5. Os mecanismos de comunicação iniciam threads de recepção.
6. O `HeartbeatManager` envia heartbeats periodicamente; o `NetworkUsersPanel` lista peers via `PresenceService`.
7. O `PluginManager` carrega os JARs em `plugins/` via SPI, com `ShellPluginWorkspace` ligado às abas centrais.
Ver também: [05_udp_multicast.md](05_udp_multicast.md), [09_sistema_de_plugins.md](09_sistema_de_plugins.md).
### Teste manual T-LOGIN-01 (login centralizado)
1. Compilar e executar: `./scripts/pitiupi.sh run --all` na raiz do repositório.
2. O `LoginDialog` deve aparecer **no centro** do monitor principal (WSL/KDE), não no canto superior esquerdo.
3. Após entrar, a `MainShellFrame` (1024×768) também deve abrir centralizada.
A posição exata depende do gerenciador de janelas; o núcleo recentra após exibir a janela.
### Atualizar plugin Chat (Onda E)
O JAR do módulo é `plugins/chat/dist/pitiupi.chat.jar` (catálogo `plugins.registry.json`). Não use `dist/chat.jar`.
```bash
cd plugins/chat && ant clean jar
./scripts/pitiupi.sh link --all
```
Isso recria o symlink `~/pitiupi/plugins/chat.jar` → o JAR buildado. O comando `link` lista o destino real de cada `.jar`.
# Interface gráfica
A interface gráfica da aplicação é baseada em Swing e é centralizada na
classe `MainWindow`.
A interface gráfica é Swing, organizada em shell (`pitiupi.presentation.shell`):
Durante a inicialização, cada plugin recebe `ConnectionManager` e `GuiManager`
no método `createPlugin()`.
| Região | Componente |
|--------|------------|
| Centro | `TabWorkspacePanel` — aba **Plugins** (galeria; abre plugin sob demanda), abas de plugin **fecháveis** (×), aba **Configurações** |
| Leste | `NetworkUsersPanel` — “Usuários nesta rede”, badges de convite, peer actions |
| Sul | Barra de status (porta e nome local) |
| Menu | `ShellMenuBar` — apenas **Ajuda → Sobre** (sair pelo X da janela) |
A extensão usual é registrar menus por categoria (`MenuCategory`). Ver
[12_menus_unicast_multicast.md](12_menus_unicast_multicast.md).
Plugins expõem `getName()` (técnico), `getIdentifier()` (SPI) e `getDisplayName()` (texto na galeria e nas abas).
A aba **Configurações** mostra em cima **Sua conexão** (nome, porta, IP local, ID da instância, contagem de peers; campos selecionáveis para copiar manualmente) e embaixo a tabela de JARs instalados (altura reduzida).
Durante a inicialização, cada plugin recebe `ConnectionManager` e `PluginWorkspace`
no método `createPlugin()`. Menus multicast na barra superior não são mais usados;
ações para todos os peers migram para **peer actions** (contexto na lista lateral).
Ao iniciar, só as abas **Plugins** e **Configurações** ficam visíveis. Cada plugin registra um painel com `bindShellWorkspaceContent`; a aba abre ao clicar na galeria ou via peer action. Referência: **Conversa na rede** (`ChatPanel`) e **Compartilhar tela** (`ScreenCastPanel`).
O **chat** usa campo de mensagem compacto (~2 linhas, quebra de linha; barra de rolagem vertical só se o texto não couber). O histórico ocupa a maior parte da altura da aba (divisor proporcional ao abrir a janela).
Canal TCP privado do chat fica no mesmo `ChatPanel` (histórico separado por conversa: sala geral, peer por IP, privado com toggle). A lista lateral usa `PeerActivityInbox` (bolinha vermelha e contagem; no menu, ● na ação correspondente).
Ao fechar a aba superior de **Compartilhar tela** (×), o núcleo chama `Plugin.onShellTabClosed()` — o plugin interrompe captura e envio (toggle desligado). Reabrir pela galeria deve mostrar compartilhamento inativo.
**TCP (transporte)** envia uma mensagem serializada pelo núcleo (`sendTcp`), na porta do login. **TCP privado** (toggle) abre conexão dedicada ao plugin entre dois peers; com o toggle ativo, o combo de transporte fica desabilitado e o envio usa esse canal. Na UI só se informa o **IP** do peer: UDP/TCP unicast do Pitiupi usa sempre a **mesma porta** que você escolheu no login (convenção de sala: todos com a mesma porta, ex. 5000 e 5001 em testes no mesmo PC).
Textos da UI (destino, combo de forma de envio e linha **“Como esta mensagem vai”**) usam linguagem simples e o termo técnico entre parênteses (multicast UDP, UDP unicast, TCP). Na sala, o transporte não é só multicast: o combo pode escolher cópias unicast ou TCP para cada pessoa, conforme o plugin.
Ver [12_menus_unicast_multicast.md](12_menus_unicast_multicast.md).
## Diagrama de comunicação
```
+------------------+
| MainWindow |
| MainShellFrame |
+--------+---------+
|
+--------------------+--------------------+
+24 -5
View File
@@ -10,14 +10,17 @@ Métodos obrigatórios:
| Método | Função |
| --- | --- |
| `getName()`, `getAuthor()`, `getVersion()`, `getIdentifier()` | Identificação (o `identifier` distingue plugins em conexões TCP dedicadas) |
| `createPlugin(ConnectionManager, GuiManager)` | Inicialização: menus, janelas, uso da rede |
| `getDisplayName()` | Texto na galeria e título de aba (padrão: `getName()`) |
| `createPlugin(ConnectionManager, PluginWorkspace)` | Inicialização na shell: painel na aba, peer actions, rede |
| `createPlugin(ConnectionManager, GuiManager)` | Legado — adapter na carga; não usar em plugins novos |
| `getMessages()` | Lista de classes `Message` que o plugin desserializa |
| `receiveMessage(Message)` | Processamento após o `PluginManager` identificar o tipo |
Métodos opcionais (TCP avançado):
Métodos opcionais (ciclo de vida e TCP avançado):
| Método | Função |
| --- | --- |
| `onShellTabClosed()` | Usuário fechou a aba do plugin no topo; liberar timers, captura, envio contínuo |
| `receiveAllTypes()` | Receber todas as mensagens públicas |
| `receivePrivateConnection(TcpConnection)` | Aceitar conexão TCP direcionada ao plugin |
| `receiveBytes(TcpConnection, byte[])` | Ler payload bruto na conexão privada |
@@ -50,11 +53,27 @@ e colocados no diretório plugins da aplicação.
### Carregamento de plugins
O `PluginManager.loadPlugins()` (chamado pela `MainWindow`):
O `PluginManager.loadPlugins()` (chamado pela `MainShellFrame`):
1. Lista arquivos `.jar` em `pitiupi/plugins/` (diretório de trabalho = pasta do núcleo).
1. Lista arquivos `.jar` em `PITIUPI_HOME/plugins` (ver [00_quickstart.md](00_quickstart.md); symlinks de dev via `./scripts/pitiupi.sh link`).
2. Para cada JAR, cria um `URLClassLoader` e usa `ServiceLoader.load(Plugin.class, classLoader)`.
3. Chama `createPlugin(connectionManager, guiManager)` em cada instância.
3. Chama `createPlugin(connectionManager, pluginWorkspace)` em cada instância (`ShellPluginWorkspace`).
4. Na recepção UDP/TCP, tenta desserializar com `PluginObjectInputStream` e despacha para plugins que declararam o tipo em `getMessages()`.
Ao fechar uma aba dinâmica de plugin, `TabWorkspacePanel` notifica `PluginManager.notifyShellTabClosed(pluginId)` **antes** de remover o conteúdo, para o plugin encerrar trabalho em segundo plano.
Implementação: `pitiupi/src/pitiupi/control/plugins/PluginManager.java`.
## Shell e abas (PluginWorkspace)
Na `MainShellFrame`, `createPlugin(ConnectionManager, PluginWorkspace)` registra o painel com `bindShellWorkspaceContent(JPanel)` **sem** abrir aba no boot. A galeria **Plugins** e as peer actions abrem a aba sob demanda.
## Peer actions na lista lateral
`PeerActionBroker.getAllActionsFor(target)` agrupa ações de **todos** os plugins (submenu por `getDisplayName()`). Destinos: `SINGLE_PEER` (um usuário) ou `ALL_PEERS` (linha **Todos**).
## Envio didático (núcleo)
`PluginRoutingPanel` (núcleo) padroniza destino, forma de envio e dica embaixo para chat, screencast e futuros plugins.
`MessageDispatcher` envia com `TransportMode` e `SendTarget`. `PluginTransportCatalog` define quais transportes cada plugin mostra na UI (sala geral vs um IP). Os combos usam textos didáticos (`describeForUi`) com o termo técnico entre parênteses (multicast UDP, UDP unicast, TCP). Na sala geral o campo IP fica desativado; um resumo **“Como esta mensagem vai”** (chat) deixa explícito o transporte escolhido — na sala pode ser multicast ou cópias unicast/TCP para cada pessoa, conforme o plugin. Para um peer use o IP manualmente ou a peer action na lista lateral.
+3 -3
View File
@@ -1,10 +1,10 @@
# Tutorial — criar um plugin
Este guia complementa [09_sistema_de_plugins.md](09_sistema_de_plugins.md). Use `pitiupi.chat` como modelo.
Este guia complementa [09_sistema_de_plugins.md](09_sistema_de_plugins.md). Use `plugins/chat` como modelo.
## 1. Novo projeto
- Copie a pasta `pitiupi.chat` para `pitiupi.meuPlugin` (nome à escolha).
- Copie a pasta `plugins/chat` para `plugins/meuPlugin` (nome à escolha).
- Ajuste `project.properties` / `build.xml` com o nome do JAR de saída.
- Mantenha `javac.classpath` apontando para `../pitiupi/dist/pitiupi.jar`.
@@ -98,7 +98,7 @@ Duas instâncias, mesma rede, portas distintas se no mesmo host. Ver [00_quickst
## TCP dedicado (avançado)
Se o plugin precisa de canal TCP com handshake por `getIdentifier()`, estude `pitiupi.mpChat` e os métodos opcionais em `Plugin` (`receivePrivateConnection`, `receiveBytes`). Ver [11_plugins_referencia.md](11_plugins_referencia.md).
Se o plugin precisa de canal TCP com handshake por `getIdentifier()`, estude `plugins/chat` (`receivePrivateConnection`, `receiveBytes`). Use `MessageDispatcher` para envios didáticos. Ver [11_plugins_referencia.md](11_plugins_referencia.md).
## Erros frequentes
+20 -49
View File
@@ -1,60 +1,31 @@
# Plugins de referência
Três exemplos no repositório para comparar estilos de integração. Todos devem ser empacotados em `pitiupi/plugins/`.
Dois módulos buildáveis no catálogo; o chat unifica o antigo mpChat (multicast, unicast, TCP e canal TCP privado).
## Comparativo
| | pitiupi.chat | pitiupi.screenCast | pitiupi.mpChat |
| --- | --- | --- | --- |
| Build | Ant | Ant | Make |
| id (`plugins.registry.json`) | `chat` | `screencast` | `mpchat` |
| Classe | `ChatPlugin` | `ScreenCastPlugin` | `MultiProtocolChat` |
| Categoria menu | `Social` | `Streaming` | `Social` (multicast + unicast) |
| Mensagem | `ChatMessage` | `ScreenCastMessage` | `MpChatMessage` |
| Transporte principal | UDP (via `ConnectionManager`) | UDP / payloads maiores | UDP público + **TCP privado** |
| TCP avançado | Não | Depende da versão do módulo | `receivePrivateConnection`, `receiveBytes` |
| | plugins/chat | plugins/screencast |
| --- | --- | --- |
| Build | Ant | Ant |
| id | `chat` | `screencast` |
| Classe | `ChatPlugin` | `ScreenCastPlugin` |
| Mensagem | `ChatMessage`, `LegacyMpChatMessage` | `ScreenCastMessage` |
| UI | `ChatPanel` (destino + transporte) | `ScreenCastPanel` (destino + transporte) |
| TCP privado | Toggle no `ChatPanel` + hooks `receiveBytes` (histórico `private:<IP>`) | — |
## pitiupi.chat
## plugins/chat
- **Referência canônica** alinhada ao contrato atual `Plugin` + `ConnectionManager` / `GuiManager`.
- `createPlugin` registra `ChatMenu` em `MenuCategory.Social`.
- `getMessages()` retorna `ChatMessage.class`; `receiveMessage` atualiza `ChatWindow`.
- Sala geral ou um peer (lista + IP).
- Transportes: multicast UDP, unicast UDP, TCP via `MessageDispatcher`.
- Peer actions: abrir sala geral; conversar com peer; canal TCP privado.
- Caminho: `plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java`.
Caminhos: `pitiupi.chat/src/pitiupi/chat/plugin/ChatPlugin.java`.
## plugins/screencast
## pitiupi.screenCast
- Compartilhamento contínuo com destino geral (multicast recomendado) ou um peer (unicast/TCP).
- Peer actions: compartilhar na rede; compartilhar com este peer.
- Caminho: `plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java`.
- Demonstra compartilhamento de tela / imagens (`ScreenCastWindow`, `ScreenCastMessage`).
- Útil para discutir **tamanho de datagramas**, compressão (`ImageTransformation`) e limites UDP.
## Exercício Ping
Caminhos: `pitiupi.screenCast/src/pitiupi/screenCast/`. Alinhado ao contrato `Plugin` + `ConnectionManager` / `GuiManager` (como `ChatPlugin`).
## pitiupi.mpChat
- Chat **público** (multicast / unicast UDP) e **privado** (TCP).
- `createPlugin(ConnectionManager, GuiManager)` como nos demais plugins; usa `ConnectionManager.connectPluginTcp` para abrir canal privado.
- Sobrescreve hooks TCP do `Plugin` (`receivePrivateConnection`, `receiveBytes`, `onSocketClosed`).
Caminhos: `pitiupi.mpChat/src/plugin/MultiProtocolChat.java`.
## Fluxo UDP (todos os plugins)
```mermaid
sequenceDiagram
participant App as MainWindow
participant UDP as SocketUDP
participant HB as HeartbeatManager
participant PM as PluginManager
participant Peer as OutroPeer
App->>UDP: join multicast
UDP->>HB: HeartbeatMessage
Peer->>UDP: Message plugin
UDP->>PM: handleMessage bytes
PM->>PM: deserialize + dispatch
```
Heartbeat não passa pelos plugins — ver [08_heartbeat_e_presenca.md](08_heartbeat_e_presenca.md).
## Próximo passo
Criar plugin próprio: [10_tutorial_plugin.md](10_tutorial_plugin.md).
Material em [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md) e pasta `plugins/ping/` (sem JAR no repositório).
@@ -1,35 +1,34 @@
# Menus — unicast e multicast
# Menus, peer actions e transportes
## MenuCategory
## Interface atual (shell v1)
Enum em `pitiupi.models.Menus.MenuCategory`:
Plugins de referência **não** dependem da barra de menus superior para o fluxo principal:
| Valor | Uso sugerido |
| --- | --- |
| `Jogos` | Plugins de jogos entre peers |
| `Social` | Chat, presença, mensagens sociais |
| `Streaming` | Screen cast, mídia |
- **Galeria** na aba **Plugins** abre cada extensão em aba própria (fechável com ×).
- **Peer actions** na lista **Usuários nesta rede** (clique em um peer ou em **Todos**) agrupam ações de todos os plugins instalados.
- Dentro da aba do plugin, **`PluginRoutingPanel`** padroniza destino (sala geral vs IP), forma de envio (multicast UDP, UDP unicast, TCP) e texto de ajuda.
## Registrar menu (padrão atual)
Ver [04_inicializacao_e_gui.md](04_inicializacao_e_gui.md) e [09_sistema_de_plugins.md](09_sistema_de_plugins.md).
Via `GuiManager.createMenu(MenuCategory category, Menu menu)` — ver `ChatPlugin`:
## MenuCategory (legado)
```java
guiManager.createMenu(MenuCategory.Social, new ChatMenu(chatWindow));
```
Enum em `pitiupi.models.Menus.MenuCategory` (`Jogos`, `Social`, `Streaming`) ainda aparece no **catálogo** (`menuCategory` em `plugins.registry.json`) e em plugins antigos que chamam `GuiManager.createMenu(...)`.
A hierarquia de menus unicast/multicast do núcleo (`UnicastMenu`, `MulticastMenu`, etc.) permite ações por peer ou broadcast; plugins de referência encapsulam isso em classes `*Menu` próprias.
Novos plugins devem preferir `PluginWorkspace` + peer actions. O menu da shell expõe apenas **Ajuda → Sobre**.
## Unicast vs multicast na aplicação
| Modo | API típica | Semântica |
| --- | --- | --- |
| Multicast | `ConnectionManager` → `SocketUDP.sendMulticast` | Todos no grupo na porta configurada |
| Multicast | `MessageDispatcher` / `SocketUDP.sendMulticast` | Todos no grupo na porta configurada |
| Unicast UDP | envio para IP de um peer | Um destinatário, sem estabelecer TCP |
| TCP | `ConnectionManager` / `SocketTCP` | Canal persistente, framing 4+N bytes |
| TCP | `ConnectionManager` / `SocketTCP` | Canal com framing 4+N bytes |
Detalhes de rede: [05_udp_multicast.md](05_udp_multicast.md), [06_tcp_conexoes.md](06_tcp_conexoes.md).
## mpChat
## Referência de código
`MulticastMpChatMenu` e `UnicastMpChatMenu` mostram como expor as duas modalidades no mesmo plugin — [11_plugins_referencia.md](11_plugins_referencia.md).
- Chat e screencast: peer actions em `ChatPlugin` e `ScreenCastPlugin`.
- Envio unificado: `pitiupi.application.messaging.MessageDispatcher` e `TransportMode`.
O antigo plugin **mpChat** foi unificado no módulo `plugins/chat` (mensagens `LegacyMpChatMessage` para compatibilidade TCP privado). Ver [11_plugins_referencia.md](11_plugins_referencia.md) e decisão D19 no [decision-register.md](../decision-register.md).
+9 -6
View File
@@ -1,5 +1,7 @@
# Guia para desenvolvedor e agente
Esta documentação descreve **apenas** a ferramenta Pitiupi neste repositório: build, rede, plugins e convenções de código. Quem clona o projeto não precisa de pastas ou ferramentas de meta-desenvolvimento externas; mantenha referências técnicas e tutoriais limitados ao que está em `docs/` e no código do próprio repo.
## Leitura mínima antes de mudar código
1. [00_quickstart.md](00_quickstart.md) — build e execução
@@ -19,16 +21,17 @@
## Gate de build
Compilar núcleo e os três plugins de referência:
Compilar núcleo e os dois plugins do catálogo:
```bash
cd pitiupi && ant jar
cd ../pitiupi.chat && ant jar
cd ../pitiupi.screenCast && ant jar
cd ../pitiupi.mpChat && make
cd pitiupi && ant jar && ant test
cd plugins/chat && ant jar
cd plugins/screencast && ant jar
```
Requer JDK, Ant e Make no PATH. Teste manual em rede: [15_testes_e_gate.md](15_testes_e_gate.md).
Ou na raiz do clone: `./scripts/pitiupi.sh build --all`.
Requer JDK e Ant no PATH. Teste manual em rede: [15_testes_e_gate.md](15_testes_e_gate.md).
## Convenções
@@ -4,12 +4,13 @@
| Comportamento | Pacote / classe |
| --- | --- |
| Login, porta, nome | `pitiupi.GUI.Login` |
| Orquestração GUI + plugins | `pitiupi.GUI.MainWindow` |
| Barra de menus base | `pitiupi.GUI.MenuBar` |
| Usuários online | `pitiupi.GUI.OnlineUsersPanel`, `HeartbeatManager` |
| Login, porta, nome | `pitiupi.presentation.shell.LoginDialog` |
| Shell principal (abas, galeria) | `pitiupi.presentation.shell.MainShellFrame`, `TabWorkspacePanel` |
| Menu Ajuda | `pitiupi.presentation.shell.ShellMenuBar` |
| Usuários online, peer actions | `pitiupi.presentation.shell.NetworkUsersPanel`, `PeerActionBroker` |
| Facade rede para plugins | `pitiupi.control.ConnectionManager` |
| Registro de menus | `pitiupi.control.GuiManager` |
| Workspace na aba (SPI) | `pitiupi.api.plugin.PluginWorkspace`, `ShellPluginWorkspace` |
| Menus legados (adapter) | `pitiupi.control.GuiManager` — deprecado; preferir peer actions |
| UDP | `pitiupi.net.udp.SocketUDP` |
| TCP servidor e mapa de conexões | `pitiupi.net.tcp.SocketTCP`, `TcpConnectionManager` |
| Conexão TCP por peer | `pitiupi.net.tcp.TcpConnection` |
@@ -24,7 +25,7 @@
```
pitiupi (núcleo)
↑ classpath
pitiupi.chat | pitiupi.screenCast | pitiupi.mpChat
plugins/chat | plugins/screencast
```
Plugins **não** devem ser dependência do núcleo.
+35 -17
View File
@@ -1,15 +1,18 @@
# Testes e gate
## Estado atual
## Gate automatizado (núcleo)
Não há suíte JUnit integrada ao repositório. A validação principal é:
O módulo `pitiupi/` inclui testes JUnit:
1. **Build** de todos os módulos.
2. **Teste manual** com duas instâncias na rede.
```bash
cd pitiupi && ant test
```
## Gate de build (recomendado)
Falha se `java`, `javac` ou Ant estiverem ausentes.
Na raiz do clone (todos os módulos do catálogo):
## Gate de build (produto)
Na raiz do clone:
```bash
./scripts/pitiupi.sh build --all
@@ -19,26 +22,41 @@ Equivalente manual:
```bash
cd pitiupi && ant jar
cd ../pitiupi.chat && ant jar
cd ../pitiupi.screenCast && ant jar
cd ../pitiupi.mpChat && make
cd plugins/chat && ant jar
cd plugins/screencast && ant jar
```
Falha se `java`, **`javac`**, Ant, Make ou `python3` estiverem ausentes (instale `openjdk-*-jdk`, não só JRE).
Depois, para runtime local:
## Checklist manual (rede)
```bash
./scripts/pitiupi.sh link --all
```
- [ ] Duas instâncias com portas distintas (mesmo host) ou dois hosts na mesma LAN
- [ ] Nomes visíveis no painel de usuários em até ~15 s
- [ ] Plugin de referência troca mensagens entre os dois
- [ ] Encerrar uma instância remove o peer da lista após timeout de heartbeat
## Checklist manual — uma máquina (5000 / 5001)
- [ ] Duas instâncias com portas distintas; nomes diferentes
- [ ] Peers visíveis na lista em até ~15 s
- [ ] Chat: sala geral e conversa por IP; tags de transporte coloridas no histórico
- [ ] ScreenCast: abrir aba, destino sala ou peer; fechar aba com × interrompe compartilhamento
- [ ] Compose do chat sem barra de rolagem vazio em janela ~1024×768
- [ ] Encerrar uma instância remove o peer após timeout de heartbeat
## Checklist manual — dois PCs (mesma Wi‑Fi)
Mesma **porta** em ambos (ex. 5000), **mesmos JARs** em `~/pitiupi/plugins` em cada máquina:
- [ ] Presença e linha **Todos**
- [ ] Troca de mensagens no chat (multicast e, se possível, unicast/TCP)
- [ ] Compartilhamento de tela entre os dois (ambiente com captura de tela funcional — no WSL a captura nativa pode falhar)
- [ ] Plugin Ping do aluno (tutorial [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md)) medindo RTT — recomendado antes da entrega final
## Checklist plugin de aluno
- [ ] JAR em `plugins/` + SPI correto
- [ ] JAR em `PITIUPI_HOME/plugins` + SPI correto
- [ ] README do módulo com build e formato das mensagens
- [ ] UI na aba (`PluginWorkspace`), sem `JFrame` principal
- [ ] Demonstração mínima professor ↔ aluno ou aluno ↔ aluno
## Melhorias futuras
Ver [engineering_backlog.md](engineering_backlog.md) (testes automatizados, CI).
Ver [engineering_backlog.md](engineering_backlog.md) (CI contínuo, mais testes de rede sem sockets reais).
+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 |
| 1 | [01_visao_disciplina_e_tp.md](01_visao_disciplina_e_tp.md) | Uso na disciplina e trabalho prático |
| 2 | [02_modulos_do_repositorio.md](02_modulos_do_repositorio.md) | Núcleo e três plugins de referência |
| 3 | [03_build_ant_netbeans.md](03_build_ant_netbeans.md) | Ant, NetBeans, Makefile mpChat |
| 3 | [03_build_ant_netbeans.md](03_build_ant_netbeans.md) | Ant e NetBeans (núcleo + plugins) |
| 4 | [04_inicializacao_e_gui.md](04_inicializacao_e_gui.md) | Boot, MainWindow, diagrama |
| 5 | [05_udp_multicast.md](05_udp_multicast.md) | SocketUDP |
| 6 | [06_tcp_conexoes.md](06_tcp_conexoes.md) | SocketTCP, TcpConnection, framing |
@@ -17,7 +17,8 @@ Referência para **código Java**, **rede P2P** e **plugins**. Índice geral: [d
| 8 | [08_heartbeat_e_presenca.md](08_heartbeat_e_presenca.md) | HeartbeatManager |
| 9 | [09_sistema_de_plugins.md](09_sistema_de_plugins.md) | Plugin, PluginManager, SPI |
| 10 | [10_tutorial_plugin.md](10_tutorial_plugin.md) | Passo a passo novo plugin |
| 11 | [11_plugins_referencia.md](11_plugins_referencia.md) | chat, screenCast, mpChat |
| 11 | [11_plugins_referencia.md](11_plugins_referencia.md) | chat e screencast |
| 17 | [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md) | Exercício Ping (aula) |
| 12 | [12_menus_unicast_multicast.md](12_menus_unicast_multicast.md) | MenuCategory e ações |
| 13 | [13_guia_dev_e_agente.md](13_guia_dev_e_agente.md) | Leitura para dev/agente |
| 14 | [14_arquitetura_e_manutencao.md](14_arquitetura_e_manutencao.md) | Onde editar cada comportamento |
+3 -3
View File
@@ -2,10 +2,10 @@
| Item | Prioridade | Notas |
| --- | --- | --- |
| Completar tutorial legado em `pitiupi/docs/PluginTutorial.md` | Média | Substituído por [10_tutorial_plugin.md](10_tutorial_plugin.md) |
| Testes JUnit para desserialização e framing TCP | Média | Sem rede real |
| CI (GitHub Actions) com `ant jar` + `make` | Baixa | Requer imagem com Ant |
| CI (GitHub Actions) com `ant jar` + `ant test` | Baixa | Requer imagem com Ant/JDK |
| Captura de tela nativa no WSL | Baixa | Limitação de ambiente; validar em Windows/Linux com display |
Itens concluídos recentemente: alinhamento de `pitiupi.mpChat` a `ConnectionManager` / `GuiManager`; documentação de JDK mínimo no [README](../../README.md) e [00_quickstart.md](00_quickstart.md).
Itens concluídos recentemente: shell com abas e peer actions (D16–D18); chat unificado (D19); docs alinhadas à shell v1 (Onda G); suíte JUnit no núcleo (`ant test`); tutorial Ping em [17_tutorial_ping_aula.md](17_tutorial_ping_aula.md).
Ao fechar um item, atualizar esta tabela e o doc técnico relacionado.
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.
-->
<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>
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/`:
```bash
java -jar dist/pitiupi.jar
```
## Pasta única (sem subpastas por categoria)
Os JARs ficam lado a lado em `plugins/`. A divisão **chat / jogo / streaming** aparece no menu **Plugin → Social | Jogos | Streaming**, definida por cada plugin com `MenuCategory` no código.
O núcleo carrega **todos os arquivos `.jar`** de `PITIUPI_HOME/plugins` ao iniciar (padrão `~/pitiupi/plugins`). Variável de ambiente opcional: `PITIUPI_HOME`.
## Instalação automática (recomendado)
Na raiz do repositório, o catálogo [`plugins.registry.json`](../../plugins.registry.json) lista módulos buildáveis:
```bash
./scripts/pitiupi.sh build --all
./scripts/pitiupi.sh link --all
./scripts/pitiupi.sh build --plugins chat
```
Symlinks gerados: `plugins/{id}.jar` → `../../{módulo}/{jar}`.
Symlinks: `{PITIUPI_HOME}/plugins/{id}.jar` → JAR buildado em `plugins/{módulo}/dist/` no clone.
## Manual
```bash
cd pitiupi/plugins
ln -sf ../../pitiupi.chat/dist/pitiupi.chat.jar chat.jar
ln -sf /caminho/ao/clone/plugins/chat/dist/pitiupi.chat.jar ~/pitiupi/plugins/chat.jar
```
Não commite `.jar` — apenas este README e symlinks locais.
Não commite `.jar` binários no Git — apenas este README.
Novo módulo: registrar no catálogo e ver [docs/documentation/10_tutorial_plugin.md](../../docs/documentation/10_tutorial_plugin.md).
+41 -57
View File
@@ -8,97 +8,76 @@ package pitiupi.GUI;
import pitiupi.control.ConnectionManager;
import pitiupi.control.GuiManager;
import pitiupi.control.plugins.PluginManager;
import pitiupi.presentation.shell.NetworkUsersPanel;
import pitiupi.presentation.shell.ShellHost;
import pitiupi.presentation.shell.ShellMenuBar;
import javax.swing.*;
import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
/**
* Janela principal da aplicação.
*
* <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
* @deprecated Substituído por {@link pitiupi.presentation.shell.MainShellFrame}.
*/
public class MainWindow extends JFrame {
@Deprecated
public class MainWindow extends JFrame implements ShellHost {
private MenuBar menuBar;
private JLabel status;
OnlineUsersPanel onlineUsersPanel;
private final NetworkUsersPanel networkUsersPanel;
private final ConnectionManager connectionManager;
private final PluginManager pluginManager;
private final GuiManager guiManager;
/**
* Cria a janela principal da aplicação.
*
* @param userName nome utilizado para identificação na rede.
* @param port porta utilizada na comunicação multicast.
*/
public MainWindow(String userName, int port) {
initMenu();
pluginManager = new PluginManager();
connectionManager = new ConnectionManager(this, userName, pluginManager, port);
connectionManager = new ConnectionManager(userName, pluginManager, port);
ShellMenuBar menuBar = new ShellMenuBar();
setJMenuBar(menuBar);
setDefaultCloseOperation(javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE);
setTitle("Pitiupi");
networkUsersPanel = new NetworkUsersPanel(connectionManager);
getContentPane().add(new JScrollPane(new JTabbedPane()), BorderLayout.CENTER);
getContentPane().add(networkUsersPanel, BorderLayout.LINE_END);
initUserPanel();
initStatusPanel();
guiManager = new GuiManager(this, menuBar, onlineUsersPanel);
guiManager = new GuiManager(this, menuBar, networkUsersPanel);
loadPlugins();
this.pack();
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
exit();
}
});
this.setSize(new java.awt.Dimension(1024, 768));
}
private void initMenu() {
menuBar = new MenuBar(this);
this.setJMenuBar(menuBar);
this.setDefaultCloseOperation(javax.swing.WindowConstants.EXIT_ON_CLOSE);
this.setTitle("Pitiupi");
javax.swing.JScrollPane jScrollPane1 = new javax.swing.JScrollPane();
javax.swing.JTabbedPane tabbedPane = new javax.swing.JTabbedPane();
jScrollPane1.setViewportView(tabbedPane);
this.getContentPane().add(jScrollPane1, java.awt.BorderLayout.CENTER);
}
private void initUserPanel() {
onlineUsersPanel = new OnlineUsersPanel();
this.connectionManager.addPeerListener(onlineUsersPanel);
this.getContentPane().add(onlineUsersPanel, java.awt.BorderLayout.LINE_END);
pack();
setSize(new Dimension(1024, 768));
}
private void initStatusPanel() {
JPanel statusPanel = new JPanel();
status = new JLabel();
statusPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
updateStatus();
statusPanel.add(status);
this.getContentPane().add(statusPanel, BorderLayout.PAGE_END);
getContentPane().add(statusPanel, BorderLayout.PAGE_END);
}
@Override
public void exit() {
this.setVisible(false);
this.dispose();
connectionManager.shutdownGracefully();
setVisible(false);
dispose();
System.exit(0);
}
@Override
public ConnectionManager getConnectionManager() {
return connectionManager;
}
@@ -107,13 +86,18 @@ public class MainWindow extends JFrame {
pluginManager.loadPlugins(connectionManager, guiManager);
}
@Override
public void setUserName(String userName) {
connectionManager.setUserName(userName);
updateStatus();
}
private void updateStatus() {
this.status.setText("Connected on port " + connectionManager.getPort() + " name " + connectionManager.getUserName());
@Override
public void setStatusMessage(String message) {
status.setText(message);
}
private void updateStatus() {
status.setText("Connected on port " + connectionManager.getPort() + " name " + connectionManager.getUserName());
}
}
@@ -33,7 +33,7 @@ public class OnlineUsersPanel extends JPanel implements PeerListener {
initMenu();
setLayout(new BorderLayout());
setBorder(BorderFactory.createTitledBorder("Online"));
setBorder(BorderFactory.createTitledBorder("Usuários nesta rede"));
setPreferredSize(new Dimension(160, 0));
listModel = new DefaultListModel<>();
@@ -64,6 +64,10 @@ public class OnlineUsersPanel extends JPanel implements PeerListener {
}
userList.setSelectedIndex(index);
User selected = listModel.getElementAt(index);
if (selected != null && selected.isBroadcastTarget()) {
return;
}
menu.show(userList, e.getX(), e.getY());
}
});
@@ -73,7 +77,7 @@ public class OnlineUsersPanel extends JPanel implements PeerListener {
menuItem.addActionListener(e -> {
User user = userList.getSelectedValue();
if (user != null) {
if (user != null && !user.isBroadcastTarget() && user.getAddress() != null) {
action.accept(user);
}
});
@@ -92,6 +96,7 @@ public class OnlineUsersPanel extends JPanel implements PeerListener {
public void onPeersChanged(List<User> peers) {
SwingUtilities.invokeLater(() -> {
listModel.clear();
listModel.addElement(User.broadcastAll());
for (User peer : peers) {
listModel.addElement(peer);
}
+17 -6
View File
@@ -5,7 +5,9 @@
*/
package pitiupi;
import java.net.UnknownHostException;
import pitiupi.platform.LegacyPluginMigration;
import pitiupi.platform.PitiupiHome;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -44,12 +46,21 @@ public class Main {
java.awt.EventQueue.invokeLater(new Runnable() {
@Override
public void run() {
pitiupi.GUI.Login login = new pitiupi.GUI.Login();
login.setVisible(true);
try {
PitiupiHome.get().ensureLayout();
LegacyPluginMigration.maybeMigrateFromLegacyCwd();
} catch (Exception e) {
Logger.getLogger(Main.class.getName()).log(Level.SEVERE, "Falha ao preparar PITIUPI_HOME", e);
}
pitiupi.GUI.MainWindow main;
main = new pitiupi.GUI.MainWindow(login.getName(), login.getPort());
main.setVisible(true);
pitiupi.presentation.shell.LoginDialog login = new pitiupi.presentation.shell.LoginDialog();
if (!login.showLogin()) {
return;
}
pitiupi.presentation.shell.MainShellFrame shell =
new pitiupi.presentation.shell.MainShellFrame(login.getUserName(), login.getPort());
shell.showOnScreen();
}
});
}
@@ -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;
import pitiupi.GUI.MainWindow;
import pitiupi.application.activity.PeerActivityInbox;
import pitiupi.application.invites.InviteInboxService;
import pitiupi.application.peeractions.PeerActionBroker;
import pitiupi.application.presence.PresenceService;
import pitiupi.models.Messages.SessionInviteMessage;
import pitiupi.control.plugins.PluginManager;
import pitiupi.models.Messages.GoodbyeMessage;
import pitiupi.models.Messages.HeartbeatMessage;
import pitiupi.models.Messages.Message;
import pitiupi.net.tcp.SocketTCP;
@@ -9,6 +14,7 @@ import pitiupi.net.tcp.TcpConnection;
import pitiupi.net.udp.SocketUDP;
import java.net.InetAddress;
import java.util.function.Consumer;
public class ConnectionManager {
private final String pitiupiIdentifier;
@@ -16,13 +22,20 @@ public class ConnectionManager {
private final PluginManager pluginManager;
private final HeartbeatManager heartbeatManager;
private final PresenceService presenceService;
private final PeerActionBroker peerActionBroker;
private final InviteInboxService inviteInboxService;
private final PeerActivityInbox peerActivityInbox;
private SocketTCP socketTCP;
private SocketUDP socketUDP;
private String username;
private int port;
private volatile boolean shutdownComplete;
private Consumer<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.username = username;
@@ -33,7 +46,64 @@ public class ConnectionManager {
reconnect();
heartbeatManager = new HeartbeatManager(this);
peerActionBroker = new PeerActionBroker();
inviteInboxService = new InviteInboxService(heartbeatManager);
peerActivityInbox = new PeerActivityInbox();
presenceService = new PresenceService(heartbeatManager, inviteInboxService, peerActivityInbox);
inviteInboxService.setOnNotificationsChanged(heartbeatManager::notifyPeerListeners);
peerActivityInbox.setOnChanged(heartbeatManager::notifyPeerListeners);
heartbeatManager.start();
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdownGracefully, "pitiupi-goodbye"));
}
public PresenceService getPresenceService() {
return presenceService;
}
public PeerActionBroker getPeerActionBroker() {
return peerActionBroker;
}
public InviteInboxService getInviteInboxService() {
return inviteInboxService;
}
public PeerActivityInbox getPeerActivityInbox() {
return peerActivityInbox;
}
public void setInviteAcceptedUiListener(Consumer<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) {
@@ -72,7 +142,11 @@ public class ConnectionManager {
}
public void receiveHeartbeat(HeartbeatMessage message, InetAddress destinationAddress) {
heartbeatManager.receiveHeartbeat(message, destinationAddress);
presenceService.onHeartbeat(message, destinationAddress);
}
public void receiveGoodbye(GoodbyeMessage message) {
presenceService.onGoodbye(message);
}
public void setPort(int port) {
+10 -25
View File
@@ -1,8 +1,7 @@
package pitiupi.control;
import pitiupi.GUI.MainWindow;
import pitiupi.GUI.MenuBar;
import pitiupi.GUI.OnlineUsersPanel;
import pitiupi.presentation.shell.NetworkUsersPanel;
import pitiupi.presentation.shell.ShellHost;
import pitiupi.models.Menus.*;
import pitiupi.models.Menus.MulticastMenus.MulticastActionMenu;
import pitiupi.models.Menus.MulticastMenus.MulticastMenu;
@@ -16,39 +15,25 @@ import java.util.logging.Level;
import java.util.logging.Logger;
public class GuiManager {
private MainWindow mainWindow;
private MenuBar menuBar;
private OnlineUsersPanel onlineUsersPanel;
private final ShellHost shellHost;
private final NetworkUsersPanel networkUsersPanel;
private Logger logger;
public GuiManager(MainWindow mainWindow, MenuBar menuBar, OnlineUsersPanel onlineUsersPanel) {
this.mainWindow = mainWindow;
this.menuBar = menuBar;
this.onlineUsersPanel = onlineUsersPanel;
public GuiManager(ShellHost shellHost, javax.swing.JMenuBar menuBar, NetworkUsersPanel networkUsersPanel) {
this.shellHost = shellHost;
this.networkUsersPanel = networkUsersPanel;
this.logger = Logger.getLogger(GuiManager.class.getName());
}
public void createMenu(MenuCategory category, Menu menu) {
System.out.println(
menu.getClass().getSuperclass().getSuperclass()
);
System.out.println(
MulticastMenu.class
);
System.out.println(
menu.getClass().getSuperclass().getSuperclass()
== MulticastMenu.class
);
try {
JMenuItem newMenu = buildMenu(menu);
if (menu instanceof MulticastMenu) {
menuBar.addPluginMenu(category, newMenu);
// Onda D: ações multicast migram para peer actions / abas (Onda E).
}
if (menu instanceof UnicastMenu) {
onlineUsersPanel.addUnicastMenu(category, newMenu);
networkUsersPanel.addUnicastMenu(category, newMenu);
}
}
catch (IllegalArgumentException e) {
@@ -86,7 +71,7 @@ public class GuiManager {
if (menu instanceof UnicastActionMenu unicastActionMenu) {
JMenuItem newMenu = new JMenuItem(menu.getText());
onlineUsersPanel.initUnicastMenu(newMenu, unicastActionMenu::onClick);
networkUsersPanel.initUnicastMenu(newMenu, unicastActionMenu::onClick);
return newMenu;
}
@@ -11,6 +11,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import pitiupi.models.User;
import pitiupi.models.Messages.GoodbyeMessage;
import pitiupi.models.Messages.HeartbeatMessage;
import java.util.UUID;
/**
@@ -40,6 +41,17 @@ public class HeartbeatManager {
this.connectionManager = connectionManager;
}
/**
* Testes unitários sem rede (não chamar {@link #start()}).
*/
public static HeartbeatManager createForTesting() {
return new HeartbeatManager(null);
}
public String getInstanceId() {
return instanceId;
}
/**
* Inicia o mecanismo de heartbeat.
*/
@@ -59,10 +71,31 @@ public class HeartbeatManager {
}
private void announce() {
if (connectionManager == null) {
return;
}
HeartbeatMessage msg = new HeartbeatMessage(connectionManager.getUserName(), instanceId);
connectionManager.sendUdpMulticast(msg);
}
public void sendGoodbyeAndStop() {
if (connectionManager != null) {
GoodbyeMessage msg = new GoodbyeMessage(connectionManager.getUserName(), instanceId);
connectionManager.sendUdpMulticast(msg);
}
stop();
}
public void receiveGoodbye(GoodbyeMessage msg) {
if (msg.getInstanceId().equals(instanceId)) {
return;
}
boolean removed = peers.remove(msg.getInstanceId()) != null;
if (removed) {
notifyListeners();
}
}
// Chamado pelo Socket quando um pacote é identificado como heartbeat
/**
* Processa uma mensagem de heartbeat recebida de outro usuário.
@@ -85,7 +118,7 @@ public class HeartbeatManager {
String key = msg.getInstanceId();
boolean isNew = !peers.containsKey(key);
User peer = peers.computeIfAbsent(key, k -> new User(msg.getUserName(), from, msg.getTimestamp()));
User peer = peers.computeIfAbsent(key, k -> new User(msg.getUserName(), from, msg.getTimestamp(), msg.getInstanceId()));
peer.touch(System.currentTimeMillis());
if (isNew) {
@@ -138,4 +171,11 @@ public class HeartbeatManager {
public List<User> getPeers() {
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;
import pitiupi.Main;
import pitiupi.api.plugin.LegacyPluginWorkspace;
import pitiupi.api.plugin.PluginWorkspace;
import pitiupi.models.Plugin;
import pitiupi.control.ConnectionManager;
import pitiupi.control.GuiManager;
import pitiupi.errors.ErrorCode;
import pitiupi.models.Messages.Message;
import pitiupi.platform.PitiupiHome;
import pitiupi.plugins.store.PluginMetadataWriter;
import pitiupi.net.tcp.TcpConnection;
import pitiupi.models.Plugin;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
@@ -15,6 +19,7 @@ import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.function.Function;
import java.util.ServiceLoader;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -24,12 +29,14 @@ public class PluginManager {
private final HashSet<String> loadedJars;
private final Logger logger;
private final PluginMetadataWriter metadataWriter;
public PluginManager() {
this.loadedPlugins = new ArrayList<>();
this.loadedJars = new HashSet<>();
logger = Logger.getLogger(getClass().getName());
this.metadataWriter = new PluginMetadataWriter();
}
public void handleMessage(byte[] msg) {
@@ -90,8 +97,39 @@ public class PluginManager {
plugin.onSocketClosed(connection);
}
public List<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) {
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"));
if (jarFiles == null || jarFiles.length == 0) {
@@ -118,13 +156,20 @@ public class PluginManager {
for (Plugin plugin : serviceLoader) {
LoadedPlugin loadedPlugin = new LoadedPlugin(plugin, classLoader);
plugin.createPlugin(connectionManager, guiManager);
PluginWorkspace workspace = workspaceFactory.apply(plugin);
plugin.createPlugin(connectionManager, workspace);
loadedPlugins.add(loadedPlugin);
loadedJars.add(file.getName());
try {
metadataWriter.write(plugin, file.toPath());
} catch (Exception metaEx) {
logger.log(Level.WARNING, "Failed to write plugin metadata for " + file.getName(), metaEx);
}
}
} catch (Exception e) {
logger.log(Level.WARNING, "Failed to load plugin " + file.getName(), e);
logger.log(Level.WARNING, "Failed to load plugin " + file.getName() + ": "
+ ErrorCode.PLUGIN_LOAD_FAILED.getUserMessage(), e);
}
}
}
+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;
import pitiupi.api.plugin.InviteDetails;
import pitiupi.api.plugin.PeerActionsRegistrar;
import pitiupi.api.plugin.PluginWorkspace;
import pitiupi.api.plugin.ShellPluginWorkspace;
import pitiupi.control.ConnectionManager;
import pitiupi.control.GuiManager;
import pitiupi.models.Messages.Message;
import pitiupi.net.tcp.TcpConnection;
import javax.swing.JComponent;
import java.util.List;
/**
@@ -20,11 +25,34 @@ import java.util.List;
* @author flavio
*/
public abstract class Plugin {
private JComponent shellWorkspaceContent;
public abstract String getName();
/**
* Nome amigável na UI (galeria, abas). Padrão: {@link #getName()}.
*/
public String getDisplayName() {
return getName();
}
public abstract String getAuthor();
public abstract String getVersion();
public abstract String getIdentifier();
/**
* Registra o painel principal da aba central (shell). Não abre a aba — use
* {@link pitiupi.api.plugin.PluginWorkspace#openOrFocusTab} na galeria ou peer actions.
*/
protected final void bindShellWorkspaceContent(JComponent content) {
this.shellWorkspaceContent = content;
}
public final JComponent getShellWorkspaceContent() {
return shellWorkspaceContent;
}
/**
* Inicializa o plugin.
*
@@ -35,6 +63,28 @@ public abstract class Plugin {
*/
public abstract void createPlugin(ConnectionManager connectionManager, GuiManager guiManager);
/**
* Inicialização via workspace (Onda C+). O adaptador chama o legado e {@link #installPeerActions}.
*/
public void createPlugin(ConnectionManager connectionManager, PluginWorkspace workspace) {
if (workspace instanceof ShellPluginWorkspace shell) {
createPlugin(connectionManager, shell.guiManager());
installPeerActions(workspace.peerActions());
}
}
public void installPeerActions(PeerActionsRegistrar registrar) {
}
public void onInviteAccepted(InviteDetails invite) {
}
/**
* Aba central do plugin fechada na shell (× na aba superior). Use para parar timers, captura, etc.
*/
public void onShellTabClosed() {
}
/**
* Retorna uma mensagem utilizada pelo plugin.
*
+23
View File
@@ -12,14 +12,34 @@ import java.net.InetAddress;
*/
public class User {
public static final String BROADCAST_INSTANCE_ID = "__broadcast__";
private final String userName;
private final InetAddress address;
private volatile long lastSeen;
private final String instanceId;
public User(String userName, InetAddress address, long lastSeen) {
this(userName, address, lastSeen, null);
}
public User(String userName, InetAddress address, long lastSeen, String instanceId) {
this.userName = userName;
this.address = address;
this.lastSeen = lastSeen;
this.instanceId = instanceId;
}
public static User broadcastAll() {
return new User("Todos", null, 0, BROADCAST_INSTANCE_ID);
}
public boolean isBroadcastTarget() {
return BROADCAST_INSTANCE_ID.equals(instanceId);
}
public String getInstanceId() {
return instanceId;
}
public String getUserName() {
@@ -45,6 +65,9 @@ public class User {
@Override
public String toString() {
if (isBroadcastTarget() || address == null) {
return userName;
}
return userName + " (" + address.getHostAddress() + ")";
}
}
+20 -5
View File
@@ -15,7 +15,9 @@ import java.util.logging.Logger;
import pitiupi.control.ConnectionManager;
import pitiupi.control.plugins.PluginManager;
import pitiupi.models.Messages.GoodbyeMessage;
import pitiupi.models.Messages.HeartbeatMessage;
import pitiupi.models.Messages.SessionInviteMessage;
import pitiupi.models.Messages.Message;
/**
@@ -151,11 +153,19 @@ public class SocketUDP extends Thread {
throw ex;
}
HeartbeatMessage heartbeat = tryParseHeartbeat(msgPacket.getData());
if (heartbeat != null) {
Object coreMessage = tryParseCoreUdpMessage(msgPacket.getData());
if (coreMessage instanceof HeartbeatMessage heartbeat) {
connectionManager.receiveHeartbeat(heartbeat, msgPacket.getAddress());
continue;
}
if (coreMessage instanceof GoodbyeMessage goodbye) {
connectionManager.receiveGoodbye(goodbye);
continue;
}
if (coreMessage instanceof SessionInviteMessage invite) {
connectionManager.receiveSessionInvite(invite);
continue;
}
pluginManager.handleMessage(msgPacket.getData());
}
@@ -168,14 +178,19 @@ public class SocketUDP extends Thread {
* @return a mensagem de heartbeat caso os dados correspondam a uma;
* {@code null} caso contrário.
*/
private HeartbeatMessage tryParseHeartbeat(byte[] data) {
private Object tryParseCoreUdpMessage(byte[] data) {
try {
ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInput in = new ObjectInputStream(bais);
Object obj = in.readObject();
return (obj instanceof HeartbeatMessage) ? (HeartbeatMessage) obj : null;
if (obj instanceof HeartbeatMessage
|| obj instanceof GoodbyeMessage
|| obj instanceof SessionInviteMessage) {
return obj;
}
return null;
} catch (IOException | ClassNotFoundException | ClassCastException ex) {
return null;
return null;
}
}
@@ -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