9 Commits

Author SHA1 Message Date
explewd
c8aeb45480 Add MIT license 2026-07-09 19:54:52 +02:00
Fredrik Johansson
586d39e3b8 Anchor: add EXT-009 scoped presence query
Lets a client ask "is peer X currently online in network Y?" without
joining that network first — a stateless O(1) lookup against the
anchor's existing client registry. Scoped to (net, id) rather than a
global lookup by id alone, so it can't be used as a cross-network
"is this pubkey online anywhere" oracle: the caller must already know
a network the peer belongs to, matching the knowledge already required
to join it and observe presence the slow way via peer-join/peer-leave.

Motivated by flit's known-devices list, where each pairing is its own
isolated anchor network and the app holds no persistent connection
while idle — today the only way to know if a device is reachable is
to attempt a full connect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:06:11 +02:00
Fredrik Johansson
b649f4d012 Restyle web UI with terminal-green theme and monospace font
Switch accent color from purple to green, add JetBrains Mono font,
and unify light/dark palettes into a single dark theme.
2026-07-08 22:59:37 +02:00
Fredrik Johansson
697a7e614d fix: challenge nonce must be 32 bytes per YAW/2 §5.1
All checks were successful
Build / Build & release (push) Successful in 13m46s
The anchor was sending a 16-byte nonce. The spec (yaw2.0-protocol.md §5.1
and yaw2-implementation.md §7.1) requires 32 bytes. Any spec-conformant
peer would fail join signature verification against this anchor, breaking
interop. Signature verification was already using the raw nonce bytes
(not hex), so only the allocation size needed changing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 18:35:57 +02:00
Fredrik Johansson
d07342e97e docs: fix placeholder URLs in QUICKSTART; add committed example scripts
QUICKSTART.md had the author's personal domain as the example anchor URL
(wss://waste.dev.xplwd.com/ws) which would confuse anyone reading it.
Replaced with wss://YOUR_ANCHOR_DOMAIN/ws and added a new Option 3
section explaining the example scripts.

Added committed *.example versions of the four local-workflow scripts
(launch-tui, launch-web, deploy-web, serve-web). The real filenames are
gitignored so local edits (HOST, ANCHOR, etc.) are never accidentally
committed; the .example files serve as templates users copy once.
Each script fails fast if the placeholder URL/host is still set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 11:51:02 +02:00
Fredrik Johansson
f425e0bb8e fix: stop leaking TURN secret to browser clients
All checks were successful
Build / Build & release (push) Successful in 13m40s
WASTE_CONFIG.turnSecret was shipped in plaintext config.js and used to
compute coturn HMAC credentials client-side in browser.ts. Anyone reading
the PWA's JS could read the secret and mint unlimited long-lived TURN
credentials, turning the relay into an open proxy.

The anchor now mints short-lived (1h) credentials server-side via a new
GET /turn-credentials endpoint (-turn-secret flag), mirroring what the
daemon already does. The browser fetches credentials instead of holding
the secret. Daemon mode was unaffected (already server-side).

Docs updated to drop turnSecret from config.js examples, document the
new nginx route, and instruct anyone with an old config.js to rotate
the coturn secret since it was previously exposed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 18:48:59 +02:00
Fredrik Johansson
bf4009558d fix: desktop app binary missing from CI release; add prebuilt binary docs
All checks were successful
Build / Build & release (push) Successful in 13m42s
wails build -o with a relative path doesn't resolve against the shell's
CWD — Wails joins it onto its own build/bin/ output dir, so the binary
landed in cmd/app/dist/ instead of the top-level dist/ the release step
globs. Build with the default name and cp it into dist/ explicitly.

Also document how to download and run the prebuilt daemon/anchor
release binaries on Linux, macOS, and Windows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:36:34 +02:00
Fredrik Johansson
d09aa2b219 feat: reactions, link previews, mobile layout, TUI multi-network + /react usage hints
- TUI /react shows usage hint on bad/missing args instead of silently no-oping
- README: document /join /net /react slash commands and Ctrl+N keybinding
- README: add send_reaction IPC command and reaction IPC event to protocol reference
- FUTURE.md: mark reactions, link previews, responsive mobile layout, TUI
  multi-network and TUI reactions as shipped; add prose sections for each
- EXTENSIONS.md: add EXT-008 documenting reaction wire protocol, SQLite
  schema, IPC commands/events, history replay, and browser-mode implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 20:08:52 +02:00
Fredrik Johansson
437deca6a0 feat: TUI multi-network + reactions + history
Multi-network:
- Left panel now shows Networks section above Rooms
- /join <name> joins a new network at runtime; -network flag now optional
- ctrl+n cycles between joined networks; /net <n|name> switches by number or name
- All state (rooms, peers, messages) scoped per network via netData struct
- Messages keyed by "netId:room" (same as web store)

Reactions:
- /react <emoji>  — react to the last message in the current room
- /react <n> <emoji> — react to message number n
- Reactions displayed as a dimmed line below each message: 👍 2  ❤️ 1
- EvtReaction handler updates in-place and refreshes viewport

History:
- EvtHistoryLoaded now handled: historical messages prepended, deduped by mid, sorted by time

UX:
- Each message prefixed with [n] line number so /react targets are unambiguous
- -network flag is now optional (start idle, /join to connect)
- Status bar hint updated with new commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 20:02:50 +02:00
18 changed files with 1338 additions and 371 deletions

View File

@@ -68,7 +68,8 @@ jobs:
- name: Build desktop app - name: Build desktop app
run: | run: |
cd cmd/app cd cmd/app
wails build -trimpath -ldflags="-s -w" -tags webkit2_41 -o ../../dist/waste-linux-amd64 wails build -trimpath -ldflags="-s -w" -tags webkit2_41
cp build/bin/waste ../../dist/waste-linux-amd64
# ── Publish release (tags only) ───────────────────────────────────────── # ── Publish release (tags only) ─────────────────────────────────────────

View File

@@ -151,9 +151,13 @@ entries from all applicable share roots, with relative `path` fields
**Status:** implemented (browser mode + daemon mode) **Status:** implemented (browser mode + daemon mode)
**Affects:** ICE server configuration only, no wire changes **Affects:** ICE server configuration only, no wire changes
The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret` The browser adapter reads `WASTE_CONFIG.turnURL` and fetches short-lived
and adds a TURN server to the WebRTC `ICEServers` list. Credentials are credentials from the anchor's `GET /turn-credentials` endpoint (derived from
generated using HMAC-SHA1 of the username (coturn `use-auth-secret` scheme). `WASTE_CONFIG.signalURL`, or overridden via `WASTE_CONFIG.turnCredentialsURL`).
The anchor computes the credential using HMAC-SHA1 of the username (coturn
`use-auth-secret` scheme) — the shared secret itself is never sent to the
browser. Daemon mode does the equivalent computation locally, since the
daemon already holds `-turn-secret` server-side.
YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is
opt-in via server configuration and does not affect peers that omit it. opt-in via server configuration and does not affect peers that omit it.
@@ -304,3 +308,180 @@ without a `mid` are assigned one at receive time and are not gossipped.
Emitted once per room after a `history_chunk` is fully processed. The UI Emitted once per room after a `history_chunk` is fully processed. The UI
should render these messages with a visual separator from live messages. should render these messages with a visual separator from live messages.
---
## EXT-008 — Message Reactions
### Wire message (`PeerMessage`)
```json
{
"type": "reaction",
"reaction_mid": "<32-hex mid of the target message>",
"reaction_emoji": "👍"
}
```
Sent on the normal mesh DataChannel (same as `chat`). No signing beyond
the existing channel-level encryption.
### Semantics
- A reaction is idempotent: the same `(mid, emoji, from_peer)` triple is
stored with `INSERT OR IGNORE` — receiving a duplicate is a no-op.
- There is no "un-react" wire message. Toggling off a reaction in the UI
is a local-only operation in the current implementation.
- `reaction_mid` must reference a message that exists in the local store;
unknown mids are silently ignored.
### Storage
SQLite table added as a migration:
```sql
CREATE TABLE IF NOT EXISTS reactions (
mid TEXT NOT NULL,
emoji TEXT NOT NULL,
from_peer TEXT NOT NULL,
reacted_at DATETIME NOT NULL,
PRIMARY KEY (mid, emoji, from_peer)
)
```
### IPC
**Command** — send a reaction (daemon and browser mode):
```json
{ "type": "send_reaction", "network_id": "...", "reaction_mid": "<hex>", "reaction_emoji": "👍" }
```
**Event** — reaction received or replayed from history:
```json
{ "type": "reaction", "network_id": "...", "peer_id": "<64-hex>", "reaction_mid": "<hex>", "reaction_emoji": "👍" }
```
Stored reactions are replayed as `reaction` IPC events when history is
loaded (`sendStoredHistory`), so the UI always sees reactions alongside
their messages.
### History replay
When `sendStoredHistory` sends a `history_chunk`, it also queries
`ReactionsForRoom` and emits one `reaction` event per stored reaction so
clients receive the full reaction state on reconnect.
### Browser mode
`browser.ts` mirrors the daemon behaviour independently:
- `PeerConn.sendReaction(mid, emoji)` broadcasts `{ type: "reaction", reaction_mid, reaction_emoji }` over the DataChannel.
- Incoming `reaction` wire frames are dispatched as `reaction` IPC events.
- `BrowserAdapter.send()` handles `send_reaction` commands and both broadcasts to all peers and emits a local `reaction` event.
- `sendChat` includes the `mid` in the wire frame so reactions can reference it correctly across peers.
---
## EXT-009 — Scoped Presence Query
**Status:** proposed
**Affects:** anchor WS protocol only (new request/response pair); no DataChannel or peer-to-peer wire changes
### Motivation
YAW/2 §5.2 presence (`peer-join` / `peer-leave`) is push-only and scoped to peers
who are simultaneously joined to the same network — you only learn someone is
online by already being in the room with them. That's fine for chat, but it's
the wrong shape for flit's "known devices" list: each paired device is its own
isolated network (`net = NetHash(PairRoomName(idA, idB))`), and the app doesn't
hold a persistent anchor connection per pairing while idle. Today the only way
to find out if a known device is reachable is to attempt a full connect.
This extension adds a lightweight, stateless query: "is peer X currently
online in network Y?" — answerable from the anchor's existing in-memory
`clients` registry with an O(1) lookup, no new server-side state.
**Deliberately not** a global "is this pubkey online anywhere" query. The
anchor's `clients` map is keyed globally by peer id, so an unscoped query
would let anyone who knows a pubkey probe its online status across every
network on the anchor, with no relationship required — a cross-identity
presence oracle. Scoping the query to `(net, id)` keeps the access model
identical to today's: knowing a network's hash is already the bar for
joining it and observing presence the slow way (§5.1 `joined`, §5.2
`peer-join`); this extension only removes the need to actually join and wait.
### Wire messages
Sent over the anchor WS connection. Does **not** require a prior `join`
the query is anchor-local and stateless, so a client may ask before joining,
after leaving, or without ever joining any network on this connection.
#### `presence_query`
```json
{ "type": "presence_query", "net": "<64-hex net hash>", "id": "<64-hex peer id>" }
```
| Field | Type | Description |
|-------|--------|--------------|
| `net` | string | Network hash, computed the same way as `join` (§5.1). |
| `id` | string | Peer id being queried. |
#### `presence`
```json
{ "type": "presence", "net": "<64-hex net hash>", "id": "<64-hex peer id>", "online": true }
```
`online` is `true` iff a client is currently registered with that exact
`(net, id)` pair. Unknown `net`/`id` combinations return `online: false`,
identical in shape to a peer who simply isn't connected — the anchor does
not distinguish "never seen this net" from "peer not currently in it."
### Anchor implementation notes
No new registry. `a.clients` is already keyed globally by peer id
(`cmd/anchor/main.go`); the query handler does:
```go
a.mu.RLock()
c, ok := a.clients[id]
online := ok && c.net == net
a.mu.RUnlock()
```
Same lock, same map, no writes. YAW/2-only anchors that don't implement this
extension simply never emit a `presence` reply — per §8, unknown request
types are ignored by clients, so callers should treat "no reply within a
short timeout" the same as `online: false` rather than blocking indefinitely.
### Security considerations
- **No new information beyond what §5.2 already permits** — the requester
must already know both `net` and `id` to ask, exactly the knowledge
required to join that network and observe presence natively. This
extension is a latency/statefulness shortcut, not a new capability.
- **Does not enable identity-wide presence enumeration.** Because the query
is scoped to a specific `net`, checking whether pubkey X is online
requires already knowing at least one network X is a member of. An anchor
operator or a client cannot use this to ask "where is X online" across
all networks it serves.
- **Rate limiting recommended** at the anchor (e.g. per-connection token
bucket) to prevent a connection from being used to hammer many `(net,
id)` guesses cheaply. This mirrors the existing `history_request`
rate-limit precedent (EXT-007, one request per peer/room per 60s) even
though the abuse shape here is different — presence queries are free of
disk I/O but still worth bounding.
- **No change to what the anchor can already infer.** An anchor could
already tell that two ids are members of the same net by virtue of
relaying between them; this extension doesn't let the anchor learn new
relationships, only lets *clients* ask a question the anchor already had
the answer to.
### Client usage (flit)
Each known device already has a deterministic `net` (`PairRoomName(idA,
idB)`, hashed). A "known devices" list can send one `presence_query` per
entry — no persistent connection required per pairing — and render
online/offline before the user taps Connect. A short client-side cache
(a few seconds) is recommended to avoid re-querying on every render.

View File

@@ -41,7 +41,9 @@ Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexiv
### TURN relay ✅ (shipped) ### TURN relay ✅ (shipped)
Both browser and daemon modes support TURN relay. Both browser and daemon modes support TURN relay.
**Browser mode:** `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. The peer dot turns yellow for relayed connections (`candidate_type: relay`). **Browser mode:** `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and fetches a time-limited credential from the anchor's `GET /turn-credentials` endpoint (HMAC-SHA1, compatible with coturn `use-auth-secret`) rather than holding the shared secret client-side. The peer dot turns yellow for relayed connections (`candidate_type: relay`).
> **Security fix:** earlier this previously embedded `turnSecret` directly in `WASTE_CONFIG`, which let anyone reading the PWA's JS mint unlimited long-lived TURN credentials. The secret now lives only on the anchor (`-turn-secret` flag); the anchor mints short-lived credentials per-request instead.
**Daemon mode:** `-turn-url` and `-turn-secret` flags on `cmd/daemon`. `turnICEServers()` in `internal/netmgr/manager.go` generates HMAC-SHA1 credentials and injects them into the ICE server list for every new peer connection. **Daemon mode:** `-turn-url` and `-turn-secret` flags on `cmd/daemon`. `turnICEServers()` in `internal/netmgr/manager.go` generates HMAC-SHA1 credentials and injects them into the ICE server list for every new peer connection.
@@ -137,9 +139,25 @@ Web frontend (React, already built) + [Wails v2](https://wails.io) shell for nat
| ✅ shipped | P2P message history gossip (EXT-007) — new peers receive recent history from first connected peer | | ✅ shipped | P2P message history gossip (EXT-007) — new peers receive recent history from first connected peer |
| ✅ shipped | Date-aware timestamps in TUI and web UI | | ✅ shipped | Date-aware timestamps in TUI and web UI |
| ✅ shipped | Historical peer alias resolution in web UI | | ✅ shipped | Historical peer alias resolution in web UI |
| ✅ shipped | Message reactions (emoji picker, full-stack: wire protocol, SQLite, IPC, web UI, TUI) |
| ✅ shipped | Link rendering + image preview in web UI messages |
| ✅ shipped | Responsive mobile layout (slide-over sidebar, hamburger button) |
| ✅ shipped | TUI multi-network (join/switch networks at runtime, `ctrl+n`, `/join`, `/net`) |
| ✅ shipped | TUI message reactions (`/react <emoji>` or `/react <n> <emoji>`) |
| 🔜 planned | Push notifications (PWA Web Push + service worker) | | 🔜 planned | Push notifications (PWA Web Push + service worker) |
| 🔜 planned | Message reactions (emoji, full-stack gossip) |
| 🔜 planned | Link rendering + image preview in messages | ### Message Reactions ✅ (shipped)
Full-stack emoji reactions. The web UI shows a `+` button on hover that opens a six-emoji picker (👍 ❤️ 😂 😮 😢 🙏). Reactions render as chips below each message; clicking an existing chip toggles your own reaction. Reactions are stored in SQLite (`reactions` table), gossiped over the mesh as `reaction` wire messages (EXT-008), and replayed via IPC on reconnect.
Browser mode: `browser.ts` independently mirrors the daemon — reactions flow over the DataChannel and are broadcast to all connected peers.
TUI: messages show `[n]` line numbers. Use `/react <emoji>` (reacts to last message) or `/react <n> <emoji>` (reacts to message `n`). Reactions render inline below the target message.
### Link Rendering + Image Preview ✅ (shipped)
URLs in messages are auto-linked. URLs ending in a recognized image extension (`.jpg`, `.png`, `.gif`, `.webp`, `.svg`) render an inline `<img>` preview (max 320×200px). `blob:` and `data:image` URLs are also treated as images.
### Responsive Mobile Layout ✅ (shipped)
At viewport width ≤ 600px the sidebar becomes a fixed-position slide-over drawer, hidden off-screen by default (`transform: translateX(-100%)`). A `☰` hamburger button in the message pane header toggles it open. Clicking any room or network in the sidebar closes it automatically. The layout is dimmed while the sidebar is open via a `::before` overlay.
### Push Notifications (planned) ### Push Notifications (planned)
The web UI is already a PWA (installable, has manifest). The missing half is a service worker + Web Push subscription: The web UI is already a PWA (installable, has manifest). The missing half is a service worker + Web Push subscription:

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 explewd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -41,13 +41,33 @@ On Linux and Windows a tray icon appears — closing the window hides to tray ra
--- ---
## Option 3 — Run the daemon manually (headless / power users) ## Option 3 — Run the daemon + TUI or web UI locally (power users / dev)
Example scripts are provided for the common local workflows. Copy them and fill in your anchor URL:
```bash
# TUI (terminal UI):
cp launch-tui.sh.example launch-tui.sh
$EDITOR launch-tui.sh # set ANCHOR=wss://your-anchor/ws
./launch-tui.sh
# Web UI in daemon mode (Vite dev server + daemon):
cp launch-web.sh.example launch-web.sh
$EDITOR launch-web.sh # set ANCHOR=wss://your-anchor/ws
./launch-web.sh
```
The real script files are gitignored so your local edits (anchor URL, alias, network) are never accidentally committed.
---
## Option 4 — Run the daemon manually (headless)
If you want the daemon running in the background without the desktop UI — on a server, over SSH, or with the web UI in a browser pointed at your local machine: If you want the daemon running in the background without the desktop UI — on a server, over SSH, or with the web UI in a browser pointed at your local machine:
```bash ```bash
# Download waste-daemon from the releases page, then: # Download waste-daemon from the releases page, then:
./waste-daemon -alias yourname -anchor wss://your-anchor-server/ws ./waste-daemon -alias yourname -anchor wss://YOUR_ANCHOR_DOMAIN/ws
``` ```
Then open the web UI in a browser at the anchor URL, or point the web UI's daemon mode at `ws://127.0.0.1:17338`. Then open the web UI in a browser at the anchor URL, or point the web UI's daemon mode at `ws://127.0.0.1:17338`.
@@ -83,7 +103,7 @@ The anchor is a tiny signaling server that helps peers find each other — it ne
```bash ```bash
# On your VPS: # On your VPS:
./waste-anchor -bind 127.0.0.1:8080 ./waste-anchor -bind 127.0.0.1:8080 -turn-secret YOUR_COTURN_SECRET
``` ```
Put it behind nginx with a `/ws` WebSocket proxy and serve the web UI static files at `/`. See [README.md](README.md#hosting-on-a-vps) for the full nginx setup. Put it behind nginx with `/ws` and `/turn-credentials` proxied to the anchor, and the web UI static files at `/`. See [README.md](README.md#hosting-on-a-vps) for the full nginx setup.

View File

@@ -21,6 +21,51 @@ waste-go/
--- ---
## Prebuilt binaries
Every tagged release publishes cross-compiled binaries — no Go toolchain required. Grab them from the repo's **Releases** page:
```
https://repo.explewd.com/explewd/waste-go/releases
```
| File | What it is | Run it on |
|---|---|---|
| `waste-daemon-<os>-<arch>` | The peer process — your identity, mesh connections, file shares | Each friend's own machine |
| `waste-anchor-<os>-<arch>` | The signaling relay (no message content ever passes through it) | One server you control (VPS) |
`<os>` is `linux`, `darwin` (macOS), or `windows`; `<arch>` is `amd64` or `arm64` (Windows builds are amd64 only). Windows binaries have a `.exe` suffix.
**Linux / macOS:**
```bash
curl -LO https://repo.explewd.com/explewd/waste-go/releases/download/<tag>/waste-daemon-linux-amd64
chmod +x waste-daemon-linux-amd64
./waste-daemon-linux-amd64 -alias yourname -data-dir ~/.waste --join 'waste:eyJ...'
```
> **macOS Gatekeeper:** unsigned binaries downloaded from a browser get a quarantine flag and macOS will refuse to run them ("cannot be opened because the developer cannot be verified"). Clear it once after downloading: `xattr -d com.apple.quarantine waste-daemon-darwin-arm64` (or right-click → Open the first time, which prompts for an override).
**Windows:** download the `.exe`, then run it from PowerShell or `cmd.exe`:
```powershell
.\waste-daemon-windows-amd64.exe -alias yourname -data-dir C:\waste --join "waste:eyJ..."
```
SmartScreen may warn about an unrecognized publisher on first run — these binaries aren't code-signed. Click "More info" → "Run anyway".
Once the daemon is running, drive it with the [TUI](#terminal-ui) (`./cmd/tui` built locally, or the web UI in [daemon mode](#daemon-mode-for-users-running-the-daemon-locally)) — the daemon itself has no UI of its own, it just exposes the local IPC API on port 17337.
The `waste-anchor` binary is for whoever is hosting a network — see [Hosting on a VPS](#hosting-on-a-vps) below for the full anchor + web UI setup. For a quick local anchor (e.g. testing on a LAN), just run:
```bash
./waste-anchor-linux-amd64 -bind 0.0.0.0:8080
```
> **No desktop app build in the current release.** A native Wails desktop app (`cmd/app/`) exists in the source tree, but the CI step that packages it for releases was broken (wrong output path) until this fix — earlier tagged releases only have daemon/anchor binaries. The next tag will include `waste-linux-amd64`. Until then, build it yourself with `./build-app.sh` (see [Desktop app (Wails)](#desktop-app-wails) below).
---
## Hosting on a VPS ## Hosting on a VPS
You need two things on the server: the **anchor** (signaling process) and the **web UI** (static files). Both are served through the same domain via Nginx Proxy Manager. You need two things on the server: the **anchor** (signaling process) and the **web UI** (static files). Both are served through the same domain via Nginx Proxy Manager.
@@ -89,7 +134,7 @@ This tells the browser where to connect for signaling. Without it the join form
### 3. Nginx Proxy Manager setup ### 3. Nginx Proxy Manager setup
Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS enabled. You need two locations: Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS enabled. You need these locations:
**Location 1 — WebSocket signaling (`/ws`)** **Location 1 — WebSocket signaling (`/ws`)**
- Location: `/ws` - Location: `/ws`
@@ -97,6 +142,12 @@ Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS en
- Forward port: `8080` - Forward port: `8080`
- Enable: WebSockets Support - Enable: WebSockets Support
**Location 1b — TURN credentials (`/turn-credentials`, only if using TURN — see [step 4](#4-turn-relay-optional-fixes-mobile--cgnat))**
- Location: `/turn-credentials`
- Forward hostname/IP: `127.0.0.1`
- Forward port: `8080`
- Plain HTTP, no WebSockets toggle needed
**Location 2 — Web UI (catch-all)** **Location 2 — Web UI (catch-all)**
- Location: `/` - Location: `/`
- Choose "Serve Static Files" (or point to a local HTTP server serving `/var/www/waste-web`) - Choose "Serve Static Files" (or point to a local HTTP server serving `/var/www/waste-web`)
@@ -117,6 +168,7 @@ Or use `serve-web.sh` which handles PID tracking and restart:
The key requirements: The key requirements:
- `/ws` → anchor process (WebSocket, keep-alive) - `/ws` → anchor process (WebSocket, keep-alive)
- `/turn-credentials` → anchor process (plain HTTP; only needed if using TURN)
- `/*` → static file server (SPA fallback: return `index.html` for unknown paths) - `/*` → static file server (SPA fallback: return `index.html` for unknown paths)
### 4. TURN relay (optional, fixes mobile / CGNAT) ### 4. TURN relay (optional, fixes mobile / CGNAT)
@@ -155,21 +207,32 @@ systemctl enable coturn
systemctl start coturn systemctl start coturn
``` ```
**Update `config.js`** to tell browsers about the TURN server: **Start the anchor with the same secret**, so it can mint short-lived credentials on your behalf:
```bash
./waste-anchor -bind 0.0.0.0:8080 -turn-secret YOUR_SECRET_HERE
```
This enables `GET /turn-credentials` on the anchor, which returns a fresh `{username, credential}` pair (1-hour TTL) computed from the shared secret — the secret itself never leaves the server.
**Update `config.js`** to tell browsers about the TURN server (no secret here — only the public relay address):
```js ```js
window.WASTE_CONFIG = { window.WASTE_CONFIG = {
signalURL: 'wss://your-domain.com/ws', signalURL: 'wss://your-domain.com/ws',
turnURL: 'turn:your-domain.com:3478', turnURL: 'turn:your-domain.com:3478',
turnSecret: 'YOUR_SECRET_HERE',
} }
``` ```
The `use-auth-secret` mode generates short-lived TURN credentials from the shared secret — no user database required. The relay only sees opaque DTLS-encrypted blobs. > **Security note:** earlier versions of this doc had you put `turnSecret` directly in `config.js`. Don't — anyone reading the PWA's JS bundle could read it and mint unlimited, long-lived TURN credentials, turning your relay into an open proxy for anyone. The browser now calls the anchor's `/turn-credentials` endpoint instead and only ever sees a credential that expires in an hour. If you have an old `config.js` with `turnSecret` set, remove it and rotate the coturn secret (`static-auth-secret` in `turnserver.conf` and the anchor's `-turn-secret` flag) since the old one was exposed.
> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations). The browser adapter calls `signalURL` with `/ws` swapped for `/turn-credentials` to find the anchor's endpoint by default; set `turnCredentialsURL` explicitly in `WASTE_CONFIG` if the anchor is reachable at a different path. If `turnURL` is set but the credentials endpoint is unreachable, the browser falls back to STUN-only.
**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. The same coturn `use-auth-secret` HMAC-SHA1 scheme is used — no extra config required beyond what you set up for browser mode. You'll also need nginx to route the new path to the anchor, alongside `/ws` (see [step 3](#3-nginx-proxy-manager-setup)):
- `/turn-credentials` → anchor process (plain HTTP, no WebSocket upgrade needed)
**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. This is unaffected by the above — the daemon computes credentials itself server-side and never exposes the secret, same as the anchor now does for browser mode.
--- ---
@@ -392,9 +455,16 @@ go run ./cmd/tui -network friends
| `-join` | — | `waste:` invite string | | `-join` | — | `waste:` invite string |
| `-ipc` | `17337` | Daemon IPC port | | `-ipc` | `17337` | Daemon IPC port |
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit **Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `Ctrl+N` — cycle networks · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
**Slash commands:** `/room <name>` — create a new room (persisted in SQLite, restored on reconnect). Rooms with unread messages show a `*` prefix in the sidebar. **Slash commands:**
- `/room <name>` — create a new room (persisted in SQLite, restored on reconnect)
- `/join <name>` — join a new network at runtime (the `-network` flag is optional; start idle and `/join` to connect)
- `/net <n|name>` — switch active network by index or name
- `/react <emoji>` — react to the last message (use actual emoji characters, e.g. `/react 👍`)
- `/react <n> <emoji>` — react to message `[n]` (line numbers shown next to each message)
Rooms with unread messages show a `*` prefix in the sidebar.
--- ---
@@ -419,6 +489,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
{"type":"create_room","room":"dev"} {"type":"create_room","room":"dev"}
{"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network {"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network
{"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"} {"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"}
{"type":"send_reaction","network_id":"...","reaction_mid":"<32-hex>","reaction_emoji":"👍"}
{"type":"export_identity","passphrase":"..."} {"type":"export_identity","passphrase":"..."}
{"type":"import_identity","passphrase":"...","backup":"..."} {"type":"import_identity","passphrase":"...","backup":"..."}
``` ```
@@ -435,6 +506,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}} {"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"} {"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
{"type":"room_created","network_id":"...","room":"dev"} {"type":"room_created","network_id":"...","room":"dev"}
{"type":"reaction","network_id":"...","peer_id":"<64-hex>","reaction_mid":"<32-hex>","reaction_emoji":"👍"}
{"type":"identity_exported","backup":"..."} {"type":"identity_exported","backup":"..."}
{"type":"error","error_message":"..."} {"type":"error","error_message":"..."}
``` ```

View File

@@ -6,12 +6,17 @@ package main
import ( import (
"context" "context"
"crypto/ed25519" "crypto/ed25519"
"crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha1"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"flag" "flag"
"log" "log"
"net/http" "net/http"
"strconv"
"sync" "sync"
"time" "time"
@@ -23,16 +28,40 @@ import (
func main() { func main() {
bind := flag.String("bind", "0.0.0.0:17339", "address to listen on") bind := flag.String("bind", "0.0.0.0:17339", "address to listen on")
turnSecret := flag.String("turn-secret", "", "coturn use-auth-secret shared secret; enables GET /turn-credentials")
flag.Parse() flag.Parse()
a := newAnchor() a := newAnchor()
http.HandleFunc("/ws", a.handleWS) http.HandleFunc("/ws", a.handleWS)
if *turnSecret != "" {
http.HandleFunc("/turn-credentials", turnCredentialsHandler(*turnSecret))
log.Printf("anchor: /turn-credentials enabled")
}
log.Printf("anchor: listening on %s", *bind) log.Printf("anchor: listening on %s", *bind)
if err := http.ListenAndServe(*bind, nil); err != nil { if err := http.ListenAndServe(*bind, nil); err != nil {
log.Fatalf("anchor: %v", err) log.Fatalf("anchor: %v", err)
} }
} }
// turnCredentialsHandler mints short-lived coturn use-auth-secret credentials
// server-side, so the shared secret never reaches the browser. Mirrors the
// scheme in internal/netmgr.Manager.turnICEServers (daemon mode).
func turnCredentialsHandler(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/json")
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(expiry))
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
json.NewEncoder(w).Encode(struct {
Username string `json:"username"`
Credential string `json:"credential"`
TTL int `json:"ttl"`
}{Username: expiry, Credential: credential, TTL: 3600})
}
}
// ── Anchor ──────────────────────────────────────────────────────────────────── // ── Anchor ────────────────────────────────────────────────────────────────────
type client struct { type client struct {
@@ -40,8 +69,30 @@ type client struct {
net string // hashed network name, set after join net string // hashed network name, set after join
send chan proto.AnchorMessage send chan proto.AnchorMessage
conn *websocket.Conn conn *websocket.Conn
// EXT-009 presence_query rate limiting. Only ever touched from this
// connection's own read-loop goroutine, so no lock needed.
presenceQueryCount int
presenceWindowStart time.Time
} }
// allowPresenceQuery implements a simple per-connection token bucket for
// EXT-009 presence_query: presenceQueryLimit requests per presenceQueryWindow.
func (c *client) allowPresenceQuery() bool {
now := time.Now()
if now.Sub(c.presenceWindowStart) > presenceQueryWindow {
c.presenceWindowStart = now
c.presenceQueryCount = 0
}
c.presenceQueryCount++
return c.presenceQueryCount <= presenceQueryLimit
}
const (
presenceQueryLimit = 20
presenceQueryWindow = 10 * time.Second
)
type anchor struct { type anchor struct {
mu sync.RWMutex mu sync.RWMutex
clients map[string]*client // keyed by hex peer id clients map[string]*client // keyed by hex peer id
@@ -80,6 +131,16 @@ func (a *anchor) unregister(c *client) {
log.Printf("anchor: peer left: %s", c.id[:min(8, len(c.id))]) log.Printf("anchor: peer left: %s", c.id[:min(8, len(c.id))])
} }
// isOnline reports whether a client is currently registered with the exact
// (net, id) pair — EXT-009. O(1): reuses the existing global clients map,
// no new state.
func (a *anchor) isOnline(net, id string) bool {
a.mu.RLock()
defer a.mu.RUnlock()
c, ok := a.clients[id]
return ok && c.net == net
}
// networkPeerIDs returns the hex ids of all peers in the same network as netHash. // networkPeerIDs returns the hex ids of all peers in the same network as netHash.
func (a *anchor) networkPeerIDs(netHash, excludeID string) []string { func (a *anchor) networkPeerIDs(netHash, excludeID string) []string {
a.mu.RLock() a.mu.RLock()
@@ -126,8 +187,8 @@ func (a *anchor) handleWS(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context()) ctx, cancel := context.WithCancel(r.Context())
defer cancel() defer cancel()
// Send a challenge nonce immediately. // Send a challenge nonce immediately. §5.1 requires 32 bytes.
nonce := make([]byte, 16) nonce := make([]byte, 32)
rand.Read(nonce) rand.Read(nonce)
nonceHex := hex.EncodeToString(nonce) nonceHex := hex.EncodeToString(nonce)
if err := wsjson.Write(ctx, conn, proto.AnchorMessage{ if err := wsjson.Write(ctx, conn, proto.AnchorMessage{
@@ -196,6 +257,24 @@ func (a *anchor) handleWS(w http.ResponseWriter, r *http.Request) {
} }
log.Printf("anchor: peer joined: %s net=%s peers=%d", c.id[:min(8, len(c.id))], msg.Net[:8], len(peers)) log.Printf("anchor: peer joined: %s net=%s peers=%d", c.id[:min(8, len(c.id))], msg.Net[:8], len(peers))
case proto.AnchorPresenceQuery:
// EXT-009. Stateless and anchor-local: does not require the
// querying connection to have joined any network. Scoped to
// (net, id) — never a global "is this pubkey online anywhere"
// lookup, to avoid a cross-network presence oracle.
if msg.Net == "" || msg.ID == "" {
continue
}
if !c.allowPresenceQuery() {
continue
}
online := a.isOnline(msg.Net, msg.ID)
resp := proto.AnchorMessage{Type: proto.AnchorPresence, Net: msg.Net, ID: msg.ID, Online: &online}
select {
case c.send <- resp:
default:
}
case proto.AnchorTo: case proto.AnchorTo:
if c.id == "" { if c.id == "" {
continue // not joined yet continue // not joined yet

View File

@@ -1,6 +1,7 @@
// Package main is the waste-go terminal UI. // Package main is the waste-go terminal UI.
// It connects to a running daemon's IPC port, joins a named network, and // It connects to a running daemon's IPC port and renders a three-pane layout:
// renders a three-pane layout: rooms (left), messages (centre), peers (right). // rooms/networks (left), messages with line numbers (centre), peers (right).
// Multiple networks are supported at runtime via /join; switch with ctrl+n.
package main package main
import ( import (
@@ -10,6 +11,7 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@@ -28,18 +30,22 @@ var (
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
styleActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) styleActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
styleRoom = lipgloss.NewStyle().Foreground(lipgloss.Color("250")) styleRoom = lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
styleNet = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
styleNetActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("51"))
stylePeer = lipgloss.NewStyle().Foreground(lipgloss.Color("72")) stylePeer = lipgloss.NewStyle().Foreground(lipgloss.Color("72"))
styleSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("86")) styleSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
styleMsgFrom = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) styleMsgFrom = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
styleMsgMe = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) styleMsgMe = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
styleMsgTime = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) styleMsgTime = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
styleLineNum = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
styleReaction = lipgloss.NewStyle().Foreground(lipgloss.Color("246"))
styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
styleBorder = lipgloss.Color("238") styleBorder = lipgloss.Color("238")
styleStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) styleStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
) )
const sideW = 22 // total width of each sidebar box (inner = sideW-2) const sideW = 22
// ── tea messages ────────────────────────────────────────────────────────────── // ── tea messages ──────────────────────────────────────────────────────────────
@@ -51,9 +57,6 @@ type ipcLineMsg struct{ line []byte }
type connectErrMsg struct{ err error } type connectErrMsg struct{ err error }
type readErrMsg struct{ err error } type readErrMsg struct{ err error }
// lineReader pumps a TCP connection through a channel so a single bufio.Scanner
// is alive for the lifetime of the connection (avoids read-ahead data loss when
// a new scanner is created on each call).
type lineReader struct { type lineReader struct {
ch chan []byte ch chan []byte
} }
@@ -84,7 +87,42 @@ func (lr *lineReader) next() tea.Cmd {
// ── model ───────────────────────────────────────────────────────────────────── // ── model ─────────────────────────────────────────────────────────────────────
// netData holds per-network state.
type netData struct {
id string
name string
localID proto.PeerID
localAlias string
rooms []string
activeRoom int
peers map[proto.PeerID]string
peerOrder []proto.PeerID
knownPeers map[proto.PeerID]string // historical peers (from store)
}
func newNetData(id, name string) *netData {
return &netData{
id: id,
name: name,
rooms: []string{"general"},
peers: make(map[proto.PeerID]string),
knownPeers: make(map[proto.PeerID]string),
}
}
func (n *netData) addRoom(room string) bool {
for _, r := range n.rooms {
if r == room {
return false
}
}
n.rooms = append(n.rooms, room)
return true
}
// entry is a single chat message in the viewport.
type entry struct { type entry struct {
mid string
from string from string
body string body string
at time.Time at time.Time
@@ -93,7 +131,7 @@ type entry struct {
type model struct { type model struct {
ipcPort int ipcPort int
networkName string initialNetwork string // from -network flag; joined on first connect
width, height int width, height int
@@ -101,16 +139,15 @@ type model struct {
enc *json.Encoder enc *json.Encoder
reader *lineReader reader *lineReader
localID proto.PeerID nets []*netData
localAlias string activeNet int
rooms []string // "general" always first; DM rooms appended // keyed by "netId:room"
activeRoom int
messages map[string][]entry messages map[string][]entry
unread map[string]bool // rooms with messages since last viewed unread map[string]bool
peers map[proto.PeerID]string // connected peers: id → alias // mid → emoji[]alias
peerOrder []proto.PeerID reactions map[string]map[string][]string
input textinput.Model input textinput.Model
viewport viewport.Model viewport viewport.Model
@@ -118,27 +155,89 @@ type model struct {
status string status string
errMsg string errMsg string
invitePopup string // non-empty = show invite overlay invitePopup string
} }
func newModel(ipcPort int, network string) model { func newModel(ipcPort int, network string) model {
ti := textinput.New() ti := textinput.New()
ti.Placeholder = "Type a message…" ti.Placeholder = "Type a message, or /join /net /room /react…"
ti.Focus() ti.Focus()
ti.CharLimit = 2000 ti.CharLimit = 2000
return model{ return model{
ipcPort: ipcPort, ipcPort: ipcPort,
networkName: network, initialNetwork: network,
rooms: []string{"general"},
messages: make(map[string][]entry), messages: make(map[string][]entry),
unread: make(map[string]bool), unread: make(map[string]bool),
peers: make(map[proto.PeerID]string), reactions: make(map[string]map[string][]string),
input: ti, input: ti,
status: "connecting…", status: "connecting…",
} }
} }
// ── accessors ─────────────────────────────────────────────────────────────────
func (m model) activeNetData() *netData {
if m.activeNet < len(m.nets) {
return m.nets[m.activeNet]
}
return nil
}
func (m model) activeNetworkID() string {
if n := m.activeNetData(); n != nil {
return n.id
}
return ""
}
func (m model) activeRoomName() string {
n := m.activeNetData()
if n == nil {
return "general"
}
if n.activeRoom < len(n.rooms) {
return n.rooms[n.activeRoom]
}
return "general"
}
func (m model) msgKey() string {
n := m.activeNetData()
if n == nil {
return ":general"
}
return n.id + ":" + n.rooms[n.activeRoom]
}
func (m model) netByID(id string) *netData {
for _, n := range m.nets {
if n.id == id {
return n
}
}
return nil
}
func (m model) aliasOf(netID string, id proto.PeerID) string {
n := m.netByID(netID)
if n == nil {
return shortID(id)
}
if id == n.localID && n.localAlias != "" {
return n.localAlias
}
if a, ok := n.peers[id]; ok && a != "" {
return a
}
if a, ok := n.knownPeers[id]; ok && a != "" {
return a
}
return shortID(id)
}
// ── Init ──────────────────────────────────────────────────────────────────────
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
return tea.Batch(connectCmd(m.ipcPort), textinput.Blink) return tea.Batch(connectCmd(m.ipcPort), textinput.Blink)
} }
@@ -162,7 +261,7 @@ func sendIPC(enc *json.Encoder, msg proto.IpcMessage) tea.Cmd {
} }
} }
// ── update ──────────────────────────────────────────────────────────────────── // ── Update ────────────────────────────────────────────────────────────────────
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
@@ -181,12 +280,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.conn = msg.conn m.conn = msg.conn
m.enc = json.NewEncoder(msg.conn) m.enc = json.NewEncoder(msg.conn)
m.reader = msg.reader m.reader = msg.reader
m.status = "joining " + m.networkName + "…" cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGetState}), m.reader.next())
cmds = append(cmds, if m.initialNetwork != "" {
sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: m.networkName}), m.status = "joining " + m.initialNetwork + "…"
sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGetState}), cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: m.initialNetwork}))
m.reader.next(), } else {
) m.status = "connected — /join <network> to start"
}
case ipcLineMsg: case ipcLineMsg:
var evt proto.IpcMessage var evt proto.IpcMessage
@@ -211,18 +311,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit return m, tea.Quit
case msg.String() == "ctrl+i": case msg.String() == "ctrl+i":
if m.enc != nil { if m.enc != nil {
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGenerateInvite})) cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{
Type: proto.CmdGenerateInvite,
NetworkID: m.activeNetworkID(),
}))
}
case msg.String() == "ctrl+n":
if len(m.nets) > 1 {
m.activeNet = (m.activeNet + 1) % len(m.nets)
m = m.refreshViewport()
} }
case msg.Type == tea.KeyEnter: case msg.Type == tea.KeyEnter:
m, cmds = m.doSend(cmds) m, cmds = m.doSend(cmds)
case msg.Type == tea.KeyTab: case msg.Type == tea.KeyTab:
m.activeRoom = (m.activeRoom + 1) % len(m.rooms) if n := m.activeNetData(); n != nil {
delete(m.unread, m.activeRoomName()) n.activeRoom = (n.activeRoom + 1) % len(n.rooms)
delete(m.unread, m.msgKey())
m = m.refreshViewport() m = m.refreshViewport()
}
case msg.Type == tea.KeyShiftTab: case msg.Type == tea.KeyShiftTab:
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms) if n := m.activeNetData(); n != nil {
delete(m.unread, m.activeRoomName()) n.activeRoom = (n.activeRoom - 1 + len(n.rooms)) % len(n.rooms)
delete(m.unread, m.msgKey())
m = m.refreshViewport() m = m.refreshViewport()
}
default: default:
var tiCmd tea.Cmd var tiCmd tea.Cmd
m.input, tiCmd = m.input.Update(msg) m.input, tiCmd = m.input.Update(msg)
@@ -230,7 +342,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
default: default:
// Let viewport handle scroll events.
if m.vpReady { if m.vpReady {
var vpCmd tea.Cmd var vpCmd tea.Cmd
m.viewport, vpCmd = m.viewport.Update(msg) m.viewport, vpCmd = m.viewport.Update(msg)
@@ -241,56 +352,106 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
// ── applyEvent ────────────────────────────────────────────────────────────────
func (m model) applyEvent(evt proto.IpcMessage) model { func (m model) applyEvent(evt proto.IpcMessage) model {
switch evt.Type { switch evt.Type {
case proto.EvtStateSnapshot: case proto.EvtStateSnapshot:
if evt.LocalPeer != nil { // Populate nets from snapshot.
m.localID = evt.LocalPeer.ID for _, ni := range evt.Networks {
m.localAlias = evt.LocalPeer.Alias n := m.netByID(ni.NetworkID)
if n == nil {
n = newNetData(ni.NetworkID, ni.NetworkName)
m.nets = append(m.nets, n)
} }
m.peers = make(map[proto.PeerID]string) if ni.LocalPeer != nil {
m.peerOrder = nil n.localID = ni.LocalPeer.ID
n.localAlias = ni.LocalPeer.Alias
}
}
// Backward-compat: connected_peers and rooms are from first network.
if len(evt.Networks) > 0 && len(m.nets) > 0 {
n := m.nets[0]
for _, p := range evt.ConnectedPeers { for _, p := range evt.ConnectedPeers {
m.peers[p.ID] = p.Alias if _, ok := n.peers[p.ID]; !ok {
m.peerOrder = append(m.peerOrder, p.ID) n.peerOrder = append(n.peerOrder, p.ID)
}
n.peers[p.ID] = p.Alias
} }
for _, r := range evt.Rooms { for _, r := range evt.Rooms {
m = m.addRoom(r) n.addRoom(r)
} }
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName) for _, p := range evt.KnownPeers {
n.knownPeers[p.ID] = p.Alias
case proto.EvtRoomCreated: }
m = m.addRoom(evt.Room) }
m = m.updateStatus()
m = m.refreshViewport() m = m.refreshViewport()
case proto.EvtNetworkJoined:
n := m.netByID(evt.NetworkID)
if n == nil {
name := evt.NetworkName
if name == "" {
name = evt.NetworkID
}
n = newNetData(evt.NetworkID, name)
m.nets = append(m.nets, n)
m.activeNet = len(m.nets) - 1
}
if evt.LocalPeer != nil {
n.localID = evt.LocalPeer.ID
n.localAlias = evt.LocalPeer.Alias
}
m = m.updateStatus()
m = m.refreshViewport()
case proto.EvtRoomCreated:
if n := m.netByID(evt.NetworkID); n != nil {
n.addRoom(evt.Room)
m = m.refreshViewport()
}
case proto.EvtSessionReady: case proto.EvtSessionReady:
if evt.PeerID != nil { netID := evt.NetworkID
if netID == "" && len(m.nets) > 0 {
netID = m.nets[0].id
}
if n := m.netByID(netID); n != nil && evt.PeerID != nil {
pid := *evt.PeerID pid := *evt.PeerID
if _, ok := m.peers[pid]; !ok { if _, ok := n.peers[pid]; !ok {
m.peerOrder = append(m.peerOrder, pid) n.peerOrder = append(n.peerOrder, pid)
} }
alias := evt.Nick alias := evt.Nick
if alias == "" { if alias == "" {
alias = shortID(pid) alias = shortID(pid)
} }
m.peers[pid] = alias n.peers[pid] = alias
} }
case proto.EvtPeerConnected: case proto.EvtPeerConnected:
if evt.Peer != nil { netID := evt.NetworkID
pid := evt.Peer.ID if netID == "" && len(m.nets) > 0 {
if _, ok := m.peers[pid]; !ok { netID = m.nets[0].id
m.peerOrder = append(m.peerOrder, pid)
} }
m.peers[pid] = evt.Peer.Alias if n := m.netByID(netID); n != nil && evt.Peer != nil {
pid := evt.Peer.ID
if _, ok := n.peers[pid]; !ok {
n.peerOrder = append(n.peerOrder, pid)
}
n.peers[pid] = evt.Peer.Alias
} }
case proto.EvtPeerDisconnected: case proto.EvtPeerDisconnected:
if evt.PeerID != nil { netID := evt.NetworkID
if netID == "" && len(m.nets) > 0 {
netID = m.nets[0].id
}
if n := m.netByID(netID); n != nil && evt.PeerID != nil {
pid := *evt.PeerID pid := *evt.PeerID
delete(m.peers, pid) delete(n.peers, pid)
m.peerOrder = filterIDs(m.peerOrder, pid) n.peerOrder = filterIDs(n.peerOrder, pid)
} }
case proto.EvtInviteGenerated: case proto.EvtInviteGenerated:
@@ -299,23 +460,111 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
case proto.EvtMessageReceived: case proto.EvtMessageReceived:
if evt.Message != nil { if evt.Message != nil {
msg := evt.Message msg := evt.Message
netID := evt.NetworkID
if netID == "" && len(m.nets) > 0 {
netID = m.nets[0].id
}
n := m.netByID(netID)
if n != nil {
n.addRoom(msg.Room)
}
e := entry{ e := entry{
from: m.aliasOf(msg.From), mid: msg.Mid,
from: m.aliasOf(netID, msg.From),
body: msg.Text, body: msg.Text,
at: time.UnixMilli(msg.Ts), at: time.UnixMilli(msg.Ts),
fromMe: msg.From == m.localID, fromMe: n != nil && msg.From == n.localID,
} }
m.messages[msg.Room] = append(m.messages[msg.Room], e) key := netID + ":" + msg.Room
m = m.addRoom(msg.Room) m.messages[key] = append(m.messages[key], e)
if msg.Room != m.activeRoomName() { if key != m.msgKey() {
m.unread[msg.Room] = true m.unread[key] = true
} }
m = m.refreshViewport() m = m.refreshViewport()
} }
case proto.EvtHistoryLoaded:
netID := evt.NetworkID
if netID == "" && len(m.nets) > 0 {
netID = m.nets[0].id
} }
n := m.netByID(netID)
key := netID + ":" + evt.Room
existing := m.messages[key]
existingMids := make(map[string]bool, len(existing))
for _, e := range existing {
if e.mid != "" {
existingMids[e.mid] = true
}
}
var fresh []entry
for _, msg := range evt.Messages {
if msg.Mid != "" && existingMids[msg.Mid] {
continue
}
fromMe := n != nil && msg.From == n.localID
fresh = append(fresh, entry{
mid: msg.Mid,
from: m.aliasOf(netID, msg.From),
body: msg.Text,
at: time.UnixMilli(msg.Ts),
fromMe: fromMe,
})
}
if len(fresh) > 0 {
// Prepend history, then existing live messages; sort by time.
merged := append(fresh, existing...)
// Simple insertion sort (lists are already mostly sorted).
for i := 1; i < len(merged); i++ {
for j := i; j > 0 && merged[j].at.Before(merged[j-1].at); j-- {
merged[j], merged[j-1] = merged[j-1], merged[j]
}
}
m.messages[key] = merged
if n != nil {
n.addRoom(evt.Room)
}
m = m.refreshViewport()
}
case proto.EvtReaction:
mid := evt.ReactionMID
emoji := evt.ReactionEmoji
if mid == "" || emoji == "" || evt.PeerID == nil {
break
}
netID := evt.NetworkID
if netID == "" && len(m.nets) > 0 {
netID = m.nets[0].id
}
alias := m.aliasOf(netID, *evt.PeerID)
if m.reactions[mid] == nil {
m.reactions[mid] = make(map[string][]string)
}
for _, a := range m.reactions[mid][emoji] {
if a == alias {
return m // already recorded
}
}
m.reactions[mid][emoji] = append(m.reactions[mid][emoji], alias)
m = m.refreshViewport()
}
return m return m
} }
func (m model) updateStatus() model {
n := m.activeNetData()
if n == nil {
m.status = "connected — /join <network> to start"
return m
}
m.status = fmt.Sprintf("● %s · %s", n.localAlias, n.name)
return m
}
// ── doSend ────────────────────────────────────────────────────────────────────
func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) { func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
body := strings.TrimSpace(m.input.Value()) body := strings.TrimSpace(m.input.Value())
if body == "" || m.enc == nil { if body == "" || m.enc == nil {
@@ -323,16 +572,106 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
} }
m.input.SetValue("") m.input.SetValue("")
if strings.HasPrefix(body, "/room ") { // /join <network-name>
name := strings.TrimSpace(strings.TrimPrefix(body, "/room ")) if strings.HasPrefix(body, "/join ") {
name := strings.TrimSpace(strings.TrimPrefix(body, "/join "))
if name != "" { if name != "" {
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdCreateRoom, Room: name})) cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: name}))
} }
return m, cmds return m, cmds
} }
room := m.rooms[m.activeRoom] // /net <number|name> — switch active network
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body} if strings.HasPrefix(body, "/net ") {
arg := strings.TrimSpace(strings.TrimPrefix(body, "/net "))
if n, err := strconv.Atoi(arg); err == nil {
idx := n - 1
if idx >= 0 && idx < len(m.nets) {
m.activeNet = idx
m = m.updateStatus()
m = m.refreshViewport()
}
} else {
for i, net := range m.nets {
if strings.EqualFold(net.name, arg) {
m.activeNet = i
m = m.updateStatus()
m = m.refreshViewport()
break
}
}
}
return m, cmds
}
// /room <name>
if strings.HasPrefix(body, "/room ") {
name := strings.TrimSpace(strings.TrimPrefix(body, "/room "))
if name != "" {
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{
Type: proto.CmdCreateRoom,
NetworkID: m.activeNetworkID(),
Room: name,
}))
}
return m, cmds
}
// /react [<n>] <emoji>
if strings.HasPrefix(body, "/react ") {
rest := strings.TrimSpace(strings.TrimPrefix(body, "/react "))
parts := strings.Fields(rest)
var targetIdx int = -1 // -1 = last message
var emoji string
switch len(parts) {
case 1:
emoji = parts[0]
case 2:
if n, err := strconv.Atoi(parts[0]); err == nil {
targetIdx = n - 1
} else {
emoji = parts[0] // fallback: treat first token as emoji
}
if emoji == "" {
emoji = parts[1]
}
}
if emoji == "" {
m.errMsg = "usage: /react <emoji> or /react <n> <emoji> (e.g. /react 👍 or /react 3 ❤️)"
return m, cmds
}
msgs := m.messages[m.msgKey()]
var targetMid string
if targetIdx == -1 && len(msgs) > 0 {
targetMid = msgs[len(msgs)-1].mid
} else if targetIdx >= 0 && targetIdx < len(msgs) {
targetMid = msgs[targetIdx].mid
}
if targetMid == "" {
m.errMsg = "usage: /react <emoji> or /react <n> <emoji> (e.g. /react 👍 or /react 3 ❤️)"
return m, cmds
}
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{
Type: proto.CmdSendReaction,
NetworkID: m.activeNetworkID(),
ReactionMID: targetMid,
ReactionEmoji: emoji,
}))
return m, cmds
}
// Regular message
n := m.activeNetData()
if n == nil {
return m, cmds
}
room := n.rooms[n.activeRoom]
ipcMsg := proto.IpcMessage{
Type: proto.CmdSendMessage,
NetworkID: n.id,
Room: room,
Body: body,
}
if strings.HasPrefix(room, "dm:") { if strings.HasPrefix(room, "dm:") {
recipID := proto.PeerID(strings.TrimPrefix(room, "dm:")) recipID := proto.PeerID(strings.TrimPrefix(room, "dm:"))
ipcMsg.To = &recipID ipcMsg.To = &recipID
@@ -343,9 +682,7 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
// ── layout helpers ──────────────────────────────────────────────────────────── // ── layout helpers ────────────────────────────────────────────────────────────
// vpContentWidth returns the inner width of the centre pane (available to the viewport).
func (m model) vpContentWidth() int { func (m model) vpContentWidth() int {
// Two sidebar boxes (sideW total each) + centre box (borders 2 = -2 from inner).
w := m.width - sideW*2 - 2 w := m.width - sideW*2 - 2
if w < 10 { if w < 10 {
w = 10 w = 10
@@ -353,10 +690,7 @@ func (m model) vpContentWidth() int {
return w return w
} }
// vpHeight returns the viewport height (lines of messages shown).
func (m model) vpHeight() int { func (m model) vpHeight() int {
// Full height minus: top border(1) + title(1) + divider(1) + bottom border(1) +
// input box (3 lines incl borders) + status bar(1) = 8 total overhead.
h := m.height - 8 h := m.height - 8
if h < 1 { if h < 1 {
h = 1 h = 1
@@ -379,10 +713,10 @@ func (m model) refreshViewport() model {
if !m.vpReady { if !m.vpReady {
return m return m
} }
room := m.activeRoomName() key := m.msgKey()
w := m.vpContentWidth()
var sb strings.Builder var sb strings.Builder
for _, e := range m.messages[room] { for i, e := range m.messages[key] {
lineNum := styleLineNum.Render(fmt.Sprintf("[%d]", i+1))
ts := styleMsgTime.Render(formatMsgTime(e.at)) ts := styleMsgTime.Render(formatMsgTime(e.at))
var from string var from string
if e.fromMe { if e.fromMe {
@@ -390,31 +724,28 @@ func (m model) refreshViewport() model {
} else { } else {
from = styleMsgFrom.Render(e.from) from = styleMsgFrom.Render(e.from)
} }
line := fmt.Sprintf("%s %s %s", ts, from, e.body) sb.WriteString(fmt.Sprintf("%s %s %s %s\n", lineNum, ts, from, e.body))
// Crude wrap: if line > w, just truncate (viewport handles horizontal scroll). if e.mid != "" {
_ = w if rxn := m.renderReactions(e.mid); rxn != "" {
sb.WriteString(line + "\n") sb.WriteString(rxn + "\n")
}
}
} }
m.viewport.SetContent(sb.String()) m.viewport.SetContent(sb.String())
m.viewport.GotoBottom() m.viewport.GotoBottom()
return m return m
} }
func (m model) activeRoomName() string { func (m model) renderReactions(mid string) string {
if m.activeRoom < len(m.rooms) { byEmoji := m.reactions[mid]
return m.rooms[m.activeRoom] if len(byEmoji) == 0 {
}
return "" return ""
}
func (m model) addRoom(room string) model {
for _, r := range m.rooms {
if r == room {
return m
} }
var parts []string
for emoji, froms := range byEmoji {
parts = append(parts, fmt.Sprintf("%s %d", emoji, len(froms)))
} }
m.rooms = append(m.rooms, room) return styleReaction.Render(" " + strings.Join(parts, " "))
return m
} }
// ── view ────────────────────────────────────────────────────────────────────── // ── view ──────────────────────────────────────────────────────────────────────
@@ -424,41 +755,33 @@ func (m model) View() string {
return "loading…\n" return "loading…\n"
} }
innerH := m.height - 3 - 1 // 3 = input box, 1 = status bar innerH := m.height - 3 - 1
if innerH < 4 { if innerH < 4 {
innerH = 4 innerH = 4
} }
// ── left: rooms ─────────────────────────────────────────────────────────── leftBox := m.renderLeft(innerH)
leftBox := m.renderRooms(innerH)
// ── right: peers ──────────────────────────────────────────────────────────
rightBox := m.renderPeers(innerH) rightBox := m.renderPeers(innerH)
// ── centre: title + messages ──────────────────────────────────────────────
centreBox := m.renderCentre(innerH) centreBox := m.renderCentre(innerH)
mainRow := lipgloss.JoinHorizontal(lipgloss.Top, leftBox, centreBox, rightBox) mainRow := lipgloss.JoinHorizontal(lipgloss.Top, leftBox, centreBox, rightBox)
// ── input ─────────────────────────────────────────────────────────────────
inputBox := lipgloss.NewStyle(). inputBox := lipgloss.NewStyle().
Width(m.width - 2). Width(m.width - 2).
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(styleBorder). BorderForeground(styleBorder).
Render(m.input.View()) Render(m.input.View())
// ── status bar ────────────────────────────────────────────────────────────
var statusLine string var statusLine string
if m.errMsg != "" { if m.errMsg != "" {
statusLine = styleErr.Render(" ✗ " + m.errMsg) statusLine = styleErr.Render(" ✗ " + m.errMsg)
} else { } else {
hint := " tab: rooms · /room <name>: new room · ctrl+i: invite · ctrl+c: quit" hint := " tab: rooms · ctrl+n: nets · /join /net /room /react · ctrl+i: invite · ctrl+c: quit"
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint) statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
} }
view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine) view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine)
// ── invite popup (full-screen overlay) ───────────────────────────────────
if m.invitePopup != "" { if m.invitePopup != "" {
label := styleActive.Render("Invite — share this with anyone you want to add:") label := styleActive.Render("Invite — share this with anyone you want to add:")
code := lipgloss.NewStyle(). code := lipgloss.NewStyle().
@@ -479,24 +802,51 @@ func (m model) View() string {
return view return view
} }
func (m model) renderRooms(boxH int) string { func (m model) renderLeft(boxH int) string {
innerW := sideW - 2 innerW := sideW - 2
contentH := boxH - 2 // subtract top+bottom border contentH := boxH - 2
sep := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))
var lines []string var lines []string
// Networks section
lines = append(lines, styleHeader.Width(innerW).Render("Networks"))
lines = append(lines, sep)
if len(m.nets) == 0 {
lines = append(lines, styleRoom.Width(innerW).Render(" (none)"))
}
for i, n := range m.nets {
label := n.name
if i == m.activeNet {
lines = append(lines, styleNetActive.Width(innerW).Render("▶ "+label))
} else {
lines = append(lines, styleNet.Width(innerW).Render(fmt.Sprintf(" [%d] %s", i+1, label)))
}
}
lines = append(lines, sep)
// Rooms section
lines = append(lines, styleHeader.Width(innerW).Render("Rooms")) lines = append(lines, styleHeader.Width(innerW).Render("Rooms"))
lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))) lines = append(lines, sep)
for i, room := range m.rooms { n := m.activeNetData()
label := roomLabel(room, m.peers) if n == nil {
if i == m.activeRoom { lines = append(lines, styleRoom.Width(innerW).Render(" (no network)"))
} else {
for i, room := range n.rooms {
label := roomLabel(room, n.peers)
key := n.id + ":" + room
if i == n.activeRoom {
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label)) lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
} else { } else {
prefix := " " prefix := " "
if m.unread[room] { if m.unread[key] {
prefix = "* " prefix = "* "
} }
lines = append(lines, styleRoom.Width(innerW).Render(prefix+label)) lines = append(lines, styleRoom.Width(innerW).Render(prefix+label))
} }
} }
}
for len(lines) < contentH { for len(lines) < contentH {
lines = append(lines, strings.Repeat(" ", innerW)) lines = append(lines, strings.Repeat(" ", innerW))
} }
@@ -513,17 +863,19 @@ func (m model) renderPeers(boxH int) string {
var lines []string var lines []string
lines = append(lines, styleHeader.Width(innerW).Render("Peers")) lines = append(lines, styleHeader.Width(innerW).Render("Peers"))
lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))) lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)))
// Local peer first n := m.activeNetData()
if m.localAlias != "" { if n != nil {
lines = append(lines, styleSelf.Width(innerW).Render("◉ "+m.localAlias+" (me)")) if n.localAlias != "" {
lines = append(lines, styleSelf.Width(innerW).Render("◉ "+n.localAlias+" (me)"))
} }
for _, pid := range m.peerOrder { for _, pid := range n.peerOrder {
alias := m.peers[pid] alias := n.peers[pid]
if alias == "" { if alias == "" {
alias = shortID(pid) alias = shortID(pid)
} }
lines = append(lines, stylePeer.Width(innerW).Render("● "+alias)) lines = append(lines, stylePeer.Width(innerW).Render("● "+alias))
} }
}
for len(lines) < contentH { for len(lines) < contentH {
lines = append(lines, strings.Repeat(" ", innerW)) lines = append(lines, strings.Repeat(" ", innerW))
} }
@@ -536,8 +888,18 @@ func (m model) renderPeers(boxH int) string {
func (m model) renderCentre(boxH int) string { func (m model) renderCentre(boxH int) string {
innerW := m.vpContentWidth() innerW := m.vpContentWidth()
room := m.activeRoomName() n := m.activeNetData()
title := styleTitle.Width(innerW).Render(" " + roomTitle(room, m.peers)) var roomName string
if n != nil {
roomName = n.rooms[n.activeRoom]
} else {
roomName = "general"
}
var peerMap map[proto.PeerID]string
if n != nil {
peerMap = n.peers
}
title := styleTitle.Width(innerW).Render(" " + roomTitle(roomName, peerMap))
divider := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)) divider := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))
vpView := "" vpView := ""
@@ -546,7 +908,6 @@ func (m model) renderCentre(boxH int) string {
} }
content := lipgloss.JoinVertical(lipgloss.Left, title, divider, vpView) content := lipgloss.JoinVertical(lipgloss.Left, title, divider, vpView)
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Width(innerW).Height(boxH - 2). Width(innerW).Height(boxH - 2).
Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder). Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder).
@@ -555,19 +916,6 @@ func (m model) renderCentre(boxH int) string {
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
func (m model) aliasOf(id proto.PeerID) string {
if id == m.localID {
if m.localAlias != "" {
return m.localAlias
}
return "me"
}
if a, ok := m.peers[id]; ok && a != "" {
return a
}
return shortID(id)
}
func shortID(id proto.PeerID) string { func shortID(id proto.PeerID) string {
s := string(id) s := string(id)
if len(s) > 8 { if len(s) > 8 {
@@ -582,26 +930,18 @@ func roomLabel(room string, peers map[proto.PeerID]string) string {
} }
if strings.HasPrefix(room, "dm:") { if strings.HasPrefix(room, "dm:") {
pid := proto.PeerID(strings.TrimPrefix(room, "dm:")) pid := proto.PeerID(strings.TrimPrefix(room, "dm:"))
if peers != nil {
if a, ok := peers[pid]; ok && a != "" { if a, ok := peers[pid]; ok && a != "" {
return "@ " + a return "@ " + a
} }
}
return "@ " + shortID(pid) return "@ " + shortID(pid)
} }
return "#" + room return "#" + room
} }
func roomTitle(room string, peers map[proto.PeerID]string) string { func roomTitle(room string, peers map[proto.PeerID]string) string {
if room == "general" { return roomLabel(room, peers)
return "#general"
}
if strings.HasPrefix(room, "dm:") {
pid := proto.PeerID(strings.TrimPrefix(room, "dm:"))
if a, ok := peers[pid]; ok && a != "" {
return "@ " + a
}
return "@ " + shortID(pid)
}
return "#" + room
} }
func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID { func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID {
@@ -619,9 +959,6 @@ func formatMsgTime(t time.Time) string {
if t.Year() == now.Year() && t.YearDay() == now.YearDay() { if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04") return t.Format("15:04")
} }
if t.Year() == now.Year() && t.YearDay() == now.YearDay()-1 {
return "Yesterday " + t.Format("15:04")
}
return t.Format("Jan 2 15:04") return t.Format("Jan 2 15:04")
} }
@@ -636,11 +973,10 @@ func min(a, b int) int {
func main() { func main() {
ipcPort := flag.Int("ipc", 17337, "daemon IPC port") ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
network := flag.String("network", "", "network name to join on startup") network := flag.String("network", "", "network name to join on startup (optional)")
joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name") joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name")
flag.Parse() flag.Parse()
// --join overrides --network.
if *joinInvite != "" { if *joinInvite != "" {
inv, err := invite.Decode(*joinInvite) inv, err := invite.Decode(*joinInvite)
if err != nil { if err != nil {
@@ -650,12 +986,6 @@ func main() {
*network = inv.Network *network = inv.Network
} }
if *network == "" {
fmt.Fprintln(os.Stderr, "error: -network or -join is required")
flag.Usage()
os.Exit(1)
}
p := tea.NewProgram( p := tea.NewProgram(
newModel(*ipcPort, *network), newModel(*ipcPort, *network),
tea.WithAltScreen(), tea.WithAltScreen(),

32
deploy-web.sh.example Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# deploy-web.sh — build and push the web UI to the VPS.
# Assumes SSH agent forwarding is set up.
#
# SETUP: copy this file to deploy-web.sh (gitignored) and set HOST below.
#
# Usage:
# ./deploy-web.sh
#
# Optional env vars:
# HOST SSH target (user@host) (required — edit below)
# REMOTE_DIR path on VPS (default: ~/waste-www)
set -euo pipefail
HOST="${HOST:-user@YOUR_VPS_IP}" # ← edit this
REMOTE_DIR="${REMOTE_DIR:-~/waste-www}"
if [[ "$HOST" == *YOUR_VPS_IP* ]]; then
echo "error: edit HOST in this script (or export HOST=user@your-vps before running)" >&2
exit 1
fi
echo "→ building web UI…"
"$(dirname "$0")/build-web.sh"
echo "→ syncing to $HOST:$REMOTE_DIR"
rsync -azv --delete \
--exclude='config.js' \
web/dist/ "$HOST:$REMOTE_DIR/"
echo "✓ done"

View File

@@ -248,6 +248,8 @@ const (
AnchorTo AnchorMsgType = "to" AnchorTo AnchorMsgType = "to"
AnchorFrom AnchorMsgType = "from" AnchorFrom AnchorMsgType = "from"
AnchorNoPeer AnchorMsgType = "no-peer" AnchorNoPeer AnchorMsgType = "no-peer"
AnchorPresenceQuery AnchorMsgType = "presence_query" // waste-go ext EXT-009
AnchorPresence AnchorMsgType = "presence" // waste-go ext EXT-009
) )
// AnchorMessage covers all WebSocket frames to/from the anchor. // AnchorMessage covers all WebSocket frames to/from the anchor.
@@ -261,6 +263,7 @@ type AnchorMessage struct {
To string `json:"to,omitempty"` // target peer hex id To string `json:"to,omitempty"` // target peer hex id
From string `json:"from,omitempty"` // sender peer hex id From string `json:"from,omitempty"` // sender peer hex id
Box string `json:"box,omitempty"` // base64 nacl/box sealed payload Box string `json:"box,omitempty"` // base64 nacl/box sealed payload
Online *bool `json:"online,omitempty"` // presence: true iff (net, id) is currently connected
} }
// ── IPC protocol (daemon ↔ local UI) ───────────────────────────────────────── // ── IPC protocol (daemon ↔ local UI) ─────────────────────────────────────────

96
launch-tui.sh.example Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# launch-tui.sh — build and launch the TUI against a remote anchor.
# Starts a local daemon then opens the Bubble Tea terminal UI.
#
# SETUP: copy this file to launch-tui.sh (gitignored) and set ANCHOR below.
#
# Usage:
# ./launch-tui.sh
# ALIAS=alice NETWORK=friends ./launch-tui.sh
#
# Optional env vars (all have defaults):
# ANCHOR anchor WebSocket URL (required — edit below)
# NETWORK network name to join (default: "friends")
# ALIAS display name (default: $USER)
# DATA_DIR identity + message store dir (default: ~/.waste-$ALIAS)
# IPC_PORT local daemon IPC port (default: 17337)
# SHARE_DIR directory to share with peers (optional)
set -euo pipefail
ANCHOR="${ANCHOR:-wss://YOUR_ANCHOR_DOMAIN/ws}" # ← edit this
NETWORK="${NETWORK:-friends}"
ALIAS="${ALIAS:-${USER:-anon}}"
DATA_DIR="${DATA_DIR:-${HOME}/.waste-${ALIAS}}"
_DEFAULT_ALIAS="${USER:-anon}"
if [ "${ALIAS}" = "${_DEFAULT_ALIAS}" ]; then
IPC_PORT="${IPC_PORT:-17337}"
else
_HASH=$(printf '%d' "0x$(printf '%s' "$ALIAS" | md5sum | cut -c1-4)")
IPC_PORT="${IPC_PORT:-$(( 17400 + _HASH % 1000 ))}"
fi
SHARE_DIR="${SHARE_DIR:-}"
RED='\033[0;31m'; GREEN='\033[0;32m'; DIM='\033[2m'; BOLD='\033[1m'; RESET='\033[0m'
if [[ "$ANCHOR" == *YOUR_ANCHOR_DOMAIN* ]]; then
echo -e "${RED}error: edit ANCHOR in this script (or export ANCHOR=wss://... before running)${RESET}" >&2
exit 1
fi
mkdir -p "$DATA_DIR"
echo ""
echo -e "${BOLD}waste TUI${RESET}"
echo -e "${DIM}anchor : ${BOLD}${ANCHOR}${RESET}"
echo -e "${DIM}network : ${BOLD}${NETWORK}${RESET}"
echo -e "${DIM}alias : ${BOLD}${ALIAS}${RESET}"
echo -e "${DIM}data : ${DATA_DIR}${RESET}"
[ -n "$SHARE_DIR" ] && echo -e "${DIM}share : ${SHARE_DIR}${RESET}"
echo ""
echo -e "${DIM}building binaries…${RESET}"
go build -o /tmp/waste-daemon-run ./cmd/daemon
go build -o /tmp/waste-tui-run ./cmd/tui
echo -e "${GREEN}✓ built${RESET}"
echo ""
existing=$(lsof -ti tcp:"$IPC_PORT" 2>/dev/null || true)
[ -n "$existing" ] && kill "$existing" 2>/dev/null && sleep 0.3 || true
echo -e "${DIM}starting daemon on :${IPC_PORT}…${RESET}"
WS_PORT=$(( IPC_PORT + 1 ))
/tmp/waste-daemon-run \
-alias "$ALIAS" -data-dir "$DATA_DIR" \
-ipc-port "$IPC_PORT" -ws-port "$WS_PORT" \
-anchor "$ANCHOR" \
2>/tmp/waste-daemon.log &
DAEMON_PID=$!
n=0
while ! nc -z 127.0.0.1 "$IPC_PORT" 2>/dev/null; do
sleep 0.1; n=$(( n + 1 ))
[ "$n" -gt 80 ] && echo -e "${RED}daemon failed — check /tmp/waste-daemon.log${RESET}" >&2 && exit 1
done
echo -e "${GREEN}✓ daemon started (pid ${DAEMON_PID})${RESET}"
sleep 0.3
if [ -n "$SHARE_DIR" ]; then
JOIN=$(jq -cn --arg net "$NETWORK" --arg dir "$SHARE_DIR" \
'{"type":"join_network","network_name":$net,"share_dir":$dir}')
else
JOIN=$(jq -cn --arg net "$NETWORK" '{"type":"join_network","network_name":$net}')
fi
echo "$JOIN" | nc -q 0 127.0.0.1 "$IPC_PORT" >/dev/null 2>&1 || true
echo -e "${DIM}joined network: ${BOLD}${NETWORK}${RESET}"
cleanup() { kill "$DAEMON_PID" 2>/dev/null || true; }
trap cleanup EXIT INT TERM
echo ""
echo -e "${BOLD}launching TUI${RESET} — ${DIM}ctrl+c to quit${RESET}"
echo ""
exec /tmp/waste-tui-run -ipc "$IPC_PORT" -network "$NETWORK"

74
launch-web.sh.example Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# launch-web.sh — start the daemon and open the web UI Vite dev server.
# For local development / daemon-mode browsing.
#
# SETUP: copy this file to launch-web.sh (gitignored) and set ANCHOR below.
#
# Usage:
# ./launch-web.sh
# ALIAS=alice NETWORK=friends ./launch-web.sh
set -euo pipefail
ANCHOR="${ANCHOR:-wss://YOUR_ANCHOR_DOMAIN/ws}" # ← edit this
NETWORK="${NETWORK:-friends}"
ALIAS="${ALIAS:-${USER:-anon}}"
DATA_DIR="${DATA_DIR:-${HOME}/.waste-${ALIAS}}"
IPC_PORT="${IPC_PORT:-17337}"
WS_PORT=$(( IPC_PORT + 1 ))
if [[ "$ANCHOR" == *YOUR_ANCHOR_DOMAIN* ]]; then
echo "error: edit ANCHOR in this script (or export ANCHOR=wss://... before running)" >&2
exit 1
fi
PIDS=()
cleanup() {
for pid in "${PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done
wait 2>/dev/null || true
}
trap cleanup EXIT INT TERM
for port in "$IPC_PORT" "$WS_PORT"; do
existing=$(lsof -ti tcp:"$port" 2>/dev/null || true)
[ -n "$existing" ] && kill "$existing" 2>/dev/null || true
done
for port in "$IPC_PORT" "$WS_PORT"; do
n=0
while lsof -ti tcp:"$port" >/dev/null 2>&1; do
sleep 0.1; n=$(( n + 1 )); [ "$n" -gt 30 ] && break
done
done
mkdir -p "$DATA_DIR"
echo "alias : $ALIAS"
echo "network : $NETWORK"
echo "anchor : $ANCHOR"
echo "ws-port : $WS_PORT"
echo ""
echo "building daemon…"
go build -o /tmp/waste-daemon-web ./cmd/daemon
/tmp/waste-daemon-web \
-alias "$ALIAS" -data-dir "$DATA_DIR" \
-ipc-port "$IPC_PORT" -ws-port "$WS_PORT" \
-anchor "$ANCHOR" \
2>/tmp/waste-daemon-web.log &
PIDS+=($!)
n=0
while ! nc -z 127.0.0.1 "$IPC_PORT" 2>/dev/null; do
sleep 0.1; n=$(( n + 1 ))
[ "$n" -gt 80 ] && echo "daemon failed to start — check /tmp/waste-daemon-web.log" >&2 && exit 1
done
sleep 0.2
jq -cn --arg net "$NETWORK" '{"type":"join_network","network_name":$net}' \
| nc -q0 127.0.0.1 "$IPC_PORT" >/dev/null 2>&1 || true
echo "daemon ready — joined $NETWORK"
echo ""
npm run dev --prefix "$(dirname "$0")/web"

38
serve-web.sh.example Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# serve-web.sh — start (or restart) the static file server on the VPS.
# Runs `npx serve` in the background, logs to ~/waste-www.log.
#
# SETUP: copy this file to serve-web.sh (gitignored) and set HOST below.
#
# Usage:
# ./serve-web.sh
#
# Optional env vars:
# HOST SSH target (user@host) (required — edit below)
# REMOTE_DIR path on VPS (default: ~/waste-www)
# PORT local port on VPS (default: 1337)
set -euo pipefail
HOST="${HOST:-user@YOUR_VPS_IP}" # ← edit this
REMOTE_DIR="${REMOTE_DIR:-~/waste-www}"
REMOTE_LOG="~/waste-www.log"
REMOTE_PID="~/waste-www.pid"
PORT="${PORT:-1337}"
if [[ "$HOST" == *YOUR_VPS_IP* ]]; then
echo "error: edit HOST in this script (or export HOST=user@your-vps before running)" >&2
exit 1
fi
ssh "$HOST" bash <<EOF
if [ -f $REMOTE_PID ]; then
kill \$(cat $REMOTE_PID) 2>/dev/null || true
rm -f $REMOTE_PID
fi
echo "[\$(date)] starting npx serve on port $PORT" >> $REMOTE_LOG
nohup npx serve -s $REMOTE_DIR -l $PORT >> $REMOTE_LOG 2>&1 &
echo \$! > $REMOTE_PID
echo "→ started (pid \$(cat $REMOTE_PID)), logging to $REMOTE_LOG"
EOF

View File

@@ -4,7 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#863bff" /> <meta name="theme-color" content="#00e87a" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

View File

@@ -4,8 +4,8 @@
"description": "Decentralized friend-to-friend encrypted mesh networking", "description": "Decentralized friend-to-friend encrypted mesh networking",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#0d0d0d", "background_color": "#080808",
"theme_color": "#863bff", "theme_color": "#00e87a",
"icons": [ "icons": [
{ {
"src": "/icon-192.png", "src": "/icon-192.png",

View File

@@ -1,26 +1,33 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--bg: #0f0f13; --bg: #080808;
--surface: #1a1a22; --surface: #0e0e0e;
--border: #2a2a36; --border: rgba(255, 255, 255, 0.09);
--accent: #7c6af7; --accent: #00e87a;
--text: #e0e0e8; --accent-dim: rgba(0, 232, 122, 0.12);
--muted: #6b6b80; --accent-border: rgba(0, 232, 122, 0.3);
--text: #c8c8c8;
--heading: #f0f0f0;
--muted: #666;
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
--sidebar-w: 220px; --sidebar-w: 220px;
} }
html, body, #root { height: 100%; } html, body, #root { height: 100%; }
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; font-size: 14px; } body { background: var(--bg); color: var(--text); font-family: var(--mono); font-size: 14px; -webkit-font-smoothing: antialiased; }
button { cursor: pointer; background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 4px 10px; font-size: 13px; } button { cursor: pointer; background: var(--accent); color: #000; border: 1px solid var(--accent); border-radius: 6px; padding: 4px 10px; font-size: 13px; font-family: var(--mono); font-weight: 600; transition: all 0.15s; }
button:hover:not(:disabled) { background: #00ff88; border-color: #00ff88; }
button:active:not(:disabled) { transform: scale(0.98); }
button:disabled { opacity: 0.4; cursor: default; } button:disabled { opacity: 0.4; cursor: default; }
input { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 6px 10px; font-size: 13px; outline: none; width: 100%; } input { background: var(--surface); color: var(--heading); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; font-size: 13px; font-family: var(--mono); outline: none; width: 100%; }
input:focus { border-color: var(--accent); } input:focus { border-color: var(--accent-border); }
/* ── onboarding ── */ /* ── onboarding ── */
.onboarding { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; max-width: 380px; margin: 0 auto; padding: 2rem; } .onboarding { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; max-width: 380px; margin: 0 auto; padding: 2rem; }
.onboarding h1 { font-size: 2rem; letter-spacing: 0.1em; color: var(--accent); margin-bottom: 4px; } .onboarding h1 { font-family: var(--mono); font-size: 2rem; font-weight: 700; letter-spacing: -0.5px; color: var(--heading); margin-bottom: 4px; }
.onboarding h1::before { content: '> '; color: var(--muted); font-weight: 400; }
.onboarding .status { color: var(--muted); font-size: 13px; } .onboarding .status { color: var(--muted); font-size: 13px; }
.onboarding .status.connecting { color: var(--accent); } .onboarding .status.connecting { color: var(--accent); }
.onboarding .status.disconnected { color: #e06060; } .onboarding .status.disconnected { color: #e06060; }
@@ -69,7 +76,7 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
.sidebar-new-room input { font-size: 12px; padding: 4px 8px; } .sidebar-new-room input { font-size: 12px; padding: 4px 8px; }
.sidebar-item { background: none; color: var(--text); text-align: left; padding: 5px 12px; border-radius: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } .sidebar-item { background: none; color: var(--text); text-align: left; padding: 5px 12px; border-radius: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.sidebar-item:hover { background: rgba(255,255,255,0.04); } .sidebar-item:hover { background: rgba(255,255,255,0.04); }
.sidebar-item.active { background: var(--accent); color: #fff; } .sidebar-item.active { background: var(--accent); color: #000; font-weight: 600; }
.sidebar-empty { font-size: 11px; color: var(--muted); padding: 4px 12px; } .sidebar-empty { font-size: 11px; color: var(--muted); padding: 4px 12px; }
/* peer rows in sidebar */ /* peer rows in sidebar */
@@ -127,7 +134,7 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
.file-entry-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .file-entry-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-entry-size { color: var(--muted); font-size: 11px; flex-shrink: 0; } .file-entry-size { color: var(--muted); font-size: 11px; flex-shrink: 0; }
.file-entry-dl { background: none; color: var(--accent); font-size: 14px; padding: 1px 4px; flex-shrink: 0; } .file-entry-dl { background: none; color: var(--accent); font-size: 14px; padding: 1px 4px; flex-shrink: 0; }
.file-entry-dl:hover { background: rgba(124,106,247,0.15); } .file-entry-dl:hover { background: var(--accent-dim); }
/* ── share manager ── */ /* ── share manager ── */
.share-manager { width: 100%; display: flex; flex-direction: column; gap: 4px; } .share-manager { width: 100%; display: flex; flex-direction: column; gap: 4px; }
@@ -179,11 +186,11 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
.reaction-add:hover { color: var(--accent); background: none; } .reaction-add:hover { color: var(--accent); background: none; }
.reaction-picker { display: flex; gap: 4px; padding: 4px 16px 2px; } .reaction-picker { display: flex; gap: 4px; padding: 4px 16px 2px; }
.reaction-picker-btn { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-size: 18px; padding: 2px 6px; line-height: 1.4; cursor: pointer; } .reaction-picker-btn { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-size: 18px; padding: 2px 6px; line-height: 1.4; cursor: pointer; }
.reaction-picker-btn:hover { border-color: var(--accent); background: rgba(124,106,247,0.12); } .reaction-picker-btn:hover { border-color: var(--accent); background: var(--accent-dim); }
.reaction-bar { display: flex; flex-wrap: wrap; gap: 4px; padding: 2px 16px 4px; } .reaction-bar { display: flex; flex-wrap: wrap; gap: 4px; padding: 2px 16px 4px; }
.reaction-chip { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; font-size: 13px; padding: 1px 8px; cursor: pointer; color: var(--text); } .reaction-chip { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; font-size: 13px; padding: 1px 8px; cursor: pointer; color: var(--text); }
.reaction-chip:hover { border-color: var(--accent); background: rgba(124,106,247,0.1); } .reaction-chip:hover { border-color: var(--accent); background: rgba(0,232,122,0.1); }
.reaction-chip.mine { border-color: var(--accent); background: rgba(124,106,247,0.18); } .reaction-chip.mine { border-color: var(--accent); background: rgba(0,232,122,0.18); }
/* ── mobile hamburger / close buttons ── */ /* ── mobile hamburger / close buttons ── */
.menu-btn-mobile { display: none; background: none; color: var(--muted); font-size: 18px; padding: 0 8px 0 0; line-height: 1; } .menu-btn-mobile { display: none; background: none; color: var(--muted); font-size: 18px; padding: 0 8px 0 0; line-height: 1; }

View File

@@ -16,23 +16,31 @@ const EKEY_PREFIX = 'yaw/2.1 ekey'
const FS_TIMEOUT = 2000 const FS_TIMEOUT = 2000
const STUN = 'stun:stun.l.google.com:19302' const STUN = 'stun:stun.l.google.com:19302'
async function turnCredential(secret: string, username: string): Promise<string> { // TURN credentials are short-lived and minted server-side by the anchor's
const key = await crypto.subtle.importKey( // GET /turn-credentials endpoint (see cmd/anchor). The shared coturn secret
'raw', new TextEncoder().encode(secret), // never reaches the browser — only a time-limited username/credential pair.
{ name: 'HMAC', hash: 'SHA-1' }, async function fetchTurnCredentials(credentialsURL: string): Promise<{ username: string; credential: string } | null> {
false, ['sign'] try {
) const res = await fetch(credentialsURL)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(username)) if (!res.ok) return null
return btoa(String.fromCharCode(...new Uint8Array(sig))) const data = await res.json()
if (!data.username || !data.credential) return null
return { username: data.username, credential: data.credential }
} catch {
return null
}
} }
async function iceServers(): Promise<RTCIceServer[]> { async function iceServers(): Promise<RTCIceServer[]> {
const cfg = (window as unknown as { WASTE_CONFIG?: { turnURL?: string; turnSecret?: string } }).WASTE_CONFIG const cfg = (window as unknown as { WASTE_CONFIG?: { turnURL?: string; turnCredentialsURL?: string; signalURL?: string } }).WASTE_CONFIG
const servers: RTCIceServer[] = [{ urls: STUN }] const servers: RTCIceServer[] = [{ urls: STUN }]
if (cfg?.turnURL && cfg?.turnSecret) { if (cfg?.turnURL) {
const user = Math.floor(Date.now() / 1000) + 3600 + ':waste' const credentialsURL = cfg.turnCredentialsURL
const credential = await turnCredential(cfg.turnSecret, user) ?? cfg.signalURL?.replace(/^ws/, 'http').replace(/\/ws\/?$/, '/turn-credentials')
servers.push({ urls: cfg.turnURL, username: user, credential }) if (credentialsURL) {
const creds = await fetchTurnCredentials(credentialsURL)
if (creds) servers.push({ urls: cfg.turnURL, username: creds.username, credential: creds.credential })
}
} }
return servers return servers
} }

View File

@@ -1,23 +1,23 @@
:root { :root {
--text: #6b6375; --text: #c8c8c8;
--text-h: #08060d; --text-h: #f0f0f0;
--bg: #fff; --bg: #080808;
--border: #e5e4e7; --border: rgba(255, 255, 255, 0.09);
--code-bg: #f4f3ec; --code-bg: #0e0e0e;
--accent: #aa3bff; --accent: #00e87a;
--accent-bg: rgba(170, 59, 255, 0.1); --accent-bg: rgba(0, 232, 122, 0.12);
--accent-border: rgba(170, 59, 255, 0.5); --accent-border: rgba(0, 232, 122, 0.3);
--social-bg: rgba(244, 243, 236, 0.5); --social-bg: rgba(14, 14, 14, 0.5);
--shadow: --shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; 0 0 0 1px rgba(255, 255, 255, 0.04), 0 4px 24px rgba(0, 0, 0, 0.6);
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: 'JetBrains Mono', ui-monospace, monospace;
--mono: ui-monospace, Consolas, monospace; --mono: 'JetBrains Mono', ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans); font: 18px/145% var(--sans);
letter-spacing: 0.18px; letter-spacing: 0.18px;
color-scheme: light dark; color-scheme: dark;
color: var(--text); color: var(--text);
background: var(--bg); background: var(--bg);
font-synthesis: none; font-synthesis: none;
@@ -30,24 +30,8 @@
} }
} }
@media (prefers-color-scheme: dark) { #social .button-icon {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2); filter: invert(1) brightness(2);
}
} }
#root { #root {