Compare commits
7 Commits
d09aa2b219
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8aeb45480 | ||
|
|
586d39e3b8 | ||
|
|
b649f4d012 | ||
|
|
697a7e614d | ||
|
|
d07342e97e | ||
|
|
f425e0bb8e | ||
|
|
bf4009558d |
@@ -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) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
116
EXTENSIONS.md
116
EXTENSIONS.md
@@ -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.
|
||||||
@@ -375,3 +379,109 @@ clients receive the full reaction state on reconnect.
|
|||||||
- Incoming `reaction` wire frames are dispatched as `reaction` IPC events.
|
- 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.
|
- `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.
|
- `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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
75
README.md
75
README.md
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
32
deploy-web.sh.example
Normal file
32
deploy-web.sh.example
Normal 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"
|
||||||
@@ -38,20 +38,20 @@ type PeerInfo struct {
|
|||||||
type MsgType string
|
type MsgType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MsgChat MsgType = "chat"
|
MsgChat MsgType = "chat"
|
||||||
MsgPm MsgType = "pm" // private message, §8
|
MsgPm MsgType = "pm" // private message, §8
|
||||||
MsgPeerGossip MsgType = "peer_gossip"
|
MsgPeerGossip MsgType = "peer_gossip"
|
||||||
MsgFileListReq MsgType = "file_list_req"
|
MsgFileListReq MsgType = "file_list_req"
|
||||||
MsgFileListResp MsgType = "file_list_resp"
|
MsgFileListResp MsgType = "file_list_resp"
|
||||||
MsgFileOffer MsgType = "file-offer" // §9, hyphenated per spec
|
MsgFileOffer MsgType = "file-offer" // §9, hyphenated per spec
|
||||||
MsgFileAccept MsgType = "file-accept"
|
MsgFileAccept MsgType = "file-accept"
|
||||||
MsgFileCancel MsgType = "file-cancel"
|
MsgFileCancel MsgType = "file-cancel"
|
||||||
MsgFileDone MsgType = "file-done"
|
MsgFileDone MsgType = "file-done"
|
||||||
MsgPing MsgType = "ping"
|
MsgPing MsgType = "ping"
|
||||||
MsgPong MsgType = "pong"
|
MsgPong MsgType = "pong"
|
||||||
MsgHistoryRequest MsgType = "history_request"
|
MsgHistoryRequest MsgType = "history_request"
|
||||||
MsgHistoryChunk MsgType = "history_chunk"
|
MsgHistoryChunk MsgType = "history_chunk"
|
||||||
MsgReaction MsgType = "reaction"
|
MsgReaction MsgType = "reaction"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PmMessage is a private message sent directly over a single peer link (§8 "pm").
|
// PmMessage is a private message sent directly over a single peer link (§8 "pm").
|
||||||
@@ -108,9 +108,9 @@ type PeerMessage struct {
|
|||||||
type ResumableFile struct {
|
type ResumableFile struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
SHA256 string `json:"sha256"`
|
SHA256 string `json:"sha256"`
|
||||||
From string `json:"from"` // peer ID hex
|
From string `json:"from"` // peer ID hex
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Offset int64 `json:"offset"` // bytes already received
|
Offset int64 `json:"offset"` // bytes already received
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryEntry is one message in a history_chunk response.
|
// HistoryEntry is one message in a history_chunk response.
|
||||||
@@ -125,10 +125,10 @@ type HistoryEntry struct {
|
|||||||
// ChatMessage is a group chat message (wire type "chat", §8).
|
// ChatMessage is a group chat message (wire type "chat", §8).
|
||||||
// Also used internally for persisting PMs after they are received.
|
// Also used internally for persisting PMs after they are received.
|
||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0)
|
Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0)
|
||||||
MsgID string `json:"msg_id,omitempty"` // EXT-007: content-addressed gossip ID
|
MsgID string `json:"msg_id,omitempty"` // EXT-007: content-addressed gossip ID
|
||||||
From PeerID `json:"from,omitempty"` // set by receiver from DC context; not on wire for pm
|
From PeerID `json:"from,omitempty"` // set by receiver from DC context; not on wire for pm
|
||||||
To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs
|
To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs
|
||||||
Room string `json:"room"`
|
Room string `json:"room"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Ts int64 `json:"ts"` // Unix milliseconds
|
Ts int64 `json:"ts"` // Unix milliseconds
|
||||||
@@ -240,27 +240,30 @@ type SignalingPayload struct {
|
|||||||
type AnchorMsgType string
|
type AnchorMsgType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AnchorChallenge AnchorMsgType = "challenge"
|
AnchorChallenge AnchorMsgType = "challenge"
|
||||||
AnchorJoin AnchorMsgType = "join"
|
AnchorJoin AnchorMsgType = "join"
|
||||||
AnchorJoined AnchorMsgType = "joined"
|
AnchorJoined AnchorMsgType = "joined"
|
||||||
AnchorPeerJoin AnchorMsgType = "peer-join"
|
AnchorPeerJoin AnchorMsgType = "peer-join"
|
||||||
AnchorPeerLeave AnchorMsgType = "peer-leave"
|
AnchorPeerLeave AnchorMsgType = "peer-leave"
|
||||||
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.
|
||||||
type AnchorMessage struct {
|
type AnchorMessage struct {
|
||||||
Type AnchorMsgType `json:"type"`
|
Type AnchorMsgType `json:"type"`
|
||||||
Nonce string `json:"nonce,omitempty"` // challenge nonce, hex
|
Nonce string `json:"nonce,omitempty"` // challenge nonce, hex
|
||||||
ID string `json:"id,omitempty"` // peer hex id
|
ID string `json:"id,omitempty"` // peer hex id
|
||||||
Net string `json:"net,omitempty"` // hashed network name
|
Net string `json:"net,omitempty"` // hashed network name
|
||||||
Sig string `json:"sig,omitempty"` // ed25519 sig over (nonce||net), hex
|
Sig string `json:"sig,omitempty"` // ed25519 sig over (nonce||net), hex
|
||||||
Peers []string `json:"peers,omitempty"` // joined: list of peer hex ids in network
|
Peers []string `json:"peers,omitempty"` // joined: list of peer hex ids in network
|
||||||
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) ─────────────────────────────────────────
|
||||||
@@ -270,45 +273,45 @@ type IpcMsgType string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Commands (UI → daemon)
|
// Commands (UI → daemon)
|
||||||
CmdSendMessage IpcMsgType = "send_message"
|
CmdSendMessage IpcMsgType = "send_message"
|
||||||
CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext)
|
CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext)
|
||||||
CmdLeaveNetwork IpcMsgType = "leave_network"
|
CmdLeaveNetwork IpcMsgType = "leave_network"
|
||||||
CmdGetState IpcMsgType = "get_state"
|
CmdGetState IpcMsgType = "get_state"
|
||||||
CmdSendFile IpcMsgType = "send_file"
|
CmdSendFile IpcMsgType = "send_file"
|
||||||
CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime
|
CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime
|
||||||
CmdGenerateInvite IpcMsgType = "generate_invite"
|
CmdGenerateInvite IpcMsgType = "generate_invite"
|
||||||
CmdGetFileList IpcMsgType = "get_file_list"
|
CmdGetFileList IpcMsgType = "get_file_list"
|
||||||
CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob
|
CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob
|
||||||
CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob
|
CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob
|
||||||
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
||||||
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
||||||
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
||||||
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
|
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
|
||||||
CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path
|
CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path
|
||||||
CmdSendReaction IpcMsgType = "send_reaction" // fields: network_id, reaction_mid, reaction_emoji
|
CmdSendReaction IpcMsgType = "send_reaction" // fields: network_id, reaction_mid, reaction_emoji
|
||||||
|
|
||||||
// Events (daemon → UI)
|
// Events (daemon → UI)
|
||||||
EvtMessageReceived IpcMsgType = "message_received"
|
EvtMessageReceived IpcMsgType = "message_received"
|
||||||
EvtPeerConnected IpcMsgType = "peer_connected"
|
EvtPeerConnected IpcMsgType = "peer_connected"
|
||||||
EvtPeerDisconnected IpcMsgType = "peer_disconnected"
|
EvtPeerDisconnected IpcMsgType = "peer_disconnected"
|
||||||
EvtSessionReady IpcMsgType = "session_ready" // DataChannel open + hello verified
|
EvtSessionReady IpcMsgType = "session_ready" // DataChannel open + hello verified
|
||||||
EvtPeerStatus IpcMsgType = "peer_status" // ICE connection state + candidate type
|
EvtPeerStatus IpcMsgType = "peer_status" // ICE connection state + candidate type
|
||||||
EvtIncomingFile IpcMsgType = "incoming_file"
|
EvtIncomingFile IpcMsgType = "incoming_file"
|
||||||
EvtFileProgress IpcMsgType = "file_progress"
|
EvtFileProgress IpcMsgType = "file_progress"
|
||||||
EvtStateSnapshot IpcMsgType = "state_snapshot"
|
EvtStateSnapshot IpcMsgType = "state_snapshot"
|
||||||
EvtError IpcMsgType = "error"
|
EvtError IpcMsgType = "error"
|
||||||
EvtInviteGenerated IpcMsgType = "invite_generated"
|
EvtInviteGenerated IpcMsgType = "invite_generated"
|
||||||
EvtFileList IpcMsgType = "file_list"
|
EvtFileList IpcMsgType = "file_list"
|
||||||
EvtFileComplete IpcMsgType = "file_complete"
|
EvtFileComplete IpcMsgType = "file_complete"
|
||||||
EvtNetworkJoined IpcMsgType = "network_joined"
|
EvtNetworkJoined IpcMsgType = "network_joined"
|
||||||
EvtNetworkLeft IpcMsgType = "network_left"
|
EvtNetworkLeft IpcMsgType = "network_left"
|
||||||
EvtIdentityExported IpcMsgType = "identity_exported"
|
EvtIdentityExported IpcMsgType = "identity_exported"
|
||||||
EvtIdentityImported IpcMsgType = "identity_imported"
|
EvtIdentityImported IpcMsgType = "identity_imported"
|
||||||
EvtSharesList IpcMsgType = "shares_list"
|
EvtSharesList IpcMsgType = "shares_list"
|
||||||
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
|
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
|
||||||
EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages
|
EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages
|
||||||
EvtResumableTransfers IpcMsgType = "resumable_transfers" // field: resumable_files
|
EvtResumableTransfers IpcMsgType = "resumable_transfers" // field: resumable_files
|
||||||
EvtReaction IpcMsgType = "reaction" // fields: reaction_mid, reaction_emoji, peer_id
|
EvtReaction IpcMsgType = "reaction" // fields: reaction_mid, reaction_emoji, peer_id
|
||||||
)
|
)
|
||||||
|
|
||||||
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
||||||
@@ -335,44 +338,44 @@ type IpcMessage struct {
|
|||||||
|
|
||||||
// join_network / leave_network
|
// join_network / leave_network
|
||||||
NetworkName string `json:"network_name,omitempty"`
|
NetworkName string `json:"network_name,omitempty"`
|
||||||
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
|
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
|
||||||
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
|
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
|
||||||
RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite
|
RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite
|
||||||
InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh)
|
InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh)
|
||||||
|
|
||||||
// send_file / set_share_dir / file_complete path
|
// send_file / set_share_dir / file_complete path
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
|
|
||||||
// events
|
// events
|
||||||
Peer *PeerInfo `json:"peer,omitempty"`
|
Peer *PeerInfo `json:"peer,omitempty"`
|
||||||
PeerID *PeerID `json:"peer_id,omitempty"`
|
PeerID *PeerID `json:"peer_id,omitempty"`
|
||||||
Nick string `json:"nick,omitempty"`
|
Nick string `json:"nick,omitempty"`
|
||||||
Message *ChatMessage `json:"message,omitempty"`
|
Message *ChatMessage `json:"message,omitempty"`
|
||||||
Offer *FileOffer `json:"offer,omitempty"`
|
Offer *FileOffer `json:"offer,omitempty"`
|
||||||
TransferID string `json:"transfer_id,omitempty"`
|
TransferID string `json:"transfer_id,omitempty"`
|
||||||
BytesReceived int64 `json:"bytes_received,omitempty"`
|
BytesReceived int64 `json:"bytes_received,omitempty"`
|
||||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||||
// state_snapshot fields (existing shape preserved for backward compat)
|
// state_snapshot fields (existing shape preserved for backward compat)
|
||||||
MasterAlias string `json:"master_alias,omitempty"` // daemon's alias (available before any network join)
|
MasterAlias string `json:"master_alias,omitempty"` // daemon's alias (available before any network join)
|
||||||
MasterID string `json:"master_id,omitempty"` // daemon's master public key hex
|
MasterID string `json:"master_id,omitempty"` // daemon's master public key hex
|
||||||
LocalPeer *PeerInfo `json:"local_peer,omitempty"`
|
LocalPeer *PeerInfo `json:"local_peer,omitempty"`
|
||||||
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
|
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
|
||||||
KnownPeers []PeerInfo `json:"known_peers,omitempty"` // historically seen, not currently connected
|
KnownPeers []PeerInfo `json:"known_peers,omitempty"` // historically seen, not currently connected
|
||||||
Rooms []string `json:"rooms,omitempty"`
|
Rooms []string `json:"rooms,omitempty"`
|
||||||
// multi-network: all joined networks (additive)
|
// multi-network: all joined networks (additive)
|
||||||
Networks []NetworkInfo `json:"networks,omitempty"`
|
Networks []NetworkInfo `json:"networks,omitempty"`
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
InviteGenerated string `json:"invite,omitempty"`
|
InviteGenerated string `json:"invite,omitempty"`
|
||||||
Files []FileEntry `json:"files,omitempty"`
|
Files []FileEntry `json:"files,omitempty"`
|
||||||
Messages []ChatMessage `json:"messages,omitempty"` // history_loaded
|
Messages []ChatMessage `json:"messages,omitempty"` // history_loaded
|
||||||
ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers
|
ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers
|
||||||
ReactionMID string `json:"reaction_mid,omitempty"` // reaction
|
ReactionMID string `json:"reaction_mid,omitempty"` // reaction
|
||||||
ReactionEmoji string `json:"reaction_emoji,omitempty"` // reaction
|
ReactionEmoji string `json:"reaction_emoji,omitempty"` // reaction
|
||||||
Shares []ShareEntry `json:"shares,omitempty"`
|
Shares []ShareEntry `json:"shares,omitempty"`
|
||||||
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
|
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
|
||||||
// export_identity / import_identity
|
// export_identity / import_identity
|
||||||
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
|
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
|
||||||
Backup string `json:"backup,omitempty"` // JSON backup blob
|
Backup string `json:"backup,omitempty"` // JSON backup blob
|
||||||
// peer_status — ICE connection quality
|
// peer_status — ICE connection quality
|
||||||
ConnState string `json:"conn_state,omitempty"` // pion PeerConnectionState string
|
ConnState string `json:"conn_state,omitempty"` // pion PeerConnectionState string
|
||||||
CandidateType string `json:"candidate_type,omitempty"` // host | srflx | relay | unknown
|
CandidateType string `json:"candidate_type,omitempty"` // host | srflx | relay | unknown
|
||||||
|
|||||||
96
launch-tui.sh.example
Normal file
96
launch-tui.sh.example
Normal 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
74
launch-web.sh.example
Normal 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
38
serve-web.sh.example
Normal 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
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
filter: invert(1) brightness(2);
|
||||||
--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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
|
|||||||
Reference in New Issue
Block a user