Compare commits
31 Commits
0e8ddbf4f4
...
v0.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf4009558d | ||
|
|
d09aa2b219 | ||
|
|
437deca6a0 | ||
|
|
851cfdc7e9 | ||
|
|
4a7a95fe9d | ||
|
|
32a6f46481 | ||
|
|
1d9c9d1524 | ||
|
|
f5fb0862ff | ||
|
|
dab5387cbd | ||
|
|
1d9827beb0 | ||
|
|
fcbd84f873 | ||
|
|
cef9374416 | ||
|
|
9ad3c96d43 | ||
|
|
48400440dd | ||
|
|
0e812a2479 | ||
|
|
f319721e01 | ||
|
|
9de625d617 | ||
|
|
15306dc0c2 | ||
|
|
7c3cedc549 | ||
|
|
1c73f1b1ef | ||
|
|
b2b5c8c7cb | ||
|
|
b6ff30de78 | ||
|
|
1bd719fa58 | ||
|
|
be297d3a49 | ||
|
|
c426fa8c08 | ||
|
|
0789cf8840 | ||
|
|
d233f4d79e | ||
|
|
95fd29ae8d | ||
|
|
340735f992 | ||
|
|
1308082c7b | ||
|
|
31e13fd509 |
82
.gitea/workflows/build.yml
Normal file
82
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build & release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
|
||||||
|
- name: Install Wails CLI
|
||||||
|
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
|
|
||||||
|
- name: Install platform dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libayatana-appindicator3-dev
|
||||||
|
|
||||||
|
# ── Server binaries (CGO_ENABLED=0, cross-compile freely) ──────────────
|
||||||
|
|
||||||
|
- name: Build server binaries
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
build() {
|
||||||
|
local GOOS=$1 GOARCH=$2
|
||||||
|
local SUFFIX="${GOOS}-${GOARCH}"
|
||||||
|
local EXT=""
|
||||||
|
[ "$GOOS" = "windows" ] && EXT=".exe"
|
||||||
|
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
|
||||||
|
go build -trimpath -ldflags="-s -w" \
|
||||||
|
-o "dist/waste-daemon-${SUFFIX}${EXT}" ./cmd/daemon
|
||||||
|
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
|
||||||
|
go build -trimpath -ldflags="-s -w" \
|
||||||
|
-o "dist/waste-anchor-${SUFFIX}${EXT}" ./cmd/anchor
|
||||||
|
}
|
||||||
|
build linux amd64
|
||||||
|
build linux arm64
|
||||||
|
build darwin amd64
|
||||||
|
build darwin arm64
|
||||||
|
build windows amd64
|
||||||
|
|
||||||
|
# ── Desktop app (Linux amd64, CGo + Wails) ─────────────────────────────
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cp -r dist ../cmd/app/frontend/dist
|
||||||
|
|
||||||
|
- name: Build desktop app
|
||||||
|
run: |
|
||||||
|
cd cmd/app
|
||||||
|
wails build -trimpath -ldflags="-s -w" -tags webkit2_41
|
||||||
|
cp build/bin/waste ../../dist/waste-linux-amd64
|
||||||
|
|
||||||
|
# ── Publish release (tags only) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@main
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
files: dist/*
|
||||||
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -2,10 +2,12 @@
|
|||||||
setup-anchor.sh
|
setup-anchor.sh
|
||||||
launch-tui.sh
|
launch-tui.sh
|
||||||
*.exe
|
*.exe
|
||||||
# compiled binary names that land in the repo root
|
# compiled binaries that land in the repo root
|
||||||
/anchor
|
/anchor
|
||||||
/waste-anchor
|
/waste-anchor
|
||||||
/waste-daemon
|
/waste-daemon
|
||||||
|
/waste
|
||||||
|
/app
|
||||||
/relay
|
/relay
|
||||||
*.identity.json
|
*.identity.json
|
||||||
/tmp/
|
/tmp/
|
||||||
@@ -19,4 +21,7 @@ build-daemon.sh
|
|||||||
deploy-web.sh
|
deploy-web.sh
|
||||||
deploy-daemon.sh
|
deploy-daemon.sh
|
||||||
serve-web.sh
|
serve-web.sh
|
||||||
|
push.sh
|
||||||
web/public/config.js
|
web/public/config.js
|
||||||
|
cmd/app/frontend/dist/*
|
||||||
|
!cmd/app/frontend/dist/.gitkeep
|
||||||
|
|||||||
377
EXTENSIONS.md
Normal file
377
EXTENSIONS.md
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
# waste-go Protocol Extensions
|
||||||
|
|
||||||
|
These are additive extensions to [YAW/2](PROTOCOL.md) implemented by waste-go.
|
||||||
|
They do **not** break compatibility — YAW/2-only peers silently ignore all new
|
||||||
|
fields. Where a waste-go peer connects to a YAW/2-only peer, the extension
|
||||||
|
simply has no effect on that peer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-001 — Signed Invites
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** `waste:` invite format, `hello` DataChannel message
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
The base YAW/2 network model is open to anyone who knows the anchor URL and
|
||||||
|
network name (or hash). This extension adds opt-in cryptographic membership
|
||||||
|
gating: invites are signed by an existing peer, and peers that enforce
|
||||||
|
`RequireInvite` reject hellos that carry no valid signed invite.
|
||||||
|
|
||||||
|
### Invite format changes
|
||||||
|
|
||||||
|
The `waste:` invite payload (base64-encoded JSON) gains two optional fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"anchor": "wss://...",
|
||||||
|
"network": "friends",
|
||||||
|
"net": "<64-hex SHA-256(yaw2-net:name)>",
|
||||||
|
"inviter": "<64-hex Ed25519 pubkey of signing peer>",
|
||||||
|
"sig": "<hex Ed25519 signature>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The signature covers the following bytes (null-separated):
|
||||||
|
|
||||||
|
```
|
||||||
|
anchor \x00 network \x00 net \x00 inviter
|
||||||
|
```
|
||||||
|
|
||||||
|
Unsigned invites (`inviter`/`sig` absent) remain valid for backward compat.
|
||||||
|
|
||||||
|
### Hello message extension
|
||||||
|
|
||||||
|
The YAW/2 §6 hello message gains one optional field:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "hello",
|
||||||
|
"id": "<hex pubkey>",
|
||||||
|
"nick": "alice",
|
||||||
|
"caps": ["chat", "file"],
|
||||||
|
"sig": "<DTLS binding sig>",
|
||||||
|
"invite": "waste:eyJ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`invite` carries the full `waste:` string the connecting peer used to join.
|
||||||
|
YAW/2-only peers ignore this field.
|
||||||
|
|
||||||
|
### Enforcement
|
||||||
|
|
||||||
|
Per-network flag `RequireInvite` (set via `join_network` IPC command).
|
||||||
|
When enabled:
|
||||||
|
|
||||||
|
1. A peer that presents no `invite` in hello is disconnected immediately.
|
||||||
|
2. A peer that presents an invite with no signature is disconnected.
|
||||||
|
3. A peer whose invite signature is invalid is disconnected.
|
||||||
|
4. A peer whose invite was signed by an unknown peer ID (not in the store or
|
||||||
|
currently connected) is disconnected.
|
||||||
|
|
||||||
|
The inviter's key must be a **known peer** — i.e. previously connected and
|
||||||
|
stored in the per-network SQLite store, or currently connected. This forms a
|
||||||
|
chain of trust: Alice (founder) invites Bob; Bob's key is now known; Bob can
|
||||||
|
invite Carol, whose invite Alice will also accept.
|
||||||
|
|
||||||
|
**Default:** off. Networks opt in. Existing networks with no RequireInvite
|
||||||
|
behave exactly as before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-002 — Hash-based Hang Link
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** web UI URL handling only, no wire changes
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
A shareable URL that pre-fills the join form without conveying cryptographic
|
||||||
|
membership. Suitable for public announcements ("come hang out here"). The
|
||||||
|
fragment is never sent to the server, keeping the network name opaque to
|
||||||
|
server logs and HTTP intermediaries.
|
||||||
|
|
||||||
|
### Format
|
||||||
|
|
||||||
|
```
|
||||||
|
https://host/#waste:eyJ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The fragment payload is the standard `waste:` base64 JSON with only `network`
|
||||||
|
and `anchor` fields — no `inviter`, no `sig`. This does **not** grant
|
||||||
|
membership on networks with `RequireInvite` enabled; it only pre-fills the
|
||||||
|
join form.
|
||||||
|
|
||||||
|
The web UI generates hang links via the 🔗 button in the Networks sidebar
|
||||||
|
section. Arriving users see the join form pre-populated and still need a
|
||||||
|
proper signed invite (if the network enforces it) to be accepted by peers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-003 — Multi-Share Configuration
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** IPC protocol only, no peer-to-peer wire changes
|
||||||
|
|
||||||
|
### New IPC commands
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{"type":"add_share","path":"/home/alice/Music"} // global
|
||||||
|
{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // scoped
|
||||||
|
{"type":"remove_share","path":"/home/alice/Music"}
|
||||||
|
{"type":"list_shares"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New IPC event
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{"type":"shares_list","shares":[{"path":"...","networks":["*"]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
`shares.json` in the data directory (next to `identity.json`). Each entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "path": "/absolute/path", "networks": ["*"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`networks: ["*"]` = global (all networks). Specific network IDs = scoped.
|
||||||
|
Coexists with the legacy `set_share_dir` single-dir mechanism.
|
||||||
|
|
||||||
|
File listings returned by `get_file_list` and `MsgFileListReq` include
|
||||||
|
entries from all applicable share roots, with relative `path` fields
|
||||||
|
(e.g. `"path": "docs/report.pdf"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-004 — TURN Relay (browser mode)
|
||||||
|
|
||||||
|
**Status:** implemented (browser mode + daemon mode)
|
||||||
|
**Affects:** ICE server configuration only, no wire changes
|
||||||
|
|
||||||
|
The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`
|
||||||
|
and adds a TURN server to the WebRTC `ICEServers` list. Credentials are
|
||||||
|
generated using HMAC-SHA1 of the username (coturn `use-auth-secret` scheme).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-005 — Per-Network Path in FileEntry
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** `MsgFileListResp` wire message (additive field)
|
||||||
|
|
||||||
|
`FileEntry` gains an optional `path` field carrying the file's relative path
|
||||||
|
within its share root (e.g. `"docs/report.pdf"`). Peers that don't understand
|
||||||
|
this field continue to use `name` for display and download requests.
|
||||||
|
|
||||||
|
`MsgFileListReq` / `get` requests use `path` as the lookup key when present,
|
||||||
|
falling back to `name` for backward compat with peers that don't send `path`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-006 — File Transfer Resume
|
||||||
|
|
||||||
|
**Status:** implemented (daemon mode)
|
||||||
|
**Affects:** `file-accept` wire message (additive field)
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
A transfer interrupted mid-stream (peer disconnect, network drop) can be
|
||||||
|
continued from where it left off on the next offer of the same file, avoiding
|
||||||
|
a full re-download.
|
||||||
|
|
||||||
|
### Protocol change
|
||||||
|
|
||||||
|
`file-accept` gains one optional field:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "file-accept", "xid": "...", "resume_offset": 65536 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`resume_offset` is the number of bytes the receiver already has on disk.
|
||||||
|
When non-zero, the sender seeks to that byte position before streaming.
|
||||||
|
Peers that don't understand this field ignore it and send from the start —
|
||||||
|
the receiver detects this by comparing incoming data to expected offset and
|
||||||
|
will still verify the final SHA-256, but the partial bytes from the interrupted
|
||||||
|
session will be overwritten (YAW/2-only interop degrades gracefully to a full
|
||||||
|
re-download, not corruption).
|
||||||
|
|
||||||
|
### Receiver behaviour
|
||||||
|
|
||||||
|
1. On `file-offer`, the receiver scans its download directory for a `.tmp.meta`
|
||||||
|
sidecar whose `sha256` matches the offer.
|
||||||
|
2. If found, the corresponding `.tmp` file's size is the resume offset. This is
|
||||||
|
sent back in `file-accept`.
|
||||||
|
3. On DC open, the receiver opens the existing `.tmp` in append mode and
|
||||||
|
re-hashes its existing bytes to restore the SHA-256 state.
|
||||||
|
4. On DC close with all bytes received, SHA-256 is verified. Success → sidecar
|
||||||
|
removed, file renamed to final path. Hash mismatch → both files removed.
|
||||||
|
5. On DC close with fewer bytes than expected (interrupted again) → both files
|
||||||
|
kept for the next resume attempt.
|
||||||
|
|
||||||
|
### Sidecar format
|
||||||
|
|
||||||
|
Each in-progress `.tmp` file has a corresponding `.tmp.meta` JSON sidecar:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "archive.zip", "sha256": "abc...", "from": "<peer-id>", "size": 1048576 }
|
||||||
|
```
|
||||||
|
|
||||||
|
The sidecar is written when the transfer starts and removed on completion or
|
||||||
|
corruption. Interrupted transfers keep the sidecar indefinitely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-007 — P2P Message History Gossip
|
||||||
|
|
||||||
|
**Status:** implemented (daemon mode)
|
||||||
|
**Affects:** peer-to-peer wire (two new message types); IPC (new event)
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
When a peer joins a network for the first time (or reconnects after an
|
||||||
|
absence), they have no history. This extension lets them request recent
|
||||||
|
messages from an existing peer over the already-established encrypted
|
||||||
|
DataChannel, without involving the anchor.
|
||||||
|
|
||||||
|
### Wire messages
|
||||||
|
|
||||||
|
#### `history_request`
|
||||||
|
|
||||||
|
Sent by the newly-connected peer to the first peer whose hello is verified.
|
||||||
|
One request per room.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "history_request",
|
||||||
|
"room": "general",
|
||||||
|
"since": 1700000000000,
|
||||||
|
"limit": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---------|---------------|-------------|
|
||||||
|
| `room` | string | Room to request history for. |
|
||||||
|
| `since` | int64 (ms) | Only return messages with `ts > since`. 0 = return up to `limit` most recent. |
|
||||||
|
| `limit` | int (max 500) | Maximum messages to return. Responder may return fewer. |
|
||||||
|
|
||||||
|
#### `history_chunk`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "history_chunk",
|
||||||
|
"room": "general",
|
||||||
|
"history": [
|
||||||
|
{ "mid": "...", "from": "<peer-id>", "from_alias": "alice", "text": "hello", "ts": 1700000001000 }
|
||||||
|
],
|
||||||
|
"history_done": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| `history` | array | Messages, oldest-first. |
|
||||||
|
| `history_done` | bool | Always `true` (single-chunk response). |
|
||||||
|
|
||||||
|
### Deduplication
|
||||||
|
|
||||||
|
`mid` is the deduplication key. The store uses `INSERT OR IGNORE` on `mid`,
|
||||||
|
so receiving a message twice (live or via gossip) is a no-op. Messages
|
||||||
|
without a `mid` are assigned one at receive time and are not gossipped.
|
||||||
|
|
||||||
|
### Behaviour
|
||||||
|
|
||||||
|
- The **receiver** sends one `history_request` per known room immediately
|
||||||
|
after hello verification with the **first** peer it connects to. Requesting
|
||||||
|
only the first peer avoids fan-out amplification.
|
||||||
|
- The **responder** queries its SQLite store and replies with a single
|
||||||
|
`history_chunk`. `limit` is capped at 500 server-side. Rate-limited to one
|
||||||
|
request per (peer, room) per 60 seconds.
|
||||||
|
- Received history messages are saved to the local store (`INSERT OR IGNORE`)
|
||||||
|
and emitted as `history_loaded` IPC events so the UI can display them.
|
||||||
|
|
||||||
|
### IPC event
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "history_loaded", "room": "general", "messages": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Emitted once per room after a `history_chunk` is fully processed. The UI
|
||||||
|
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.
|
||||||
73
FUTURE.md
73
FUTURE.md
@@ -38,12 +38,12 @@ React + Vite frontend. Two modes:
|
|||||||
### NAT Traversal ✅ (WebRTC ICE/STUN)
|
### NAT Traversal ✅ (WebRTC ICE/STUN)
|
||||||
Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexive (STUN) candidates and performs UDP hole punching automatically. The anchor (`cmd/anchor`) doubles as a STUN server on UDP/3478.
|
Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexive (STUN) candidates and performs UDP hole punching automatically. The anchor (`cmd/anchor`) doubles as a STUN server on UDP/3478.
|
||||||
|
|
||||||
### TURN relay ✅ (shipped, browser mode)
|
### TURN relay ✅ (shipped)
|
||||||
Browser mode now supports TURN relay. `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. Mobile/CGNAT peers that fail STUN hole-punching automatically fall back to TURN relay.
|
Both browser and daemon modes support TURN relay.
|
||||||
|
|
||||||
The peer dot in the sidebar turns yellow for relayed connections (`candidate_type: 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`).
|
||||||
|
|
||||||
**Daemon mode:** not yet wired. Add `-turn-url` / `-turn-secret` flags to `cmd/daemon/main.go` and pass them into the `ICEServers` slice in `internal/anchor/client.go`.
|
**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.
|
||||||
|
|
||||||
### Signaling ✅ YAW/2.1 (shipped)
|
### Signaling ✅ YAW/2.1 (shipped)
|
||||||
Forward-secret signaling via per-session ephemeral X25519 keys. Falls back transparently to 2.0 static-key sealing for peers that don't speak 2.1.
|
Forward-secret signaling via per-session ephemeral X25519 keys. Falls back transparently to 2.0 static-key sealing for peers that don't speak 2.1.
|
||||||
@@ -76,10 +76,14 @@ Multiple share roots per network, with global (all networks) or scoped visibilit
|
|||||||
- **Daemon:** `shares.json` next to `identity.json` in the data dir. `add_share`/`remove_share`/`list_shares` IPC commands. File listing recursively walks all share roots, returning relative paths. Backward compatible with the existing `set_share_dir` single-dir mechanism.
|
- **Daemon:** `shares.json` next to `identity.json` in the data dir. `add_share`/`remove_share`/`list_shares` IPC commands. File listing recursively walks all share roots, returning relative paths. Backward compatible with the existing `set_share_dir` single-dir mechanism.
|
||||||
- **Browser:** `waste_shares` in `localStorage` stores named share records (folder name, global flag). The `ShareManager` sidebar component shows the list with re-pick (↺) and remove (✕) buttons. Actual `File` objects live in memory — the record persists across reloads so the user can restore with one click.
|
- **Browser:** `waste_shares` in `localStorage` stores named share records (folder name, global flag). The `ShareManager` sidebar component shows the list with re-pick (↺) and remove (✕) buttons. Actual `File` objects live in memory — the record persists across reloads so the user can restore with one click.
|
||||||
|
|
||||||
### Additional Channels / Rooms ✅ (shipped, web UI)
|
### Additional Channels / Rooms ✅ (shipped)
|
||||||
The `+` button in the Rooms sidebar section creates custom rooms, stored in `customRooms` keyed by `network_id`. Room names are slugified strings — any peer that sends to a room name causes it to appear on the recipient automatically. DM rooms (`dm:<peerId>`) appear automatically when messages arrive.
|
Custom rooms are supported in both the web UI and the TUI.
|
||||||
|
|
||||||
**Not yet done:** TUI room creation, daemon-side SQLite persistence of room lists across restarts.
|
**Web UI:** The `+` button in the Rooms sidebar creates custom rooms, stored in `customRooms` keyed by `network_id`. Room names are slugified strings — any peer that sends to a room name causes it to appear on the recipient automatically.
|
||||||
|
|
||||||
|
**TUI:** Type `/room <name>` in the input to create a room. The daemon persists it in the `rooms` SQLite table and echoes a `room_created` IPC event back. On reconnect, rooms are restored via `state_snapshot`. Rooms that receive messages while not active show a `*` prefix in the sidebar; the marker clears when you switch to that room.
|
||||||
|
|
||||||
|
DM rooms (`dm:<peerId>`) appear automatically in both interfaces when messages arrive.
|
||||||
|
|
||||||
### File Transfer UX ✅ (shipped)
|
### File Transfer UX ✅ (shipped)
|
||||||
- Manual accept/reject via the Transfers panel in the sidebar
|
- Manual accept/reject via the Transfers panel in the sidebar
|
||||||
@@ -87,13 +91,10 @@ The `+` button in the Rooms sidebar section creates custom rooms, stored in `cus
|
|||||||
- Live progress bar per active transfer
|
- Live progress bar per active transfer
|
||||||
- Push (📎) sends directly to a peer without them needing to share a folder
|
- Push (📎) sends directly to a peer without them needing to share a folder
|
||||||
|
|
||||||
**Not yet done:** resume after disconnection, daemon-side download directory.
|
On daemon start, the download directory is scanned for `.tmp.meta` sidecars and a `resumable_transfers` IPC event is emitted so the UI can show pending transfers with a progress bar.
|
||||||
|
|
||||||
### TURN Relay for Daemon Mode
|
|
||||||
The daemon doesn't yet support TURN. Add `-turn-url` and `-turn-secret` flags to `cmd/daemon/main.go` and wire them into the ICE server list in `internal/anchor/client.go`. The credential generation is the same HMAC-SHA1 scheme already implemented in browser mode.
|
|
||||||
|
|
||||||
### Native UI
|
### Native UI
|
||||||
Web frontend (React, already built) + Tauri shell for native packaging. The IPC protocol is the full boundary — the UI is already a pure consumer. Main work: Tauri setup, system tray, OS notifications.
|
Web frontend (React, already built) + [Wails v2](https://wails.io) shell for native packaging. Wails is Go-native — no Rust toolchain required. The daemon runs embedded in the same process; the webview connects to the existing WebSocket IPC at `ws://127.0.0.1:17338`. Built in `cmd/app/` via `./build-app.sh`. System tray (Linux/Windows) and OS notifications are implemented. macOS menu-bar tray requires Cocoa main-thread integration — currently a stub.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -121,10 +122,50 @@ Web frontend (React, already built) + Tauri shell for native packaging. The IPC
|
|||||||
| ✅ shipped | Session persistence + logout (browser mode) |
|
| ✅ shipped | Session persistence + logout (browser mode) |
|
||||||
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
||||||
| ✅ shipped | Subfolder support + directory browser UI in file browser |
|
| ✅ shipped | Subfolder support + directory browser UI in file browser |
|
||||||
| next | TURN relay for daemon mode |
|
| ✅ shipped | Signed invites + invite-only networks (`RequireInvite`) |
|
||||||
| next | TUI room creation + daemon-side room persistence |
|
| ✅ shipped | Hash-based "come hang" links (`#waste:...`) |
|
||||||
| next | File transfer resume after disconnection |
|
| ✅ shipped | Protocol extensions documented in EXTENSIONS.md |
|
||||||
| future | Native UI (React + Tauri) |
|
| ✅ shipped | TURN relay for daemon mode (`-turn-url` / `-turn-secret`) |
|
||||||
|
| ✅ shipped | TUI room creation + daemon-side room persistence |
|
||||||
|
| ✅ shipped | Unread room indicators in TUI (`*` prefix) |
|
||||||
|
| ✅ shipped | Per-network download directories (`-download-dir` flag + `set_download_dir` IPC) |
|
||||||
|
| ✅ shipped | File transfer resume after disconnection |
|
||||||
|
| ✅ shipped | PWA manifest — installable via "Add to Home Screen" on iOS and Android |
|
||||||
|
| ✅ shipped | Native desktop app (Wails 2) — system tray (Linux/Windows), OS notifications, single binary |
|
||||||
|
| ✅ shipped | Gitea Actions CI — server binaries (all platforms via cross-compile) + desktop app (Linux amd64) |
|
||||||
|
| ✅ shipped | File transfer resume UX — resumable transfers surfaced in Transfers panel on reconnect |
|
||||||
|
| ✅ 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 | 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) |
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
The web UI is already a PWA (installable, has manifest). The missing half is a service worker + Web Push subscription:
|
||||||
|
|
||||||
|
1. **Service worker** — intercepts `push` events and shows OS notifications via `showNotification()`.
|
||||||
|
2. **VAPID key pair** — generated once by the daemon (`-vapid-key` flag); the public key is served to the browser so it can subscribe.
|
||||||
|
3. **Subscription persistence** — the browser's `PushSubscription` JSON is sent to the daemon over IPC (`register_push` command). The daemon stores it per-network-per-peer.
|
||||||
|
4. **Daemon relay** — when a `message_received` event fires with no active IPC WebSocket connection, the daemon POSTs a Web Push notification to the stored subscription endpoint.
|
||||||
|
|
||||||
|
This keeps the architecture clean: the daemon already runs in the background; it becomes the notification relay. No third-party push server is required for self-hosted setups (coturn already in use for TURN; a lightweight Web Push POST is similar).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
89
QUICKSTART.md
Normal file
89
QUICKSTART.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# waste — quick start
|
||||||
|
|
||||||
|
waste is a private, encrypted chat and file sharing app for people you trust.
|
||||||
|
No accounts, no phone numbers, no central server that knows your messages.
|
||||||
|
|
||||||
|
Pick the option that fits you best.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 1 — Just open it in your browser
|
||||||
|
|
||||||
|
If someone is running a waste anchor server and has shared the URL with you:
|
||||||
|
|
||||||
|
1. Open the URL in any modern browser
|
||||||
|
2. Enter your name and a network name your group has agreed on
|
||||||
|
3. Done — you're in
|
||||||
|
|
||||||
|
On mobile, tap **Share → Add to Home Screen** to install it as an app icon.
|
||||||
|
|
||||||
|
To invite someone: click the 🔗 button in the sidebar and share the link.
|
||||||
|
|
||||||
|
> Your identity and messages stay in your browser. Nothing is stored on the server — the server only helps peers find each other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 2 — Desktop app (recommended for regular use)
|
||||||
|
|
||||||
|
Download the latest `waste` binary for your platform from the [releases page](../../releases).
|
||||||
|
|
||||||
|
**Linux / macOS:**
|
||||||
|
```bash
|
||||||
|
chmod +x waste-linux-amd64 # or waste-darwin-arm64, etc.
|
||||||
|
./waste-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:** double-click `waste-windows-amd64.exe`.
|
||||||
|
|
||||||
|
The app opens a window with the waste UI. Enter your name, the anchor URL, and a network name to join. Your identity is saved between sessions in your config directory (`~/.config/waste` on Linux, `~/Library/Application Support/waste` on macOS, `%APPDATA%\waste` on Windows).
|
||||||
|
|
||||||
|
On Linux and Windows a tray icon appears — closing the window hides to tray rather than quitting. Right-click the tray icon to reopen or quit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 3 — Run the daemon manually (headless / power users)
|
||||||
|
|
||||||
|
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
|
||||||
|
# Download waste-daemon from the releases page, then:
|
||||||
|
./waste-daemon -alias yourname -anchor wss://your-anchor-server/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`.
|
||||||
|
|
||||||
|
Full flag reference:
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `-alias` | `anon` | Your display name |
|
||||||
|
| `-anchor` | — | Anchor server WebSocket URL |
|
||||||
|
| `-data-dir` | `~/.waste` | Where identity and messages are stored |
|
||||||
|
| `-download-dir` | same as data-dir | Where received files are saved |
|
||||||
|
| `-ipc-port` | `17337` | Local TCP IPC port |
|
||||||
|
| `-ws-port` | `0` (off) | WebSocket IPC port (needed for web UI) |
|
||||||
|
| `-turn-url` | — | TURN relay URL (fixes mobile/CGNAT) |
|
||||||
|
| `-turn-secret` | — | TURN shared secret |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inviting someone
|
||||||
|
|
||||||
|
1. Click `Ctrl+I` in the TUI, or click **Generate invite** in the web UI
|
||||||
|
2. Share the `waste:...` link with your friend (Signal, email, anything)
|
||||||
|
3. They open it in a browser or pass it to `waste-daemon --join 'waste:...'`
|
||||||
|
|
||||||
|
Invite links encode the anchor URL and network name. The anchor never sees your messages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running your own anchor server
|
||||||
|
|
||||||
|
The anchor is a tiny signaling server that helps peers find each other — it never sees plaintext messages or file contents. You need a VPS with a domain and TLS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On your VPS:
|
||||||
|
./waste-anchor -bind 127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
160
README.md
160
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.
|
||||||
@@ -169,6 +214,8 @@ The `use-auth-secret` mode generates short-lived TURN credentials from the share
|
|||||||
|
|
||||||
> 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 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).
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How it works: daemon vs browser mode
|
## How it works: daemon vs browser mode
|
||||||
@@ -183,6 +230,8 @@ A user visits your domain, enters their name and a network name, and joins. Invi
|
|||||||
|
|
||||||
**Identity note:** browser mode uses the master identity directly (same keypair on all networks, compatible with yaw2). The daemon derives a separate keypair per network via HKDF. A browser user and a daemon user on the same network will see each other and can chat — they just appear as different peers even if they're the same person.
|
**Identity note:** browser mode uses the master identity directly (same keypair on all networks, compatible with yaw2). The daemon derives a separate keypair per network via HKDF. A browser user and a daemon user on the same network will see each other and can chat — they just appear as different peers even if they're the same person.
|
||||||
|
|
||||||
|
**Mobile:** the web UI ships a PWA manifest (`manifest.json`) so iOS and Android users can pin it to their home screen via "Add to Home Screen" in Safari or Chrome. It runs as a standalone app with no browser chrome. No app store, no native install required.
|
||||||
|
|
||||||
**Session persistence:** the last-used network name, alias, and anchor URL are saved to `localStorage`. On reload, the browser automatically rejoins the saved network — no login screen. To leave a network or switch identity, click the **⏻** button in the top-left of the sidebar. You'll be asked whether to also clear the identity keypair (export a backup first if you want to keep it).
|
**Session persistence:** the last-used network name, alias, and anchor URL are saved to `localStorage`. On reload, the browser automatically rejoins the saved network — no login screen. To leave a network or switch identity, click the **⏻** button in the top-left of the sidebar. You'll be asked whether to also clear the identity keypair (export a backup first if you want to keep it).
|
||||||
|
|
||||||
### Daemon mode (for users running the daemon locally)
|
### Daemon mode (for users running the daemon locally)
|
||||||
@@ -213,8 +262,80 @@ Hover over a peer in the sidebar to reveal action buttons. Click **⊞** to requ
|
|||||||
|
|
||||||
Hover over a peer and click **📎** to open a file picker. The selected file is pushed immediately to that peer — they don't need to be sharing anything. The recipient's browser auto-downloads the file on arrival.
|
Hover over a peer and click **📎** to open a file picker. The selected file is pushed immediately to that peer — they don't need to be sharing anything. The recipient's browser auto-downloads the file on arrival.
|
||||||
|
|
||||||
|
### Transfer resume (daemon mode)
|
||||||
|
|
||||||
|
When a file transfer is interrupted mid-stream — peer disconnects, network drops — the partially-received data is kept on disk. A `.meta` sidecar is written alongside each in-progress `.tmp` file recording the file name, size, and SHA-256.
|
||||||
|
|
||||||
|
When the peer reconnects and re-offers the same file, the daemon finds the matching partial by SHA-256, sends back a `file-accept` with a non-zero `resume_offset`, and the sender seeks to that byte position before streaming. The receiver appends to the existing file and verifies the full SHA-256 at the end.
|
||||||
|
|
||||||
|
Corrupted transfers (all bytes received but hash mismatch) are removed. Interrupted transfers are kept indefinitely until either successfully completed or the `.tmp`/`.meta` files are manually deleted.
|
||||||
|
|
||||||
> In daemon mode, use `add_share` / `remove_share` via IPC to manage share roots. Share configuration is stored in `shares.json` next to `identity.json` in the data directory and survives restarts. `networks: ["*"]` makes a share visible on all networks; omit to scope it to specific network IDs. The legacy `set_share_dir` single-dir command still works alongside it.
|
> In daemon mode, use `add_share` / `remove_share` via IPC to manage share roots. Share configuration is stored in `shares.json` next to `identity.json` in the data directory and survives restarts. `networks: ["*"]` makes a share visible on all networks; omit to scope it to specific network IDs. The legacy `set_share_dir` single-dir command still works alongside it.
|
||||||
|
|
||||||
|
### Daemon download directory
|
||||||
|
|
||||||
|
Received files are saved into a per-network subdirectory so networks stay isolated. By default this is inside the data directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.waste/downloads-<network-id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `-download-dir` at startup to use a different base:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/daemon -download-dir ~/Downloads ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Files then land in `~/Downloads/downloads-<network-id>/`. The path can also be changed at runtime via the `set_download_dir` IPC command (takes effect for the next incoming transfer on that network). The current path is reported in every `state_snapshot` event under `networks[].download_dir`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop app (Wails)
|
||||||
|
|
||||||
|
`cmd/app/` is a [Wails v2](https://wails.io) shell that packages the React frontend and the daemon logic into a single native binary for Windows, macOS, and Linux. No Rust, no Electron — just Go + the OS webview.
|
||||||
|
|
||||||
|
The daemon runs embedded in the same process. The webview connects to `ws://127.0.0.1:17338` exactly as it does in browser-daemon mode, so the frontend code is unchanged. Identity and stores use the OS config directory (`~/Library/Application Support/waste` on macOS, `~/.config/waste` on Linux, `%APPDATA%\waste` on Windows).
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.24+
|
||||||
|
- Node.js 20+
|
||||||
|
- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||||
|
- Linux platform deps: `sudo apt-get install libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev`
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production build → dist/waste-linux-amd64 (or waste.exe / waste.app)
|
||||||
|
./build-app.sh
|
||||||
|
|
||||||
|
# Dev mode (hot-reload; start Vite in another terminal: cd web && npm run dev)
|
||||||
|
./build-app.sh dev
|
||||||
|
|
||||||
|
# Build without system tray (no libayatana-appindicator3-dev needed)
|
||||||
|
CGO_ENABLED=1 WAILS_TAGS=notray ./build-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`build-app.sh` builds the React frontend, copies `web/dist/` into `cmd/app/frontend/dist/` for embedding, then runs `wails build`. The result is a single self-contained binary.
|
||||||
|
|
||||||
|
### What the desktop app provides
|
||||||
|
|
||||||
|
- **Single binary** — daemon logic is embedded, no subprocess to manage
|
||||||
|
- **System tray** (Linux/Windows) — hide to tray, reopen from tray menu, Quit
|
||||||
|
- **macOS** — hides to Dock on window close; full menu-bar tray is future work
|
||||||
|
- **OS notifications** — native popup on new message or completed file transfer (uses the browser `Notification` API via Wails events; the app will request permission on first notification)
|
||||||
|
- **Data directory** — OS-appropriate config dir (`~/.config/waste`, `~/Library/Application Support/waste`, `%APPDATA%\waste`)
|
||||||
|
|
||||||
|
### CI / automated builds
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yml` runs on every `v*` tag push:
|
||||||
|
|
||||||
|
- **Server binaries** (daemon + anchor): cross-compiled for Linux amd64/arm64, macOS amd64/arm64, Windows amd64 — no CGo, no special runner needed
|
||||||
|
- **Desktop app**: Linux amd64 on the default runner (installs GTK + WebKit + appindicator deps automatically)
|
||||||
|
- **Releases**: all artifacts attached to the Gitea release
|
||||||
|
|
||||||
|
To add macOS or Windows desktop builds, add self-hosted Gitea runners on those platforms and mirror the `desktop-linux` job.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
@@ -284,6 +405,24 @@ The invite encodes the anchor URL and network name. Sharing it only lets the rec
|
|||||||
|
|
||||||
Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
|
Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
|
||||||
|
|
||||||
|
### Signed invites and invite-only networks (waste-go extension)
|
||||||
|
|
||||||
|
Invites generated by `generate_invite` are **cryptographically signed** by the generating peer. The `waste:` payload carries an `inviter` field (Ed25519 public key) and a `sig` field (signature over anchor + network + inviter). When Bob joins, the invite is forwarded in the `hello` message so Alice can verify it.
|
||||||
|
|
||||||
|
To enable invite-only enforcement on a network, pass `require_invite: true` in the `join_network` command. Peers presenting no invite, an unsigned invite, or an invite signed by an unknown peer are rejected.
|
||||||
|
|
||||||
|
### "Come hang" hang links
|
||||||
|
|
||||||
|
The 🔗 button in the web UI copies a **hash-based hang link**:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://your-domain.com/#waste:eyJ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The fragment (`#...`) is never sent to the server, so the network name stays server-opaque. Anyone who opens the link gets the join form pre-filled — but they still need a proper signed invite to be accepted on networks with `require_invite` enabled. Suitable for public announcements of open or semi-open networks.
|
||||||
|
|
||||||
|
See [EXTENSIONS.md](EXTENSIONS.md) for the full protocol addendum.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Terminal UI
|
## Terminal UI
|
||||||
@@ -298,7 +437,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)
|
||||||
|
- `/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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -317,16 +465,20 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
|||||||
{"type":"get_file_list","peer_id":"<64-hex>"}
|
{"type":"get_file_list","peer_id":"<64-hex>"}
|
||||||
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"}
|
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"}
|
||||||
{"type":"add_share","path":"/home/alice/Music"} // global share
|
{"type":"add_share","path":"/home/alice/Music"} // global share
|
||||||
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // network-scoped
|
{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // network-scoped
|
||||||
{"type":"remove_share","path":"/home/alice/Music"}
|
{"type":"remove_share","path":"/home/alice/Music"}
|
||||||
{"type":"list_shares"}
|
{"type":"list_shares"}
|
||||||
|
{"type":"create_room","room":"dev"}
|
||||||
|
{"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":"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":"..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Events:**
|
**Events:**
|
||||||
```jsonc
|
```jsonc
|
||||||
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>"}
|
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>","networks":[{"network_id":"...","network_name":"friends","share_dir":"...","download_dir":"..."}]}
|
||||||
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob"}}
|
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob"}}
|
||||||
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
|
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
|
||||||
{"type":"peer_disconnected","peer_id":"<64-hex>"}
|
{"type":"peer_disconnected","peer_id":"<64-hex>"}
|
||||||
@@ -335,6 +487,8 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
|||||||
{"type":"invite_generated","invite":"waste:<base64>"}
|
{"type":"invite_generated","invite":"waste:<base64>"}
|
||||||
{"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":"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":"..."}
|
||||||
```
|
```
|
||||||
|
|||||||
30
build-app.sh
Executable file
30
build-app.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the waste desktop app (Wails + embedded React frontend).
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
|
# Node.js 20+
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-app.sh # production build → bin/waste (or bin/waste.exe)
|
||||||
|
# ./build-app.sh dev # dev mode (hot-reload; requires Vite dev server)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODE="${1:-build}"
|
||||||
|
|
||||||
|
echo "==> Building web frontend..."
|
||||||
|
(cd web && npm install --silent && npm run build)
|
||||||
|
|
||||||
|
echo "==> Copying frontend dist into app package..."
|
||||||
|
rm -rf cmd/app/frontend/dist
|
||||||
|
cp -r web/dist cmd/app/frontend/dist
|
||||||
|
|
||||||
|
if [ "$MODE" = "dev" ]; then
|
||||||
|
echo "==> Starting Wails dev mode (start 'cd web && npm run dev' in another terminal)..."
|
||||||
|
(cd cmd/app && wails dev)
|
||||||
|
else
|
||||||
|
echo "==> Running Wails build..."
|
||||||
|
mkdir -p bin
|
||||||
|
(cd cmd/app && wails build -o "$(pwd)/../../bin/waste")
|
||||||
|
echo "==> Done: bin/waste"
|
||||||
|
fi
|
||||||
130
cmd/app/app.go
Normal file
130
cmd/app/app.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"github.com/waste-go/internal/crypto"
|
||||||
|
"github.com/waste-go/internal/ipc"
|
||||||
|
"github.com/waste-go/internal/netmgr"
|
||||||
|
"github.com/waste-go/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const wsPort = 17338
|
||||||
|
|
||||||
|
// App is the Wails application backend.
|
||||||
|
// It embeds the daemon directly — no subprocess needed.
|
||||||
|
// The React frontend connects to the daemon's WebSocket IPC at ws://127.0.0.1:17338,
|
||||||
|
// exactly as it does in browser-daemon mode.
|
||||||
|
type App struct {
|
||||||
|
ctx context.Context
|
||||||
|
mgr *netmgr.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func newApp() *App {
|
||||||
|
return &App{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startup is called when the Wails window is ready. It initialises the daemon,
|
||||||
|
// starts the WebSocket IPC listener, sets up the system tray, and begins
|
||||||
|
// forwarding message/file events to the webview as OS notifications.
|
||||||
|
func (a *App) startup(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
|
||||||
|
dir := dataDir()
|
||||||
|
id, err := crypto.LoadOrCreate(dir, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("app: identity: %v", err)
|
||||||
|
runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
|
||||||
|
Type: runtime.ErrorDialog,
|
||||||
|
Title: "waste — startup error",
|
||||||
|
Message: "Failed to load identity: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("app: peer id: %s alias: %s", id.PeerID().Short(), id.Alias)
|
||||||
|
|
||||||
|
a.mgr = netmgr.New(netmgr.Config{
|
||||||
|
MasterIdentity: id,
|
||||||
|
StoreDir: dir,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Forward daemon events to the webview and generate OS notifications.
|
||||||
|
go a.watchEvents()
|
||||||
|
|
||||||
|
// Start the WebSocket IPC server; the webview connects here (daemon mode).
|
||||||
|
go func() {
|
||||||
|
if err := ipc.RunWS(a.mgr, wsPort); err != nil {
|
||||||
|
log.Printf("app: IPC: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("app: WebSocket IPC listening on 127.0.0.1:%d", wsPort)
|
||||||
|
|
||||||
|
// System tray (Linux/Windows; no-op on macOS — see tray_darwin.go).
|
||||||
|
a.startTray()
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdown is called when the Wails app exits.
|
||||||
|
func (a *App) shutdown(ctx context.Context) {
|
||||||
|
if a.mgr != nil {
|
||||||
|
a.mgr.LeaveAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchEvents subscribes to all daemon events and emits OS notifications for
|
||||||
|
// incoming messages and completed file transfers. The "notify" event is received
|
||||||
|
// by the frontend via Wails EventsOn and displayed using the browser Notification API.
|
||||||
|
func (a *App) watchEvents() {
|
||||||
|
if a.mgr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events := a.mgr.Subscribe()
|
||||||
|
defer a.mgr.Unsubscribe(events)
|
||||||
|
|
||||||
|
for evt := range events {
|
||||||
|
switch evt.Type {
|
||||||
|
case proto.EvtMessageReceived:
|
||||||
|
if evt.Message == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Don't notify for messages sent by the local peer.
|
||||||
|
if a.mgr.MasterIdentity() != nil && evt.Message.From == a.mgr.MasterIdentity().PeerID() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runtime.EventsEmit(a.ctx, "notify", map[string]string{
|
||||||
|
"title": "waste — new message",
|
||||||
|
"body": evt.Message.Text,
|
||||||
|
})
|
||||||
|
|
||||||
|
case proto.EvtFileComplete:
|
||||||
|
runtime.EventsEmit(a.ctx, "notify", map[string]string{
|
||||||
|
"title": "waste — file received",
|
||||||
|
"body": filepath.Base(evt.Path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataDir returns the OS-appropriate config directory for identity and stores.
|
||||||
|
// macOS: ~/Library/Application Support/waste
|
||||||
|
// Linux: ~/.config/waste
|
||||||
|
// Windows: %APPDATA%\waste
|
||||||
|
func dataDir() string {
|
||||||
|
base, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
return filepath.Join(home, ".waste")
|
||||||
|
}
|
||||||
|
return ".waste"
|
||||||
|
}
|
||||||
|
dir := filepath.Join(base, "waste")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
log.Printf("app: mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
BIN
cmd/app/appicon.png
Normal file
BIN
cmd/app/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
0
cmd/app/frontend/dist/.gitkeep
vendored
Normal file
0
cmd/app/frontend/dist/.gitkeep
vendored
Normal file
66
cmd/app/main.go
Normal file
66
cmd/app/main.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// waste desktop app — Wails shell wrapping the daemon and React UI.
|
||||||
|
// In dev mode the UI is served from the Vite dev server (http://localhost:5173).
|
||||||
|
// In production the compiled frontend is embedded from frontend/dist/.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/logger"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := newApp()
|
||||||
|
|
||||||
|
logLevel := logger.INFO
|
||||||
|
if os.Getenv("WASTE_DEBUG") != "" {
|
||||||
|
logLevel = logger.DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
err := wails.Run(&options.App{
|
||||||
|
Title: "waste",
|
||||||
|
Width: 1200,
|
||||||
|
Height: 800,
|
||||||
|
MinWidth: 800,
|
||||||
|
MinHeight: 600,
|
||||||
|
DisableResize: false,
|
||||||
|
Fullscreen: false,
|
||||||
|
LogLevel: logLevel,
|
||||||
|
LogLevelProduction: logger.ERROR,
|
||||||
|
AssetServer: &assetserver.Options{
|
||||||
|
Assets: assets,
|
||||||
|
},
|
||||||
|
OnStartup: app.startup,
|
||||||
|
OnShutdown: app.shutdown,
|
||||||
|
Bind: []interface{}{app},
|
||||||
|
// Hide the window instead of quitting when the close button is clicked.
|
||||||
|
// The user can quit via the app menu or by stopping the process.
|
||||||
|
HideWindowOnClose: true,
|
||||||
|
Mac: &mac.Options{
|
||||||
|
TitleBar: mac.TitleBarHiddenInset(),
|
||||||
|
About: &mac.AboutInfo{
|
||||||
|
Title: "waste",
|
||||||
|
Message: "Decentralized friend-to-friend encrypted mesh networking.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Windows: &windows.Options{
|
||||||
|
WebviewIsTransparent: false,
|
||||||
|
WindowIsTranslucent: false,
|
||||||
|
},
|
||||||
|
Linux: &linux.Options{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
cmd/app/tray.go
Normal file
35
cmd/app/tray.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build !darwin && !notray
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
|
||||||
|
"github.com/getlantern/systray"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed appicon.png
|
||||||
|
var trayIcon []byte
|
||||||
|
|
||||||
|
func (a *App) startTray() {
|
||||||
|
go systray.Run(func() {
|
||||||
|
systray.SetIcon(trayIcon)
|
||||||
|
systray.SetTooltip("waste")
|
||||||
|
|
||||||
|
mShow := systray.AddMenuItem("Open waste", "Show the waste window")
|
||||||
|
systray.AddSeparator()
|
||||||
|
mQuit := systray.AddMenuItem("Quit", "Quit waste")
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mShow.ClickedCh:
|
||||||
|
runtime.WindowShow(a.ctx)
|
||||||
|
case <-mQuit.ClickedCh:
|
||||||
|
systray.Quit()
|
||||||
|
runtime.Quit(a.ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, func() {})
|
||||||
|
}
|
||||||
14
cmd/app/tray_darwin.go
Normal file
14
cmd/app/tray_darwin.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// On macOS, Cocoa requires the system tray to run on the main thread, which
|
||||||
|
// Wails already owns. Full menu-bar tray integration is future work and would
|
||||||
|
// require an NSStatusItem helper or a fork of systray that supports
|
||||||
|
// RunWithExternalLoop on macOS.
|
||||||
|
//
|
||||||
|
// For now the app hides to the Dock when the window is closed (HideWindowOnClose)
|
||||||
|
// and users can reopen it from the Dock or Cmd+Tab. The standard macOS Cmd+Q
|
||||||
|
// binding quits cleanly via Wails.
|
||||||
|
|
||||||
|
func (a *App) startTray() {}
|
||||||
8
cmd/app/tray_stub.go
Normal file
8
cmd/app/tray_stub.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build notray
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// Build with -tags notray to omit the system tray (no libayatana-appindicator3-dev needed).
|
||||||
|
// Used for headless builds and CI environments that haven't installed the GTK tray headers.
|
||||||
|
|
||||||
|
func (a *App) startTray() {}
|
||||||
12
cmd/app/wails.json
Normal file
12
cmd/app/wails.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||||
|
"name": "waste",
|
||||||
|
"outputfilename": "waste",
|
||||||
|
"frontend:install": "echo 'run: cd web && npm install'",
|
||||||
|
"frontend:build": "echo 'run: ./build-app.sh'",
|
||||||
|
"frontend:dev:watcher": "",
|
||||||
|
"frontend:dev:serverUrl": "http://localhost:5173",
|
||||||
|
"author": {
|
||||||
|
"name": "waste-go"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,10 @@ func main() {
|
|||||||
wsPort := flag.Int("ws-port", 0, "port for WebSocket IPC (web UI); 0 = disabled")
|
wsPort := flag.Int("ws-port", 0, "port for WebSocket IPC (web UI); 0 = disabled")
|
||||||
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
||||||
shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
|
shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
|
||||||
|
downloadDir := flag.String("download-dir", "", "base directory for received files; defaults to data-dir, split by network")
|
||||||
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
||||||
|
turnURL := flag.String("turn-url", "", "TURN server URL, e.g. turn:your-vps:3478")
|
||||||
|
turnSecret := flag.String("turn-secret", "", "shared secret for coturn use-auth-secret HMAC credential")
|
||||||
importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import")
|
importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import")
|
||||||
importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
|
importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -74,6 +77,9 @@ func main() {
|
|||||||
StoreDir: dir,
|
StoreDir: dir,
|
||||||
AnchorURL: *anchorURL,
|
AnchorURL: *anchorURL,
|
||||||
ShareDir: expandHome(*shareDir),
|
ShareDir: expandHome(*shareDir),
|
||||||
|
DownloadDir: expandHome(*downloadDir),
|
||||||
|
TurnURL: *turnURL,
|
||||||
|
TurnSecret: *turnSecret,
|
||||||
})
|
})
|
||||||
|
|
||||||
if autoJoinNetwork != "" {
|
if autoJoinNetwork != "" {
|
||||||
|
|||||||
645
cmd/tui/main.go
645
cmd/tui/main.go
@@ -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,15 +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
|
||||||
|
|
||||||
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
|
||||||
@@ -117,26 +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),
|
||||||
peers: make(map[proto.PeerID]string),
|
unread: make(map[string]bool),
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -160,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
|
||||||
@@ -179,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
|
||||||
@@ -209,16 +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 {
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
@@ -226,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)
|
||||||
@@ -237,71 +352,219 @@ 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 {
|
||||||
|
n.addRoom(r)
|
||||||
|
}
|
||||||
|
for _, p := range evt.KnownPeers {
|
||||||
|
n.knownPeers[p.ID] = p.Alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m = m.updateStatus()
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
|
||||||
|
|
||||||
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:
|
||||||
m.invitePopup = evt.InviteString
|
m.invitePopup = evt.InviteGenerated
|
||||||
|
|
||||||
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,
|
||||||
|
}
|
||||||
|
key := netID + ":" + msg.Room
|
||||||
|
m.messages[key] = append(m.messages[key], e)
|
||||||
|
if key != m.msgKey() {
|
||||||
|
m.unread[key] = true
|
||||||
}
|
}
|
||||||
m.messages[msg.Room] = append(m.messages[msg.Room], e)
|
|
||||||
m = m.addRoom(msg.Room)
|
|
||||||
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 {
|
||||||
@@ -309,8 +572,106 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.input.SetValue("")
|
m.input.SetValue("")
|
||||||
|
|
||||||
room := m.rooms[m.activeRoom]
|
// /join <network-name>
|
||||||
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
if strings.HasPrefix(body, "/join ") {
|
||||||
|
name := strings.TrimSpace(strings.TrimPrefix(body, "/join "))
|
||||||
|
if name != "" {
|
||||||
|
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: name}))
|
||||||
|
}
|
||||||
|
return m, cmds
|
||||||
|
}
|
||||||
|
|
||||||
|
// /net <number|name> — switch active network
|
||||||
|
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
|
||||||
@@ -321,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
|
||||||
@@ -331,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
|
||||||
@@ -357,42 +713,39 @@ 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] {
|
||||||
ts := styleMsgTime.Render(e.at.Format("15:04"))
|
lineNum := styleLineNum.Render(fmt.Sprintf("[%d]", i+1))
|
||||||
|
ts := styleMsgTime.Render(formatMsgTime(e.at))
|
||||||
var from string
|
var from string
|
||||||
if e.fromMe {
|
if e.fromMe {
|
||||||
from = styleMsgMe.Render(e.from)
|
from = styleMsgMe.Render(e.from)
|
||||||
} 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 ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -402,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 · 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().
|
||||||
@@ -457,20 +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 {
|
||||||
lines = append(lines, styleRoom.Width(innerW).Render(" "+label))
|
prefix := " "
|
||||||
|
if m.unread[key] {
|
||||||
|
prefix = "* "
|
||||||
|
}
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
@@ -487,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))
|
||||||
}
|
}
|
||||||
@@ -510,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 := ""
|
||||||
@@ -520,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).
|
||||||
@@ -529,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 {
|
||||||
@@ -556,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 {
|
||||||
@@ -588,6 +954,14 @@ func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatMsgTime(t time.Time) string {
|
||||||
|
now := time.Now()
|
||||||
|
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
||||||
|
return t.Format("15:04")
|
||||||
|
}
|
||||||
|
return t.Format("Jan 2 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
return a
|
return a
|
||||||
@@ -599,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 {
|
||||||
@@ -613,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(),
|
||||||
|
|||||||
39
go.mod
39
go.mod
@@ -6,14 +6,16 @@ require (
|
|||||||
filippo.io/edwards25519 v1.2.0
|
filippo.io/edwards25519 v1.2.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/pion/webrtc/v3 v3.3.6
|
github.com/pion/webrtc/v3 v3.3.6
|
||||||
golang.org/x/crypto v0.24.0
|
golang.org/x/crypto v0.33.0
|
||||||
modernc.org/sqlite v1.53.0
|
modernc.org/sqlite v1.53.0
|
||||||
nhooyr.io/websocket v1.8.17
|
nhooyr.io/websocket v1.8.17
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
@@ -27,7 +29,26 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
|
||||||
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
||||||
|
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
|
||||||
|
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
|
||||||
|
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
|
||||||
|
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
|
||||||
|
github.com/getlantern/systray v1.2.2 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/go-stack/stack v1.8.0 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
@@ -35,6 +56,7 @@ require (
|
|||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
||||||
github.com/pion/datachannel v1.5.8 // indirect
|
github.com/pion/datachannel v1.5.8 // indirect
|
||||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||||
github.com/pion/ice/v2 v2.3.38 // indirect
|
github.com/pion/ice/v2 v2.3.38 // indirect
|
||||||
@@ -50,15 +72,24 @@ require (
|
|||||||
github.com/pion/stun v0.6.1 // indirect
|
github.com/pion/stun v0.6.1 // indirect
|
||||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/stretchr/testify v1.9.0 // indirect
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
|
github.com/wailsapp/wails/v2 v2.12.0 // indirect
|
||||||
github.com/wlynxg/anet v0.0.3 // indirect
|
github.com/wlynxg/anet v0.0.3 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/net v0.22.0 // indirect
|
golang.org/x/net v0.35.0 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.16.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.73.4 // indirect
|
modernc.org/libc v1.73.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
85
go.sum
85
go.sum
@@ -1,9 +1,13 @@
|
|||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
@@ -31,21 +35,63 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||||
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||||
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
|
||||||
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||||
|
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
|
||||||
|
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
|
||||||
|
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
|
||||||
|
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
|
||||||
|
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
|
||||||
|
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
|
||||||
|
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
|
||||||
|
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||||
|
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
|
||||||
|
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
|
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||||
|
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||||
|
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||||
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||||
|
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||||
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
@@ -60,6 +106,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
|||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||||
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||||
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
||||||
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
|
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
|
||||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||||
@@ -102,22 +150,45 @@ github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
|||||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||||
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
||||||
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
|
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||||
|
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
@@ -130,12 +201,15 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y
|
|||||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||||
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
@@ -144,17 +218,24 @@ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
|||||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -173,6 +254,7 @@ golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
|||||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
@@ -180,6 +262,8 @@ golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
@@ -187,6 +271,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
|||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
// Answerer: we haven't created a session yet, do it now.
|
// Answerer: we haven't created a session yet, do it now.
|
||||||
pc, err := newPC()
|
pc, err := newPC(m.ICEServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
log.Printf("anchor: new PC for answerer: %v", err)
|
log.Printf("anchor: new PC for answerer: %v", err)
|
||||||
@@ -412,7 +412,7 @@ func dispatchSignaling(
|
|||||||
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
|
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
|
||||||
// the peer's ekey, then sends the offer (ephemeral or static).
|
// the peer's ekey, then sends the offer (ephemeral or static).
|
||||||
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
|
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
|
||||||
pc, err := newPC()
|
pc, err := newPC(m.ICEServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -473,7 +473,7 @@ func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m
|
|||||||
|
|
||||||
// answerOffer processes an incoming offer and returns the PeerConnection.
|
// answerOffer processes an incoming offer and returns the PeerConnection.
|
||||||
func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) {
|
func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) {
|
||||||
pc, err := newPC()
|
pc, err := newPC(m.ICEServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -618,10 +618,9 @@ func hashNetName(name string) string {
|
|||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPC() (*webrtc.PeerConnection, error) {
|
func newPC(extra []webrtc.ICEServer) (*webrtc.PeerConnection, error) {
|
||||||
return webrtc.NewPeerConnection(webrtc.Configuration{
|
servers := append([]webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, extra...)
|
||||||
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
|
return webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: servers})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolPtr(b bool) *bool { return &b }
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ func (id *Identity) PeerInfo() proto.PeerInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PeerIDHex satisfies the invite.Signer interface.
|
||||||
|
func (id *Identity) PeerIDHex() string { return string(id.PeerID()) }
|
||||||
|
|
||||||
// Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
|
// Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
|
||||||
func (id *Identity) Sign(data []byte) string {
|
func (id *Identity) Sign(data []byte) string {
|
||||||
sig := ed25519.Sign(id.privateKey, data)
|
sig := ed25519.Sign(id.privateKey, data)
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
|
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
|
||||||
// to the signaling server. A yaw2 client that can parse the base64 JSON can join
|
// to the signaling server. A yaw2 client that can parse the base64 JSON can join
|
||||||
// the same network without knowing the plaintext name.
|
// the same network without knowing the plaintext name.
|
||||||
|
//
|
||||||
|
// Signed invites (waste-go extension): when `inviter` and `sig` are present,
|
||||||
|
// the invite was issued by a known peer. Receiving peers that enforce
|
||||||
|
// RequireInvite will reject hellos that carry no valid signed invite.
|
||||||
|
// YAW/2-only peers ignore both fields.
|
||||||
package invite
|
package invite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -24,26 +29,66 @@ type Invite struct {
|
|||||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||||
Network string `json:"network"` // plaintext network name
|
Network string `json:"network"` // plaintext network name
|
||||||
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
||||||
|
Inviter string `json:"inviter,omitempty"` // hex Ed25519 pubkey of the signing peer (waste-go extension)
|
||||||
|
Sig string `json:"sig,omitempty"` // hex Ed25519 sig over canonical payload (waste-go extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode returns a waste: invite string for the given anchor URL and network name.
|
// IsSigned reports whether the invite carries a signature.
|
||||||
|
func (inv Invite) IsSigned() bool { return inv.Inviter != "" && inv.Sig != "" }
|
||||||
|
|
||||||
|
// Signer can sign data and report its own peer ID.
|
||||||
|
type Signer interface {
|
||||||
|
Sign(data []byte) string
|
||||||
|
PeerIDHex() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier verifies an Ed25519 signature given a hex public key.
|
||||||
|
type Verifier func(publicKeyHex string, data []byte, sigHex string) error
|
||||||
|
|
||||||
|
// Encode returns an unsigned waste: invite string (backward compatible).
|
||||||
func Encode(anchor, network string) (string, error) {
|
func Encode(anchor, network string) (string, error) {
|
||||||
|
return marshal(Invite{
|
||||||
|
Anchor: anchor,
|
||||||
|
Network: network,
|
||||||
|
Net: NetHash(network),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeSigned returns a signed waste: invite string.
|
||||||
|
// The signature covers: anchor + NUL + network + NUL + net + NUL + inviter.
|
||||||
|
func EncodeSigned(anchor, network string, signer Signer) (string, error) {
|
||||||
if anchor == "" {
|
if anchor == "" {
|
||||||
return "", fmt.Errorf("anchor URL is required")
|
return "", fmt.Errorf("anchor URL is required")
|
||||||
}
|
}
|
||||||
if network == "" {
|
if network == "" {
|
||||||
return "", fmt.Errorf("network name is required")
|
return "", fmt.Errorf("network name is required")
|
||||||
}
|
}
|
||||||
h := sha256.Sum256([]byte("yaw2-net:" + network))
|
inviter := signer.PeerIDHex()
|
||||||
b, err := json.Marshal(Invite{
|
net := NetHash(network)
|
||||||
|
sig := signer.Sign(sigPayload(anchor, network, net, inviter))
|
||||||
|
return marshal(Invite{
|
||||||
Anchor: anchor,
|
Anchor: anchor,
|
||||||
Network: network,
|
Network: network,
|
||||||
Net: hex.EncodeToString(h[:]),
|
Net: net,
|
||||||
|
Inviter: inviter,
|
||||||
|
Sig: sig,
|
||||||
})
|
})
|
||||||
if err != nil {
|
}
|
||||||
return "", err
|
|
||||||
|
// Verify checks the invite signature and that the inviter is in the trusted set.
|
||||||
|
// Unsigned invites return nil — the caller decides whether to accept them.
|
||||||
|
func Verify(inv Invite, trusted map[string]bool, verify Verifier) error {
|
||||||
|
if !inv.IsSigned() {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
payload := sigPayload(inv.Anchor, inv.Network, inv.Net, inv.Inviter)
|
||||||
|
if err := verify(inv.Inviter, payload, inv.Sig); err != nil {
|
||||||
|
return fmt.Errorf("invite signature invalid: %w", err)
|
||||||
|
}
|
||||||
|
if !trusted[inv.Inviter] {
|
||||||
|
return fmt.Errorf("invite signed by unknown peer %s", inv.Inviter[:16])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode parses a waste: invite string and returns the Invite.
|
// Decode parses a waste: invite string and returns the Invite.
|
||||||
@@ -66,9 +111,20 @@ func Decode(s string) (Invite, error) {
|
|||||||
return inv, nil
|
return inv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetHash returns the full 64-char hex network hash for the given name
|
// NetHash returns the full 64-char hex network hash for the given name.
|
||||||
// (SHA-256("yaw2-net:" + name)). This is the `net` field sent to the anchor.
|
|
||||||
func NetHash(name string) string {
|
func NetHash(name string) string {
|
||||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func marshal(inv Invite) (string, error) {
|
||||||
|
b, err := json.Marshal(inv)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sigPayload(anchor, network, net, inviter string) []byte {
|
||||||
|
return []byte(anchor + "\x00" + network + "\x00" + net + "\x00" + inviter)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nhooyr.io/websocket"
|
"nhooyr.io/websocket"
|
||||||
@@ -126,6 +127,8 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
|
|
||||||
// Send initial state snapshot.
|
// Send initial state snapshot.
|
||||||
send(stateSnapshot(mgr))
|
send(stateSnapshot(mgr))
|
||||||
|
// Send stored history for each room so the UI is populated on connect.
|
||||||
|
sendStoredHistory(mgr, send)
|
||||||
|
|
||||||
scanner := bufio.NewScanner(conn)
|
scanner := bufio.NewScanner(conn)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
@@ -146,7 +149,6 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
case cmd.NetworkName != "":
|
case cmd.NetworkName != "":
|
||||||
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
||||||
case len(cmd.NetworkHash) == 64:
|
case len(cmd.NetworkHash) == 64:
|
||||||
// yaw2-compatible: join by full 64-char hex hash (net field)
|
|
||||||
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
||||||
default:
|
default:
|
||||||
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
|
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
|
||||||
@@ -156,7 +158,14 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(errMsg(fmt.Sprintf("join_network: %v", err)))
|
send(errMsg(fmt.Sprintf("join_network: %v", err)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// network_joined event (with share_dir) is emitted by Manager.Join/JoinByHash.
|
if n, ok := mgr.Get(netID); ok {
|
||||||
|
if cmd.RequireInvite {
|
||||||
|
n.Mesh.RequireInvite = true
|
||||||
|
}
|
||||||
|
if cmd.InviteString != "" {
|
||||||
|
n.Mesh.InviteString = cmd.InviteString
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = netID
|
_ = netID
|
||||||
|
|
||||||
case proto.CmdLeaveNetwork:
|
case proto.CmdLeaveNetwork:
|
||||||
@@ -207,6 +216,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
} else {
|
} else {
|
||||||
// Group chat → spec "chat" type: flat {type, mid, room, text, ts}
|
// Group chat → spec "chat" type: flat {type, mid, room, text, ts}
|
||||||
mid := randomHex(16)
|
mid := randomHex(16)
|
||||||
|
msgID := proto.ComputeMsgID(n.Identity.PeerID(), cmd.Room, ts, cmd.Body)
|
||||||
wire, err := json.Marshal(proto.PeerMessage{
|
wire, err := json.Marshal(proto.PeerMessage{
|
||||||
Type: proto.MsgChat,
|
Type: proto.MsgChat,
|
||||||
Mid: mid,
|
Mid: mid,
|
||||||
@@ -220,6 +230,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
n.Mesh.Broadcast(wire)
|
n.Mesh.Broadcast(wire)
|
||||||
local := &proto.ChatMessage{
|
local := &proto.ChatMessage{
|
||||||
Mid: mid,
|
Mid: mid,
|
||||||
|
MsgID: msgID,
|
||||||
From: n.Identity.PeerID(),
|
From: n.Identity.PeerID(),
|
||||||
Room: cmd.Room,
|
Room: cmd.Room,
|
||||||
Text: cmd.Body,
|
Text: cmd.Body,
|
||||||
@@ -233,6 +244,51 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case proto.CmdSendReaction:
|
||||||
|
n := mgr.Resolve(cmd.NetworkID)
|
||||||
|
if n == nil {
|
||||||
|
send(errMsg("send_reaction: not joined to any network"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cmd.ReactionMID == "" || cmd.ReactionEmoji == "" {
|
||||||
|
send(errMsg("send_reaction: reaction_mid and reaction_emoji are required"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wire, err := json.Marshal(proto.PeerMessage{
|
||||||
|
Type: proto.MsgReaction,
|
||||||
|
ReactionMID: cmd.ReactionMID,
|
||||||
|
ReactionEmoji: cmd.ReactionEmoji,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n.Mesh.Broadcast(wire)
|
||||||
|
n.Mesh.SaveReaction(cmd.ReactionMID, cmd.ReactionEmoji, string(n.Identity.PeerID()))
|
||||||
|
n.Mesh.Emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtReaction,
|
||||||
|
NetworkID: n.ID,
|
||||||
|
PeerID: ptr(n.Identity.PeerID()),
|
||||||
|
ReactionMID: cmd.ReactionMID,
|
||||||
|
ReactionEmoji: cmd.ReactionEmoji,
|
||||||
|
})
|
||||||
|
|
||||||
|
case proto.CmdCreateRoom:
|
||||||
|
n := mgr.Resolve(cmd.NetworkID)
|
||||||
|
if n == nil {
|
||||||
|
send(errMsg("create_room: not joined to any network"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(cmd.Room)
|
||||||
|
if name == "" || name == "general" {
|
||||||
|
send(errMsg("create_room: room name is required and cannot be 'general'"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := n.Store.SaveRoom(name); err != nil {
|
||||||
|
send(errMsg(fmt.Sprintf("create_room: %v", err)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
send(proto.IpcMessage{Type: proto.EvtRoomCreated, NetworkID: n.ID, Room: name})
|
||||||
|
|
||||||
case proto.CmdGetState:
|
case proto.CmdGetState:
|
||||||
send(stateSnapshot(mgr))
|
send(stateSnapshot(mgr))
|
||||||
|
|
||||||
@@ -298,7 +354,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
inv, err := invite.Encode(mgr.AnchorURL(), n.Name)
|
inv, err := invite.EncodeSigned(mgr.AnchorURL(), n.Name, n.Identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
||||||
continue
|
continue
|
||||||
@@ -306,7 +362,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(proto.IpcMessage{
|
send(proto.IpcMessage{
|
||||||
Type: proto.EvtInviteGenerated,
|
Type: proto.EvtInviteGenerated,
|
||||||
NetworkID: n.ID,
|
NetworkID: n.ID,
|
||||||
InviteString: inv,
|
InviteGenerated: inv,
|
||||||
})
|
})
|
||||||
|
|
||||||
case proto.CmdSetShareDir:
|
case proto.CmdSetShareDir:
|
||||||
@@ -320,6 +376,22 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case proto.CmdSetDownloadDir:
|
||||||
|
n := mgr.Resolve(cmd.NetworkID)
|
||||||
|
if n == nil {
|
||||||
|
send(errMsg("set_download_dir: not joined to any network"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cmd.Path == "" {
|
||||||
|
send(errMsg("set_download_dir: path is required"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !mgr.SetDownloadDir(n.ID, cmd.Path) {
|
||||||
|
send(errMsg("set_download_dir: network not found"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
send(stateSnapshot(mgr))
|
||||||
|
|
||||||
case proto.CmdSendFile:
|
case proto.CmdSendFile:
|
||||||
n := mgr.Resolve(cmd.NetworkID)
|
n := mgr.Resolve(cmd.NetworkID)
|
||||||
if n == nil {
|
if n == nil {
|
||||||
@@ -410,11 +482,79 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
|
|||||||
pi := all[0].Identity.PeerInfo()
|
pi := all[0].Identity.PeerInfo()
|
||||||
msg.LocalPeer = &pi
|
msg.LocalPeer = &pi
|
||||||
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
|
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
|
||||||
|
if extra, err := all[0].Store.Rooms(); err == nil {
|
||||||
|
for _, r := range extra {
|
||||||
|
msg.Rooms = append(msg.Rooms, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Include all historically-known peers so the UI can resolve aliases in history.
|
||||||
|
if known, err := all[0].Store.KnownPeers(); err == nil {
|
||||||
|
connected := map[proto.PeerID]bool{}
|
||||||
|
for _, p := range msg.ConnectedPeers {
|
||||||
|
connected[p.ID] = true
|
||||||
|
}
|
||||||
|
for id, alias := range known {
|
||||||
|
if connected[id] {
|
||||||
|
continue // already in ConnectedPeers
|
||||||
|
}
|
||||||
|
msg.KnownPeers = append(msg.KnownPeers, proto.PeerInfo{
|
||||||
|
ID: id,
|
||||||
|
Alias: alias,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendStoredHistory pushes recent messages for all known rooms to a newly-connected IPC client.
|
||||||
|
func sendStoredHistory(mgr *netmgr.Manager, send func(proto.IpcMessage)) {
|
||||||
|
all := mgr.All()
|
||||||
|
if len(all) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := all[0] // use first network; multi-network history follows same pattern
|
||||||
|
if n.Store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rooms := []string{"general"}
|
||||||
|
if extra, err := n.Store.Rooms(); err == nil {
|
||||||
|
rooms = append(rooms, extra...)
|
||||||
|
}
|
||||||
|
for _, room := range rooms {
|
||||||
|
msgs, err := n.Store.RecentMessagesSince(room, 0, 200)
|
||||||
|
if err != nil || len(msgs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
send(proto.IpcMessage{
|
||||||
|
Type: proto.EvtHistoryLoaded,
|
||||||
|
NetworkID: n.ID,
|
||||||
|
Room: room,
|
||||||
|
Messages: msgs,
|
||||||
|
})
|
||||||
|
// Send stored reactions for this room's messages.
|
||||||
|
rxns, err := n.Store.ReactionsForRoom(room)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for mid, byEmoji := range rxns {
|
||||||
|
for emoji, fromPeers := range byEmoji {
|
||||||
|
for _, fromPeer := range fromPeers {
|
||||||
|
pid := proto.PeerID(fromPeer)
|
||||||
|
send(proto.IpcMessage{
|
||||||
|
Type: proto.EvtReaction,
|
||||||
|
NetworkID: n.ID,
|
||||||
|
PeerID: &pid,
|
||||||
|
ReactionMID: mid,
|
||||||
|
ReactionEmoji: emoji,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func errMsg(s string) proto.IpcMessage {
|
func errMsg(s string) proto.IpcMessage {
|
||||||
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
|
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package mesh
|
package mesh
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -13,6 +14,9 @@ import (
|
|||||||
"github.com/waste-go/internal/store"
|
"github.com/waste-go/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ICEServer mirrors webrtc.ICEServer so callers don't import pion directly.
|
||||||
|
type ICEServer = webrtc.ICEServer
|
||||||
|
|
||||||
// PeerConn is a live connection to one peer.
|
// PeerConn is a live connection to one peer.
|
||||||
type PeerConn struct {
|
type PeerConn struct {
|
||||||
Info proto.PeerInfo
|
Info proto.PeerInfo
|
||||||
@@ -29,9 +33,12 @@ type Mesh struct {
|
|||||||
Store *store.Store // may be nil if persistence is disabled
|
Store *store.Store // may be nil if persistence is disabled
|
||||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||||
DownloadDir string // directory where received files are saved
|
DownloadDir string // directory where received files are saved
|
||||||
|
RequireInvite bool // waste-go ext: reject peers that present no valid signed invite
|
||||||
|
InviteString string // the invite this peer used to join (sent in hello to other peers)
|
||||||
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
|
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
|
||||||
// multi-share scanning without the mesh needing to know about shares.json.
|
// multi-share scanning without the mesh needing to know about shares.json.
|
||||||
ScanFiles func() []proto.FileEntry
|
ScanFiles func() []proto.FileEntry
|
||||||
|
ICEServers []ICEServer // extra ICE servers (e.g. TURN); appended to the default STUN entry
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
peers map[proto.PeerID]*PeerConn
|
peers map[proto.PeerID]*PeerConn
|
||||||
@@ -45,6 +52,12 @@ type Mesh struct {
|
|||||||
// attempt to connect to. Drained by the anchor client's runOnce loop.
|
// attempt to connect to. Drained by the anchor client's runOnce loop.
|
||||||
PendingConnect chan proto.PeerID
|
PendingConnect chan proto.PeerID
|
||||||
|
|
||||||
|
// historyRequested tracks rooms for which we have already sent a history_request
|
||||||
|
// this session. Reset on reconnect is intentional (new peers may have newer history).
|
||||||
|
historyMu sync.Mutex
|
||||||
|
historyRequested map[string]bool // room → true
|
||||||
|
historyFirstPeer proto.PeerID // ID of the peer we requested history from
|
||||||
|
|
||||||
// subscribers receive a copy of every event (fan-out to IPC clients)
|
// subscribers receive a copy of every event (fan-out to IPC clients)
|
||||||
subMu sync.Mutex
|
subMu sync.Mutex
|
||||||
subs []chan proto.IpcMessage
|
subs []chan proto.IpcMessage
|
||||||
@@ -60,9 +73,33 @@ func New(id *crypto.Identity, st *store.Store) *Mesh {
|
|||||||
outbound: make(map[string]*outboundTransfer),
|
outbound: make(map[string]*outboundTransfer),
|
||||||
inbound: make(map[string]*inboundTransfer),
|
inbound: make(map[string]*inboundTransfer),
|
||||||
PendingConnect: make(chan proto.PeerID, 32),
|
PendingConnect: make(chan proto.PeerID, 32),
|
||||||
|
historyRequested: make(map[string]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trustedPeerIDs returns a set of peer IDs trusted on this network:
|
||||||
|
// all currently connected peers plus all peers in the persistent store.
|
||||||
|
func (m *Mesh) trustedPeerIDs() map[string]bool {
|
||||||
|
trusted := map[string]bool{}
|
||||||
|
// Own identity is always trusted.
|
||||||
|
trusted[string(m.Identity.PeerID())] = true
|
||||||
|
// Connected peers.
|
||||||
|
m.mu.RLock()
|
||||||
|
for id := range m.peers {
|
||||||
|
trusted[string(id)] = true
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
// Previously seen peers from the store.
|
||||||
|
if m.Store != nil {
|
||||||
|
if known, err := m.Store.KnownPeers(); err == nil {
|
||||||
|
for id := range known {
|
||||||
|
trusted[string(id)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trusted
|
||||||
|
}
|
||||||
|
|
||||||
// ScanShareDir returns the list of files in the local share directory.
|
// ScanShareDir returns the list of files in the local share directory.
|
||||||
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
||||||
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
||||||
@@ -109,6 +146,17 @@ func (m *Mesh) AddPeer(conn *PeerConn) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveReaction persists a reaction if a store is configured.
|
||||||
|
// Duplicate (mid, emoji, fromPeer) triples are silently dropped.
|
||||||
|
func (m *Mesh) SaveReaction(mid, emoji, fromPeer string) {
|
||||||
|
if m.Store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.Store.SaveReaction(mid, emoji, fromPeer); err != nil {
|
||||||
|
log.Printf("mesh: store reaction %s/%s: %v", mid, emoji, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SaveMessage persists a chat message if a store is configured.
|
// SaveMessage persists a chat message if a store is configured.
|
||||||
// Duplicate mids are silently dropped.
|
// Duplicate mids are silently dropped.
|
||||||
func (m *Mesh) SaveMessage(msg *proto.ChatMessage) {
|
func (m *Mesh) SaveMessage(msg *proto.ChatMessage) {
|
||||||
@@ -206,6 +254,122 @@ func (m *Mesh) Unsubscribe(ch <-chan proto.IpcMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequestHistoryFrom sends history_request messages to peerID for all rooms
|
||||||
|
// we know about but haven't yet requested this session. Only contacts the first
|
||||||
|
// peer we connect to, to avoid fan-out amplification.
|
||||||
|
func (m *Mesh) RequestHistoryFrom(peerID proto.PeerID) {
|
||||||
|
if m.Store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.historyMu.Lock()
|
||||||
|
if m.historyFirstPeer != "" && m.historyFirstPeer != peerID {
|
||||||
|
m.historyMu.Unlock()
|
||||||
|
return // only request from the first peer
|
||||||
|
}
|
||||||
|
m.historyFirstPeer = peerID
|
||||||
|
m.historyMu.Unlock()
|
||||||
|
|
||||||
|
rooms, err := m.Store.Rooms()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Always include "general" even if not explicitly created.
|
||||||
|
roomSet := map[string]bool{"general": true}
|
||||||
|
for _, r := range rooms {
|
||||||
|
roomSet[r] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
m.historyMu.Lock()
|
||||||
|
var toRequest []string
|
||||||
|
for r := range roomSet {
|
||||||
|
if !m.historyRequested[r] {
|
||||||
|
m.historyRequested[r] = true
|
||||||
|
toRequest = append(toRequest, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.historyMu.Unlock()
|
||||||
|
|
||||||
|
for _, room := range toRequest {
|
||||||
|
req, err := json.Marshal(proto.PeerMessage{
|
||||||
|
Type: proto.MsgHistoryRequest,
|
||||||
|
Room: room,
|
||||||
|
Limit: 200,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.SendTo(peerID, req)
|
||||||
|
log.Printf("mesh: sent history_request room=%s to %s", room, peerID.Short())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHistoryRequest responds to a history_request from a peer.
|
||||||
|
func (m *Mesh) HandleHistoryRequest(from proto.PeerID, room string, sinceMs int64, limit int) {
|
||||||
|
if m.Store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgs, err := m.Store.RecentMessagesSince(room, sinceMs, limit)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mesh: history_request from %s room=%s: %v", from.Short(), room, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up aliases for from_peer values.
|
||||||
|
entries := make([]proto.HistoryEntry, 0, len(msgs))
|
||||||
|
for _, msg := range msgs {
|
||||||
|
entries = append(entries, proto.HistoryEntry{
|
||||||
|
Mid: msg.Mid,
|
||||||
|
From: string(msg.From),
|
||||||
|
FromAlias: m.Store.PeerAlias(msg.From),
|
||||||
|
Text: msg.Text,
|
||||||
|
Ts: msg.Ts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk, err := json.Marshal(proto.PeerMessage{
|
||||||
|
Type: proto.MsgHistoryChunk,
|
||||||
|
Room: room,
|
||||||
|
History: entries,
|
||||||
|
HistoryDone: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.SendTo(from, chunk)
|
||||||
|
log.Printf("mesh: sent history_chunk room=%s to %s: %d msgs", room, from.Short(), len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHistoryChunk saves received history messages and emits history_loaded.
|
||||||
|
func (m *Mesh) HandleHistoryChunk(room string, entries []proto.HistoryEntry) {
|
||||||
|
if m.Store == nil || len(entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var saved []proto.ChatMessage
|
||||||
|
for _, e := range entries {
|
||||||
|
msg := &proto.ChatMessage{
|
||||||
|
Mid: e.Mid,
|
||||||
|
MsgID: e.Mid, // mid is already content-addressed for gossipped messages
|
||||||
|
From: proto.PeerID(e.From),
|
||||||
|
Room: room,
|
||||||
|
Text: e.Text,
|
||||||
|
Ts: e.Ts,
|
||||||
|
}
|
||||||
|
if err := m.Store.SaveMessage(msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
saved = append(saved, *msg)
|
||||||
|
}
|
||||||
|
if len(saved) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtHistoryLoaded,
|
||||||
|
Room: room,
|
||||||
|
Messages: saved,
|
||||||
|
})
|
||||||
|
log.Printf("mesh: history_chunk room=%s: %d/%d new messages", room, len(saved), len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
// Emit sends an event to all IPC subscribers (exported for ipc/nat packages).
|
// Emit sends an event to all IPC subscribers (exported for ipc/nat packages).
|
||||||
func (m *Mesh) Emit(msg proto.IpcMessage) {
|
func (m *Mesh) Emit(msg proto.IpcMessage) {
|
||||||
m.emit(msg)
|
m.emit(msg)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/pion/webrtc/v3"
|
"github.com/pion/webrtc/v3"
|
||||||
|
|
||||||
"github.com/waste-go/internal/crypto"
|
"github.com/waste-go/internal/crypto"
|
||||||
|
"github.com/waste-go/internal/invite"
|
||||||
"github.com/waste-go/internal/proto"
|
"github.com/waste-go/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ func WireDataChannel(
|
|||||||
Nick: id.Alias,
|
Nick: id.Alias,
|
||||||
Caps: []string{"chat", "file"},
|
Caps: []string{"chat", "file"},
|
||||||
Sig: id.Sign(bindBytes),
|
Sig: id.Sign(bindBytes),
|
||||||
|
Invite: m.InviteString,
|
||||||
}
|
}
|
||||||
helloJSON, _ := json.Marshal(hello)
|
helloJSON, _ := json.Marshal(hello)
|
||||||
if err := dc.SendText(string(helloJSON)); err != nil {
|
if err := dc.SendText(string(helloJSON)); err != nil {
|
||||||
@@ -149,6 +151,37 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
|||||||
log.Printf("peer: bad hello from %s: %v", from.Short(), err)
|
log.Printf("peer: bad hello from %s: %v", from.Short(), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invite enforcement (waste-go extension).
|
||||||
|
if m.RequireInvite {
|
||||||
|
if hello.Invite == "" {
|
||||||
|
log.Printf("peer: rejecting %s — no invite presented (RequireInvite=true)", from.Short())
|
||||||
|
m.Emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtError,
|
||||||
|
ErrorMessage: fmt.Sprintf("peer %s rejected: no invite", from.Short()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inv, err := invite.Decode(hello.Invite)
|
||||||
|
if err != nil || !inv.IsSigned() {
|
||||||
|
log.Printf("peer: rejecting %s — invite not signed: %v", from.Short(), err)
|
||||||
|
m.Emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtError,
|
||||||
|
ErrorMessage: fmt.Sprintf("peer %s rejected: invite not signed", from.Short()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trusted := m.trustedPeerIDs()
|
||||||
|
if err := invite.Verify(inv, trusted, crypto.Verify); err != nil {
|
||||||
|
log.Printf("peer: rejecting %s — %v", from.Short(), err)
|
||||||
|
m.Emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtError,
|
||||||
|
ErrorMessage: fmt.Sprintf("peer %s rejected: %v", from.Short(), err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update alias once we have the verified nick.
|
// Update alias once we have the verified nick.
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if conn, ok := m.peers[from]; ok {
|
if conn, ok := m.peers[from]; ok {
|
||||||
@@ -164,6 +197,8 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
|||||||
})
|
})
|
||||||
// Tell the new peer about everyone we can currently see.
|
// Tell the new peer about everyone we can currently see.
|
||||||
go m.sendGossipTo(from)
|
go m.sendGossipTo(from)
|
||||||
|
// Request message history from this peer (EXT-007).
|
||||||
|
go m.RequestHistoryFrom(from)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +215,7 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
|||||||
case proto.MsgChat:
|
case proto.MsgChat:
|
||||||
chat := &proto.ChatMessage{
|
chat := &proto.ChatMessage{
|
||||||
Mid: midOrRandom(msg.Mid),
|
Mid: midOrRandom(msg.Mid),
|
||||||
|
MsgID: proto.ComputeMsgID(from, msg.Room, msg.Ts, msg.Text),
|
||||||
From: from,
|
From: from,
|
||||||
Room: msg.Room,
|
Room: msg.Room,
|
||||||
Text: msg.Text,
|
Text: msg.Text,
|
||||||
@@ -234,7 +270,7 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
|||||||
m.acceptIncoming(msg, from)
|
m.acceptIncoming(msg, from)
|
||||||
|
|
||||||
case proto.MsgFileAccept:
|
case proto.MsgFileAccept:
|
||||||
m.startSend(msg.Xid, from)
|
m.startSend(msg.Xid, from, msg.ResumeOffset)
|
||||||
|
|
||||||
case proto.MsgFileCancel:
|
case proto.MsgFileCancel:
|
||||||
log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason)
|
log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason)
|
||||||
@@ -265,6 +301,24 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("mesh: gossip from %s: %d hints, %d new", from.Short(), len(msg.Gossip.Peers), newPeers)
|
log.Printf("mesh: gossip from %s: %d hints, %d new", from.Short(), len(msg.Gossip.Peers), newPeers)
|
||||||
|
case proto.MsgHistoryRequest:
|
||||||
|
go m.HandleHistoryRequest(from, msg.Room, msg.Since, msg.Limit)
|
||||||
|
|
||||||
|
case proto.MsgHistoryChunk:
|
||||||
|
go m.HandleHistoryChunk(msg.Room, msg.History)
|
||||||
|
|
||||||
|
case proto.MsgReaction:
|
||||||
|
if msg.ReactionMID == "" || msg.ReactionEmoji == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.SaveReaction(msg.ReactionMID, msg.ReactionEmoji, string(from))
|
||||||
|
m.emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtReaction,
|
||||||
|
PeerID: peerIDPtr(from),
|
||||||
|
ReactionMID: msg.ReactionMID,
|
||||||
|
ReactionEmoji: msg.ReactionEmoji,
|
||||||
|
})
|
||||||
|
|
||||||
case proto.MsgPing:
|
case proto.MsgPing:
|
||||||
log.Printf("mesh: ping from %s", from.Short())
|
log.Printf("mesh: ping from %s", from.Short())
|
||||||
case proto.MsgPong:
|
case proto.MsgPong:
|
||||||
|
|||||||
@@ -40,7 +40,96 @@ type inboundTransfer struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
tmp *os.File
|
tmp *os.File
|
||||||
hasher hash.Hash
|
hasher hash.Hash
|
||||||
written int64
|
written int64 // total bytes received; starts at resumeOffset when resuming
|
||||||
|
resumePath string // path to existing .tmp when resuming; empty for new transfers
|
||||||
|
resumeOffset int64 // bytes already present in resumePath
|
||||||
|
metaPath string // path to the .tmp.meta sidecar
|
||||||
|
}
|
||||||
|
|
||||||
|
// partialMeta is written as a JSON sidecar alongside each in-progress .tmp file.
|
||||||
|
// It survives interruptions so the receiver can resume later.
|
||||||
|
type partialMeta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPartial scans dir for a .tmp.meta sidecar whose sha256 matches.
|
||||||
|
// Returns (tmpPath, metaPath, offset) — all empty/zero if no match.
|
||||||
|
func findPartial(dir, sha256hex string) (tmpPath, metaPath string, offset int64) {
|
||||||
|
metas, _ := filepath.Glob(filepath.Join(dir, "*.tmp.meta"))
|
||||||
|
for _, mp := range metas {
|
||||||
|
data, err := os.ReadFile(mp)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m partialMeta
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.SHA256 != sha256hex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tp := strings.TrimSuffix(mp, ".meta")
|
||||||
|
info, err := os.Stat(tp)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return tp, mp, info.Size()
|
||||||
|
}
|
||||||
|
return "", "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePartialMeta(path string, t *inboundTransfer) {
|
||||||
|
data, _ := json.Marshal(partialMeta{
|
||||||
|
Name: t.name,
|
||||||
|
SHA256: t.sha256,
|
||||||
|
From: string(t.from),
|
||||||
|
Size: t.size,
|
||||||
|
})
|
||||||
|
os.WriteFile(path, data, 0o644) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanResumable scans the download directory for .tmp.meta sidecars left by
|
||||||
|
// interrupted transfers and emits a resumable_transfers IPC event listing them.
|
||||||
|
// Called once after a network is joined so the UI can show pending transfers.
|
||||||
|
func (m *Mesh) ScanResumable() {
|
||||||
|
if m.DownloadDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metas, _ := filepath.Glob(filepath.Join(m.DownloadDir, "*.tmp.meta"))
|
||||||
|
var files []proto.ResumableFile
|
||||||
|
for _, mp := range metas {
|
||||||
|
data, err := os.ReadFile(mp)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var meta partialMeta
|
||||||
|
if err := json.Unmarshal(data, &meta); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tp := strings.TrimSuffix(mp, ".meta")
|
||||||
|
info, err := os.Stat(tp)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, proto.ResumableFile{
|
||||||
|
Name: meta.Name,
|
||||||
|
SHA256: meta.SHA256,
|
||||||
|
From: meta.From,
|
||||||
|
Size: meta.Size,
|
||||||
|
Offset: info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtResumableTransfers,
|
||||||
|
ResumableFiles: files,
|
||||||
|
})
|
||||||
|
log.Printf("transfer: %d resumable transfer(s) found in %s", len(files), m.DownloadDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OfferFile reads filename from ShareDir, computes its SHA-256, and sends a
|
// OfferFile reads filename from ShareDir, computes its SHA-256, and sends a
|
||||||
@@ -102,28 +191,49 @@ func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// acceptIncoming is called when we receive file-offer from a peer.
|
// acceptIncoming is called when we receive file-offer from a peer.
|
||||||
// It stores the inbound state and immediately sends file-accept (auto-accept).
|
// Checks for a resumable partial download matching the offer's sha256.
|
||||||
|
// Sends file-accept immediately, with a non-zero resume_offset when resuming.
|
||||||
func (m *Mesh) acceptIncoming(msg proto.PeerMessage, from proto.PeerID) {
|
func (m *Mesh) acceptIncoming(msg proto.PeerMessage, from proto.PeerID) {
|
||||||
m.transferMu.Lock()
|
t := &inboundTransfer{
|
||||||
m.inbound[msg.Xid] = &inboundTransfer{
|
|
||||||
from: from,
|
from: from,
|
||||||
name: msg.Name,
|
name: msg.Name,
|
||||||
size: msg.Size,
|
size: msg.Size,
|
||||||
sha256: msg.SHA256,
|
sha256: msg.SHA256,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var resumeOffset int64
|
||||||
|
if m.DownloadDir != "" {
|
||||||
|
if tp, mp, off := findPartial(m.DownloadDir, msg.SHA256); tp != "" {
|
||||||
|
t.resumePath = tp
|
||||||
|
t.metaPath = mp
|
||||||
|
t.resumeOffset = off
|
||||||
|
t.written = off
|
||||||
|
resumeOffset = off
|
||||||
|
log.Printf("transfer: found partial for %s at %s (%d/%d bytes)", msg.Name, tp, off, msg.Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.transferMu.Lock()
|
||||||
|
m.inbound[msg.Xid] = t
|
||||||
m.transferMu.Unlock()
|
m.transferMu.Unlock()
|
||||||
|
|
||||||
accept, _ := json.Marshal(proto.PeerMessage{
|
accept, _ := json.Marshal(proto.PeerMessage{
|
||||||
Type: proto.MsgFileAccept,
|
Type: proto.MsgFileAccept,
|
||||||
Xid: msg.Xid,
|
Xid: msg.Xid,
|
||||||
|
ResumeOffset: resumeOffset,
|
||||||
})
|
})
|
||||||
m.SendTo(from, accept)
|
m.SendTo(from, accept)
|
||||||
|
if resumeOffset > 0 {
|
||||||
|
log.Printf("transfer: resuming %s from byte %d xid=%s", msg.Name, resumeOffset, msg.Xid[:8])
|
||||||
|
} else {
|
||||||
log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8])
|
log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startSend opens the binary DataChannel "f:<xid>" and streams the file.
|
// startSend opens the binary DataChannel "f:<xid>" and streams the file.
|
||||||
// Called when we receive file-accept from the peer.
|
// Called when we receive file-accept from the peer. resumeOffset is non-zero
|
||||||
func (m *Mesh) startSend(xid string, from proto.PeerID) {
|
// when the receiver is resuming a previous partial download.
|
||||||
|
func (m *Mesh) startSend(xid string, from proto.PeerID, resumeOffset int64) {
|
||||||
m.transferMu.Lock()
|
m.transferMu.Lock()
|
||||||
t, ok := m.outbound[xid]
|
t, ok := m.outbound[xid]
|
||||||
m.transferMu.Unlock()
|
m.transferMu.Unlock()
|
||||||
@@ -156,13 +266,13 @@ func (m *Mesh) startSend(xid string, from proto.PeerID) {
|
|||||||
dc.OnOpen(func() {
|
dc.OnOpen(func() {
|
||||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
if msg.IsString && string(msg.Data) == "ok" {
|
if msg.IsString && string(msg.Data) == "ok" {
|
||||||
go m.sendFileChunks(dc, t, xid)
|
go m.sendFileChunks(dc, t, xid, resumeOffset)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid string) {
|
func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid string, offset int64) {
|
||||||
defer func() {
|
defer func() {
|
||||||
m.transferMu.Lock()
|
m.transferMu.Lock()
|
||||||
delete(m.outbound, xid)
|
delete(m.outbound, xid)
|
||||||
@@ -177,6 +287,15 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
|
if offset > 0 {
|
||||||
|
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
log.Printf("transfer: seek to %d xid=%s: %v", offset, xid[:8], err)
|
||||||
|
dc.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("transfer: resuming send from byte %d xid=%s", offset, xid[:8])
|
||||||
|
}
|
||||||
|
|
||||||
// Backpressure: block when the DC buffer is full.
|
// Backpressure: block when the DC buffer is full.
|
||||||
resume := make(chan struct{}, 1)
|
resume := make(chan struct{}, 1)
|
||||||
dc.SetBufferedAmountLowThreshold(fileBufferLowWater)
|
dc.SetBufferedAmountLowThreshold(fileBufferLowWater)
|
||||||
@@ -188,7 +307,7 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
|
|||||||
})
|
})
|
||||||
|
|
||||||
buf := make([]byte, fileChunkSize)
|
buf := make([]byte, fileChunkSize)
|
||||||
var sent int64
|
sent := offset // start progress reporting from where the receiver left off
|
||||||
|
|
||||||
for {
|
for {
|
||||||
n, readErr := f.Read(buf)
|
n, readErr := f.Read(buf)
|
||||||
@@ -229,8 +348,9 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleInboundFileDC is called from the anchor when a "f:<xid>" DataChannel
|
// HandleInboundFileDC is called from the anchor when a "f:<xid>" DataChannel
|
||||||
// arrives on a PeerConnection. It writes chunks to a temp file, verifies the
|
// arrives on a PeerConnection. It writes chunks to a temp file (or resumes an
|
||||||
// SHA-256 on close, and emits EvtFileComplete.
|
// existing partial), verifies the SHA-256 on close, and emits EvtFileComplete.
|
||||||
|
// Interrupted transfers keep their .tmp and .meta files for future resume.
|
||||||
func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from proto.PeerID) {
|
func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from proto.PeerID) {
|
||||||
m.transferMu.Lock()
|
m.transferMu.Lock()
|
||||||
t, ok := m.inbound[xid]
|
t, ok := m.inbound[xid]
|
||||||
@@ -247,16 +367,43 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
|||||||
log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err)
|
log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if t.resumePath != "" {
|
||||||
|
// Resume: open existing .tmp in read-write mode, seek to end for appending.
|
||||||
|
f, err := os.OpenFile(t.resumePath, os.O_RDWR, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("transfer: open resume file %s: %v", t.resumePath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Restore the hasher state by re-hashing the bytes already on disk.
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := f.Seek(0, io.SeekStart); err == nil {
|
||||||
|
io.Copy(h, f) //nolint:errcheck
|
||||||
|
}
|
||||||
|
if _, err := f.Seek(0, io.SeekEnd); err != nil {
|
||||||
|
log.Printf("transfer: seek to end xid=%s: %v", xid[:8], err)
|
||||||
|
f.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.tmp = f
|
||||||
|
t.hasher = h
|
||||||
|
log.Printf("transfer: resuming inbound %s at byte %d xid=%s", t.name, t.resumeOffset, xid[:8])
|
||||||
|
} else {
|
||||||
tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp")
|
tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("transfer: create temp file: %v", err)
|
log.Printf("transfer: create temp file: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.mu.Lock()
|
|
||||||
t.tmp = tmp
|
t.tmp = tmp
|
||||||
t.hasher = sha256.New()
|
t.hasher = sha256.New()
|
||||||
t.mu.Unlock()
|
t.metaPath = tmp.Name() + ".meta"
|
||||||
|
writePartialMeta(t.metaPath, t)
|
||||||
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
|
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
|
||||||
|
}
|
||||||
|
|
||||||
// Signal sender that we are ready — it will not start streaming until
|
// Signal sender that we are ready — it will not start streaming until
|
||||||
// it receives this, guaranteeing our OnMessage/OnClose are registered first.
|
// it receives this, guaranteeing our OnMessage/OnClose are registered first.
|
||||||
dc.SendText("ok") //nolint:errcheck
|
dc.SendText("ok") //nolint:errcheck
|
||||||
@@ -298,11 +445,12 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
|||||||
dc.OnClose(func() {
|
dc.OnClose(func() {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
tmp := t.tmp
|
tmp := t.tmp
|
||||||
|
metaPath := t.metaPath
|
||||||
actualSha := ""
|
actualSha := ""
|
||||||
if t.hasher != nil {
|
if t.hasher != nil {
|
||||||
actualSha = hex.EncodeToString(t.hasher.Sum(nil))
|
actualSha = hex.EncodeToString(t.hasher.Sum(nil))
|
||||||
}
|
}
|
||||||
name, expectedSha := t.name, t.sha256
|
name, expectedSha, size, written := t.name, t.sha256, t.size, t.written
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
m.transferMu.Lock()
|
m.transferMu.Lock()
|
||||||
@@ -312,10 +460,21 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
|||||||
if tmp == nil {
|
if tmp == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
|
|
||||||
|
// Incomplete — keep .tmp and .meta for future resume.
|
||||||
|
if written < size {
|
||||||
|
log.Printf("transfer: interrupted %s at %d/%d bytes xid=%s (resumable)", name, written, size, xid[:8])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full receive — verify integrity.
|
||||||
if actualSha != expectedSha {
|
if actualSha != expectedSha {
|
||||||
os.Remove(tmp.Name())
|
os.Remove(tmpName)
|
||||||
|
if metaPath != "" {
|
||||||
|
os.Remove(metaPath)
|
||||||
|
}
|
||||||
log.Printf("transfer: sha256 mismatch xid=%s got=%s want=%s", xid[:8], actualSha[:8], expectedSha[:8])
|
log.Printf("transfer: sha256 mismatch xid=%s got=%s want=%s", xid[:8], actualSha[:8], expectedSha[:8])
|
||||||
m.Emit(proto.IpcMessage{
|
m.Emit(proto.IpcMessage{
|
||||||
Type: proto.EvtError,
|
Type: proto.EvtError,
|
||||||
@@ -325,13 +484,17 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Success — remove sidecar and move to final path.
|
||||||
|
if metaPath != "" {
|
||||||
|
os.Remove(metaPath)
|
||||||
|
}
|
||||||
final := filepath.Join(m.DownloadDir, name)
|
final := filepath.Join(m.DownloadDir, name)
|
||||||
if _, err := os.Stat(final); err == nil {
|
if _, err := os.Stat(final); err == nil {
|
||||||
ext := filepath.Ext(name)
|
ext := filepath.Ext(name)
|
||||||
base := strings.TrimSuffix(name, ext)
|
base := strings.TrimSuffix(name, ext)
|
||||||
final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+ext)
|
final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+ext)
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmp.Name(), final); err != nil {
|
if err := os.Rename(tmpName, final); err != nil {
|
||||||
log.Printf("transfer: rename xid=%s: %v", xid[:8], err)
|
log.Printf("transfer: rename xid=%s: %v", xid[:8], err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,20 @@ package netmgr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v3"
|
||||||
|
|
||||||
"github.com/waste-go/internal/anchor"
|
"github.com/waste-go/internal/anchor"
|
||||||
"github.com/waste-go/internal/crypto"
|
"github.com/waste-go/internal/crypto"
|
||||||
@@ -26,8 +33,11 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
MasterIdentity *crypto.Identity
|
MasterIdentity *crypto.Identity
|
||||||
StoreDir string // base directory for per-network SQLite files
|
StoreDir string // base directory for per-network SQLite files
|
||||||
|
DownloadDir string // base directory for received files; defaults to StoreDir if empty
|
||||||
AnchorURL string // WebSocket anchor URL used for all networks
|
AnchorURL string // WebSocket anchor URL used for all networks
|
||||||
ShareDir string // default share directory; overridden per network via Join or SetShareDir
|
ShareDir string // default share directory; overridden per network via Join or SetShareDir
|
||||||
|
TurnURL string // optional TURN server URL, e.g. "turn:your-vps:3478"
|
||||||
|
TurnSecret string // shared secret for coturn use-auth-secret HMAC credential
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network is a single joined network context.
|
// Network is a single joined network context.
|
||||||
@@ -110,9 +120,12 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
|
|||||||
} else if mgr.cfg.ShareDir != "" {
|
} else if mgr.cfg.ShareDir != "" {
|
||||||
m.ShareDir = mgr.cfg.ShareDir
|
m.ShareDir = mgr.cfg.ShareDir
|
||||||
}
|
}
|
||||||
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full)
|
m.DownloadDir = filepath.Join(mgr.downloadBase(), "downloads-"+netID_full)
|
||||||
capturedNetID := netID
|
capturedNetID := netID
|
||||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
|
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
|
||||||
|
if ice := mgr.turnICEServers(); ice != nil {
|
||||||
|
m.ICEServers = ice
|
||||||
|
}
|
||||||
|
|
||||||
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
|
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
|
||||||
meshEvents := m.Subscribe()
|
meshEvents := m.Subscribe()
|
||||||
@@ -149,6 +162,8 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go m.ScanResumable()
|
||||||
|
|
||||||
mgr.emit(proto.IpcMessage{
|
mgr.emit(proto.IpcMessage{
|
||||||
Type: proto.EvtNetworkJoined,
|
Type: proto.EvtNetworkJoined,
|
||||||
NetworkID: netID,
|
NetworkID: netID,
|
||||||
@@ -195,9 +210,12 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
|||||||
} else if mgr.cfg.ShareDir != "" {
|
} else if mgr.cfg.ShareDir != "" {
|
||||||
m.ShareDir = mgr.cfg.ShareDir
|
m.ShareDir = mgr.cfg.ShareDir
|
||||||
}
|
}
|
||||||
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID)
|
m.DownloadDir = filepath.Join(mgr.downloadBase(), "downloads-"+netID)
|
||||||
capturedNetID2 := netID
|
capturedNetID2 := netID
|
||||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
|
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
|
||||||
|
if ice := mgr.turnICEServers(); ice != nil {
|
||||||
|
m.ICEServers = ice
|
||||||
|
}
|
||||||
|
|
||||||
meshEvents := m.Subscribe()
|
meshEvents := m.Subscribe()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -233,6 +251,8 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go m.ScanResumable()
|
||||||
|
|
||||||
mgr.emit(proto.IpcMessage{
|
mgr.emit(proto.IpcMessage{
|
||||||
Type: proto.EvtNetworkJoined,
|
Type: proto.EvtNetworkJoined,
|
||||||
NetworkID: netID,
|
NetworkID: netID,
|
||||||
@@ -278,6 +298,28 @@ func (mgr *Manager) SetShareDir(netID, path string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// downloadBase returns the configured download base directory, falling back to StoreDir.
|
||||||
|
func (mgr *Manager) downloadBase() string {
|
||||||
|
if mgr.cfg.DownloadDir != "" {
|
||||||
|
return mgr.cfg.DownloadDir
|
||||||
|
}
|
||||||
|
return mgr.cfg.StoreDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDownloadDir updates the download directory for an already-joined network.
|
||||||
|
// Changes take effect for the next incoming file transfer on that network.
|
||||||
|
func (mgr *Manager) SetDownloadDir(netID, path string) bool {
|
||||||
|
mgr.mu.RLock()
|
||||||
|
net, ok := mgr.networks[netID]
|
||||||
|
mgr.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
net.Mesh.DownloadDir = path
|
||||||
|
log.Printf("netmgr: download dir for %q set to %q", net.Name, path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// LeaveAll leaves every joined network.
|
// LeaveAll leaves every joined network.
|
||||||
func (mgr *Manager) LeaveAll() {
|
func (mgr *Manager) LeaveAll() {
|
||||||
mgr.mu.RLock()
|
mgr.mu.RLock()
|
||||||
@@ -411,6 +453,26 @@ func (mgr *Manager) emit(msg proto.IpcMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// turnICEServers returns TURN ICE servers if TurnURL and TurnSecret are set,
|
||||||
|
// using coturn's use-auth-secret HMAC-SHA1 time-limited credential scheme.
|
||||||
|
// Returns nil if TURN is not configured.
|
||||||
|
func (mgr *Manager) turnICEServers() []webrtc.ICEServer {
|
||||||
|
if mgr.cfg.TurnURL == "" || mgr.cfg.TurnSecret == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Username = Unix timestamp 1 hour from now.
|
||||||
|
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
|
||||||
|
mac := hmac.New(sha1.New, []byte(mgr.cfg.TurnSecret))
|
||||||
|
mac.Write([]byte(expiry))
|
||||||
|
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
return []webrtc.ICEServer{{
|
||||||
|
URLs: []string{mgr.cfg.TurnURL},
|
||||||
|
Username: expiry,
|
||||||
|
Credential: credential,
|
||||||
|
CredentialType: webrtc.ICECredentialTypePassword,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func hashNetName(name string) string {
|
func hashNetName(name string) string {
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
// Binary data (keys, signatures) is hex-encoded; signaling boxes are base64.
|
// Binary data (keys, signatures) is hex-encoded; signaling boxes are base64.
|
||||||
package proto
|
package proto
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -45,6 +49,9 @@ const (
|
|||||||
MsgFileDone MsgType = "file-done"
|
MsgFileDone MsgType = "file-done"
|
||||||
MsgPing MsgType = "ping"
|
MsgPing MsgType = "ping"
|
||||||
MsgPong MsgType = "pong"
|
MsgPong MsgType = "pong"
|
||||||
|
MsgHistoryRequest MsgType = "history_request"
|
||||||
|
MsgHistoryChunk MsgType = "history_chunk"
|
||||||
|
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").
|
||||||
@@ -77,17 +84,49 @@ type PeerMessage struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
SHA256 string `json:"sha256,omitempty"`
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
ResumeOffset int64 `json:"resume_offset,omitempty"` // waste-go ext: non-zero in file-accept when resuming
|
||||||
|
|
||||||
// file-done / file-cancel / file-accept just need xid (already above)
|
// file-done / file-cancel / file-accept just need xid (already above)
|
||||||
Reason string `json:"reason,omitempty"` // file-cancel
|
Reason string `json:"reason,omitempty"` // file-cancel
|
||||||
|
|
||||||
Seq *uint64 `json:"seq,omitempty"` // ping/pong
|
Seq *uint64 `json:"seq,omitempty"` // ping/pong
|
||||||
|
|
||||||
|
// history_request fields
|
||||||
|
Since int64 `json:"since,omitempty"` // Unix ms; 0 = no lower bound
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
|
||||||
|
// history_chunk fields
|
||||||
|
History []HistoryEntry `json:"history,omitempty"`
|
||||||
|
HistoryDone bool `json:"history_done,omitempty"`
|
||||||
|
|
||||||
|
// reaction fields
|
||||||
|
ReactionMID string `json:"reaction_mid,omitempty"`
|
||||||
|
ReactionEmoji string `json:"reaction_emoji,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumableFile describes a partially-downloaded file found on daemon startup.
|
||||||
|
type ResumableFile struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
From string `json:"from"` // peer ID hex
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Offset int64 `json:"offset"` // bytes already received
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryEntry is one message in a history_chunk response.
|
||||||
|
type HistoryEntry struct {
|
||||||
|
Mid string `json:"mid"`
|
||||||
|
From string `json:"from"` // peer ID hex
|
||||||
|
FromAlias string `json:"from_alias"` // advisory
|
||||||
|
Text string `json:"text"`
|
||||||
|
Ts int64 `json:"ts"` // Unix ms
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
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"`
|
||||||
@@ -95,6 +134,14 @@ type ChatMessage struct {
|
|||||||
Ts int64 `json:"ts"` // Unix milliseconds
|
Ts int64 `json:"ts"` // Unix milliseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ComputeMsgID returns the EXT-007 content-addressed ID for a message.
|
||||||
|
// sha256(fromID \x00 room \x00 ts_decimal \x00 text)
|
||||||
|
func ComputeMsgID(fromID PeerID, room string, ts int64, text string) string {
|
||||||
|
h := sha256.New()
|
||||||
|
fmt.Fprintf(h, "%s\x00%s\x00%d\x00%s", string(fromID), room, ts, text)
|
||||||
|
return fmt.Sprintf("sha256:%x", h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
// PeerGossip shares known peer addresses.
|
// PeerGossip shares known peer addresses.
|
||||||
type PeerGossip struct {
|
type PeerGossip struct {
|
||||||
Peers []GossipEntry `json:"peers"`
|
Peers []GossipEntry `json:"peers"`
|
||||||
@@ -139,12 +186,16 @@ type FileOffer struct {
|
|||||||
|
|
||||||
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
||||||
// The signature binds this identity to the specific DTLS session.
|
// The signature binds this identity to the specific DTLS session.
|
||||||
|
// The Invite field is a waste-go extension (§ waste-go/extensions.md):
|
||||||
|
// when RequireInvite is enabled on a network, peers that omit or present
|
||||||
|
// an invalid signed invite are disconnected. YAW/2-only peers ignore this field.
|
||||||
type HelloMessage struct {
|
type HelloMessage struct {
|
||||||
Type string `json:"type"` // always "hello"
|
Type string `json:"type"` // always "hello"
|
||||||
ID string `json:"id"` // hex pubkey
|
ID string `json:"id"` // hex pubkey
|
||||||
Nick string `json:"nick"` // alias
|
Nick string `json:"nick"` // alias
|
||||||
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
||||||
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
||||||
|
Invite string `json:"invite,omitempty"` // waste-go ext: signed waste: invite string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HelloBindString returns the bytes the hello signature covers:
|
// HelloBindString returns the bytes the hello signature covers:
|
||||||
@@ -232,6 +283,9 @@ const (
|
|||||||
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)
|
||||||
|
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
|
||||||
|
|
||||||
// Events (daemon → UI)
|
// Events (daemon → UI)
|
||||||
EvtMessageReceived IpcMsgType = "message_received"
|
EvtMessageReceived IpcMsgType = "message_received"
|
||||||
@@ -251,6 +305,10 @@ const (
|
|||||||
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)
|
||||||
|
EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages
|
||||||
|
EvtResumableTransfers IpcMsgType = "resumable_transfers" // field: resumable_files
|
||||||
|
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.
|
||||||
@@ -279,6 +337,8 @@ type IpcMessage struct {
|
|||||||
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
|
||||||
|
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"`
|
||||||
@@ -297,14 +357,19 @@ type IpcMessage struct {
|
|||||||
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
|
||||||
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"`
|
||||||
InviteString 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
|
||||||
|
ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers
|
||||||
|
ReactionMID string `json:"reaction_mid,omitempty"` // reaction
|
||||||
|
ReactionEmoji string `json:"reaction_emoji,omitempty"` // reaction
|
||||||
Shares []ShareEntry `json:"shares,omitempty"`
|
Shares []ShareEntry `json:"shares,omitempty"`
|
||||||
ShareNetworks []string `json:"networks,omitempty"` // for add_share command
|
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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -28,8 +29,30 @@ CREATE TABLE IF NOT EXISTS peers (
|
|||||||
alias TEXT NOT NULL,
|
alias TEXT NOT NULL,
|
||||||
last_seen DATETIME NOT NULL
|
last_seen DATETIME NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// migrations run after the base schema. ALTER TABLE ADD COLUMN fails with
|
||||||
|
// "duplicate column name" on subsequent opens — we swallow that error.
|
||||||
|
// CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS are idempotent.
|
||||||
|
var migrations = []string{
|
||||||
|
// EXT-007: canonical message ID for history dedup (NULL for pre-feature messages).
|
||||||
|
`ALTER TABLE messages ADD COLUMN msg_id TEXT`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msg_id ON messages (msg_id) WHERE msg_id IS NOT NULL`,
|
||||||
|
// Reactions: (mid, emoji, from_peer) triple is the unique dedup key.
|
||||||
|
`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)
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
// Store is a local SQLite-backed message and peer store.
|
// Store is a local SQLite-backed message and peer store.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -46,6 +69,12 @@ func Open(path string) (*Store, error) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, fmt.Errorf("migrate db: %w", err)
|
return nil, fmt.Errorf("migrate db: %w", err)
|
||||||
}
|
}
|
||||||
|
for _, m := range migrations {
|
||||||
|
if _, err := db.Exec(m); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("migration %q: %w", m, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return &Store{db: db}, nil
|
return &Store{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +88,9 @@ func (s *Store) Close() error {
|
|||||||
func (s *Store) SaveMessage(msg *proto.ChatMessage) error {
|
func (s *Store) SaveMessage(msg *proto.ChatMessage) error {
|
||||||
sentAt := time.UnixMilli(msg.Ts).UTC()
|
sentAt := time.UnixMilli(msg.Ts).UTC()
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT OR IGNORE INTO messages (mid, room, from_peer, body, sent_at)
|
`INSERT OR IGNORE INTO messages (mid, msg_id, room, from_peer, body, sent_at)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
msg.Mid, msg.Room, string(msg.From), msg.Text, sentAt,
|
msg.Mid, nullableString(msg.MsgID), msg.Room, string(msg.From), msg.Text, sentAt,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -86,14 +115,39 @@ func (s *Store) PeerAlias(peerID proto.PeerID) string {
|
|||||||
|
|
||||||
// RecentMessages returns up to limit messages for a room, oldest first.
|
// RecentMessages returns up to limit messages for a room, oldest first.
|
||||||
func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) {
|
func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) {
|
||||||
rows, err := s.db.Query(
|
return s.queryMessages(
|
||||||
`SELECT mid, from_peer, body, sent_at
|
`SELECT mid, from_peer, room, body, sent_at FROM messages
|
||||||
FROM messages
|
|
||||||
WHERE room = ?
|
WHERE room = ?
|
||||||
ORDER BY sent_at DESC
|
ORDER BY sent_at DESC LIMIT ?`,
|
||||||
LIMIT ?`,
|
|
||||||
room, limit,
|
room, limit,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentMessagesSince returns up to limit messages for a room with ts > sinceMs, oldest first.
|
||||||
|
// sinceMs == 0 returns the most recent messages regardless of timestamp.
|
||||||
|
func (s *Store) RecentMessagesSince(room string, sinceMs int64, limit int) ([]proto.ChatMessage, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
if sinceMs == 0 {
|
||||||
|
return s.queryMessages(
|
||||||
|
`SELECT mid, from_peer, room, body, sent_at FROM messages
|
||||||
|
WHERE room = ?
|
||||||
|
ORDER BY sent_at DESC LIMIT ?`,
|
||||||
|
room, limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
since := time.UnixMilli(sinceMs).UTC()
|
||||||
|
return s.queryMessages(
|
||||||
|
`SELECT mid, from_peer, room, body, sent_at FROM messages
|
||||||
|
WHERE room = ? AND sent_at > ?
|
||||||
|
ORDER BY sent_at DESC LIMIT ?`,
|
||||||
|
room, since, limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) queryMessages(q string, args ...any) ([]proto.ChatMessage, error) {
|
||||||
|
rows, err := s.db.Query(q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -104,11 +158,10 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
|||||||
var m proto.ChatMessage
|
var m proto.ChatMessage
|
||||||
var from string
|
var from string
|
||||||
var sentAt time.Time
|
var sentAt time.Time
|
||||||
if err := rows.Scan(&m.Mid, &from, &m.Text, &sentAt); err != nil {
|
if err := rows.Scan(&m.Mid, &from, &m.Room, &m.Text, &sentAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.From = proto.PeerID(from)
|
m.From = proto.PeerID(from)
|
||||||
m.Room = room
|
|
||||||
m.Ts = sentAt.UnixMilli()
|
m.Ts = sentAt.UnixMilli()
|
||||||
msgs = append(msgs, m)
|
msgs = append(msgs, m)
|
||||||
}
|
}
|
||||||
@@ -119,6 +172,33 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
|||||||
return msgs, rows.Err()
|
return msgs, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveRoom persists a room name. Duplicate names are silently ignored.
|
||||||
|
func (s *Store) SaveRoom(name string) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO rooms (name, created_at) VALUES (?, ?)`,
|
||||||
|
name, time.Now().UTC(),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rooms returns all persisted room names, ordered by creation time.
|
||||||
|
func (s *Store) Rooms() ([]string, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT name FROM rooms ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// KnownPeers returns all peers seen since this daemon started storing data.
|
// KnownPeers returns all peers seen since this daemon started storing data.
|
||||||
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||||
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
||||||
@@ -136,3 +216,47 @@ func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveReaction persists a reaction. Duplicate (mid, emoji, from_peer) triples are silently ignored.
|
||||||
|
func (s *Store) SaveReaction(mid, emoji, fromPeer string) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO reactions (mid, emoji, from_peer, reacted_at) VALUES (?, ?, ?, ?)`,
|
||||||
|
mid, emoji, fromPeer, time.Now().UTC(),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactionsForRoom returns all reactions for messages in a given room.
|
||||||
|
// Result: mid → emoji → []fromPeer (ordered by reaction time).
|
||||||
|
func (s *Store) ReactionsForRoom(room string) (map[string]map[string][]string, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT r.mid, r.emoji, r.from_peer
|
||||||
|
FROM reactions r
|
||||||
|
JOIN messages m ON m.mid = r.mid
|
||||||
|
WHERE m.room = ?
|
||||||
|
ORDER BY r.reacted_at
|
||||||
|
`, room)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]map[string][]string)
|
||||||
|
for rows.Next() {
|
||||||
|
var mid, emoji, from string
|
||||||
|
if err := rows.Scan(&mid, &emoji, &from); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if out[mid] == nil {
|
||||||
|
out[mid] = make(map[string][]string)
|
||||||
|
}
|
||||||
|
out[mid][emoji] = append(out[mid][emoji], from)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableString(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#863bff" />
|
||||||
|
<meta name="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-title" content="waste" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>waste</title>
|
<title>waste</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"wails:dev": "wails dev",
|
||||||
|
"wails:build": "wails build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"libsodium-wrappers": "^0.8.4",
|
"libsodium-wrappers": "^0.8.4",
|
||||||
|
|||||||
BIN
web/public/apple-touch-icon.png
Normal file
BIN
web/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
web/public/icon-192.png
Normal file
BIN
web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
BIN
web/public/icon-512.png
Normal file
BIN
web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 456 KiB |
23
web/public/manifest.json
Normal file
23
web/public/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "waste",
|
||||||
|
"short_name": "waste",
|
||||||
|
"description": "Decentralized friend-to-friend encrypted mesh networking",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0d0d0d",
|
||||||
|
"theme_color": "#863bff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ input:focus { border-color: var(--accent); }
|
|||||||
.onboarding-section { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
|
.onboarding-section { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
.join-form { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
.join-form { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
||||||
.join-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
|
.join-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
|
||||||
|
.saved-networks { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||||
|
.saved-network-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.network-chip { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 20px; padding: 4px 14px; font-size: 13px; }
|
||||||
|
.network-chip:hover { border-color: var(--accent); color: var(--accent); background: var(--surface); }
|
||||||
button.primary { background: var(--accent); width: 100%; padding: 8px; font-size: 14px; }
|
button.primary { background: var(--accent); width: 100%; padding: 8px; font-size: 14px; }
|
||||||
.toggle-link { background: none; color: var(--muted); font-size: 12px; padding: 4px 0; text-align: left; }
|
.toggle-link { background: none; color: var(--muted); font-size: 12px; padding: 4px 0; text-align: left; }
|
||||||
.toggle-link:hover { color: var(--text); }
|
.toggle-link:hover { color: var(--text); }
|
||||||
@@ -88,7 +92,7 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
|
|||||||
.messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; }
|
.messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; }
|
||||||
.message { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; }
|
.message { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; }
|
||||||
.message:hover { background: rgba(255,255,255,0.02); }
|
.message:hover { background: rgba(255,255,255,0.02); }
|
||||||
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 52px; }
|
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 72px; }
|
||||||
.message-alias { font-weight: 600; font-size: 13px; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; width: 90px; padding-right: 10px; }
|
.message-alias { font-weight: 600; font-size: 13px; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; width: 90px; padding-right: 10px; }
|
||||||
.message.mine .message-alias { color: var(--accent); }
|
.message.mine .message-alias { color: var(--accent); }
|
||||||
.message-text { word-break: break-word; font-size: 14px; color: var(--text); }
|
.message-text { word-break: break-word; font-size: 14px; color: var(--text); }
|
||||||
@@ -158,3 +162,86 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
|
|||||||
.file-entry-dir { cursor: pointer; }
|
.file-entry-dir { cursor: pointer; }
|
||||||
.file-entry-dir:hover { background: rgba(255,255,255,0.04); }
|
.file-entry-dir:hover { background: rgba(255,255,255,0.04); }
|
||||||
.file-entry-icon { font-size: 12px; flex-shrink: 0; }
|
.file-entry-icon { font-size: 12px; flex-shrink: 0; }
|
||||||
|
.history-divider { display: flex; align-items: center; gap: 8px; margin: 10px 0 6px; color: var(--muted); font-size: 11px; }
|
||||||
|
.history-divider::before, .history-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
|
||||||
|
/* ── message links + image preview ── */
|
||||||
|
.msg-link { color: var(--accent); text-decoration: underline; word-break: break-all; }
|
||||||
|
.msg-link:hover { opacity: 0.8; }
|
||||||
|
.message-text { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.msg-image-preview { max-width: 320px; max-height: 200px; border-radius: 6px; border: 1px solid var(--border); margin-top: 4px; object-fit: contain; display: block; }
|
||||||
|
|
||||||
|
/* ── reactions ── */
|
||||||
|
.message-wrapper { display: flex; flex-direction: column; padding: 0; }
|
||||||
|
.message-wrapper .message { padding: 2px 16px; }
|
||||||
|
.reaction-add { background: none; color: var(--muted); font-size: 13px; padding: 0 4px; line-height: 1; opacity: 0; transition: opacity 0.1s; margin-left: 4px; flex-shrink: 0; }
|
||||||
|
.message-wrapper:hover .reaction-add { opacity: 1; }
|
||||||
|
.reaction-add:hover { color: var(--accent); background: none; }
|
||||||
|
.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:hover { border-color: var(--accent); background: rgba(124,106,247,0.12); }
|
||||||
|
.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:hover { border-color: var(--accent); background: rgba(124,106,247,0.1); }
|
||||||
|
.reaction-chip.mine { border-color: var(--accent); background: rgba(124,106,247,0.18); }
|
||||||
|
|
||||||
|
/* ── 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:hover { color: var(--text); background: none; }
|
||||||
|
.sidebar-close-mobile { display: none; background: none; color: var(--muted); font-size: 14px; padding: 2px 4px; }
|
||||||
|
.sidebar-close-mobile:hover { color: var(--text); background: none; }
|
||||||
|
|
||||||
|
/* ── responsive layout ── */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:root { --sidebar-w: 80vw; }
|
||||||
|
|
||||||
|
.chat-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sidebar slides in over the top */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
height: 100%;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.22s ease;
|
||||||
|
box-shadow: 4px 0 24px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.chat-layout.sidebar-open .sidebar {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* dim overlay behind open sidebar */
|
||||||
|
.chat-layout.sidebar-open::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn-mobile { display: inline-block; }
|
||||||
|
.sidebar-close-mobile { display: inline-block; }
|
||||||
|
|
||||||
|
/* message pane fills full width */
|
||||||
|
.chat-layout > .message-pane { grid-column: 1; }
|
||||||
|
|
||||||
|
/* file browser stacks below on mobile */
|
||||||
|
.chat-layout.has-file-browser { grid-template-columns: 1fr; }
|
||||||
|
.chat-layout.has-file-browser > .file-browser { border-left: none; border-top: 1px solid var(--border); max-height: 40vh; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* slightly larger tap targets */
|
||||||
|
.sidebar-item { padding: 8px 12px; font-size: 14px; }
|
||||||
|
.peer-row { padding: 6px 12px; }
|
||||||
|
.peer-row-actions { opacity: 1; }
|
||||||
|
.peer-action { font-size: 18px; padding: 2px 6px; }
|
||||||
|
|
||||||
|
/* message layout: stack alias above text on very narrow screens */
|
||||||
|
.message { flex-wrap: wrap; }
|
||||||
|
.message-ts { width: auto; min-width: 56px; }
|
||||||
|
.message-alias { width: auto; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,35 @@ const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WAS
|
|||||||
// Use browser mode if a signal URL is configured, even on localhost
|
// Use browser mode if a signal URL is configured, even on localhost
|
||||||
const useBrowser = !isLocal || !!cfg?.signalURL
|
const useBrowser = !isLocal || !!cfg?.signalURL
|
||||||
|
|
||||||
|
// Wails runtime is injected at /wails/runtime.js when running inside the desktop app.
|
||||||
|
// EventsOn/EventsOff are no-ops in plain browser mode.
|
||||||
|
type WailsRuntime = { EventsOn: (event: string, cb: (data: unknown) => void) => void }
|
||||||
|
const wails = (window as unknown as { runtime?: WailsRuntime }).runtime
|
||||||
|
|
||||||
|
function useWailsNotifications() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wails) return
|
||||||
|
|
||||||
|
const handler = (data: unknown) => {
|
||||||
|
const { title, body } = data as { title: string; body: string }
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
new Notification(title, { body })
|
||||||
|
} else if (Notification.permission !== 'denied') {
|
||||||
|
Notification.requestPermission().then(p => {
|
||||||
|
if (p === 'granted') new Notification(title, { body })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wails.EventsOn('notify', handler)
|
||||||
|
return () => {
|
||||||
|
// EventsOff added in Wails v2.4; guard in case of older runtime.
|
||||||
|
;(window as unknown as { runtime?: { EventsOff?: (...e: string[]) => void } })
|
||||||
|
.runtime?.EventsOff?.('notify')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
|
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
|
||||||
|
|
||||||
@@ -21,6 +50,8 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [connect, connectBrowser])
|
}, [connect, connectBrowser])
|
||||||
|
|
||||||
|
useWailsNotifications()
|
||||||
|
|
||||||
if (daemonStatus !== 'connected' || !localPeer) {
|
if (daemonStatus !== 'connected' || !localPeer) {
|
||||||
return <Onboarding status={daemonStatus} />
|
return <Onboarding status={daemonStatus} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,9 +433,17 @@ class PeerConn {
|
|||||||
nick: m['nick'] as string || '', caps: m['caps'] || []
|
nick: m['nick'] as string || '', caps: m['caps'] || []
|
||||||
})
|
})
|
||||||
} else if (m['type'] === 'chat') {
|
} else if (m['type'] === 'chat') {
|
||||||
|
const ts = (m['ts'] as number) || Date.now()
|
||||||
this.on('chat', {
|
this.on('chat', {
|
||||||
peer: this.peerId, room: m['room'] as string || 'general',
|
peer: this.peerId, room: m['room'] as string || 'general',
|
||||||
text: m['text'] as string, ts: m['ts'] as number || Date.now()
|
text: m['text'] as string, ts,
|
||||||
|
mid: (m['mid'] as string) || `${this.peerId}-${ts}`,
|
||||||
|
})
|
||||||
|
} else if (m['type'] === 'reaction') {
|
||||||
|
this.on('reaction', {
|
||||||
|
peer: this.peerId,
|
||||||
|
mid: m['reaction_mid'] as string,
|
||||||
|
emoji: m['reaction_emoji'] as string,
|
||||||
})
|
})
|
||||||
} else if (m['type'] === 'pm') {
|
} else if (m['type'] === 'pm') {
|
||||||
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
|
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
|
||||||
@@ -556,14 +564,18 @@ class PeerConn {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
sendChat(room: string, text: string) {
|
sendChat(room: string, text: string, mid: string) {
|
||||||
this._dc({ type: 'chat', room, text, ts: Date.now() })
|
this._dc({ type: 'chat', room, text, ts: Date.now(), mid })
|
||||||
}
|
}
|
||||||
|
|
||||||
sendPm(text: string) {
|
sendPm(text: string) {
|
||||||
this._dc({ type: 'pm', text, ts: Date.now() })
|
this._dc({ type: 'pm', text, ts: Date.now() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendReaction(mid: string, emoji: string) {
|
||||||
|
this._dc({ type: 'reaction', reaction_mid: mid, reaction_emoji: emoji })
|
||||||
|
}
|
||||||
|
|
||||||
private _dc(obj: object) {
|
private _dc(obj: object) {
|
||||||
if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj))
|
if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj))
|
||||||
}
|
}
|
||||||
@@ -755,9 +767,17 @@ export class BrowserAdapter {
|
|||||||
public_key: data['peer'] as string, created_at: new Date().toISOString()
|
public_key: data['peer'] as string, created_at: new Date().toISOString()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} else if (event === 'reaction') {
|
||||||
|
this.emit({
|
||||||
|
type: 'reaction',
|
||||||
|
network_id: this.networkId,
|
||||||
|
peer_id: data['peer'] as unknown as import('../types').PeerID,
|
||||||
|
reaction_mid: data['mid'] as string,
|
||||||
|
reaction_emoji: data['emoji'] as string,
|
||||||
|
})
|
||||||
} else if (event === 'chat') {
|
} else if (event === 'chat') {
|
||||||
const ts = (data['ts'] as number) || Date.now()
|
const ts = (data['ts'] as number) || Date.now()
|
||||||
const mid = `${data['peer']}-${ts}`
|
const mid = (data['mid'] as string) || `${data['peer']}-${ts}`
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'message_received',
|
type: 'message_received',
|
||||||
network_id: this.networkId,
|
network_id: this.networkId,
|
||||||
@@ -910,8 +930,8 @@ export class BrowserAdapter {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Broadcast
|
// Broadcast — include mid on the wire so recipients can reference it in reactions
|
||||||
this.peers.forEach(p => p.sendChat(room, text))
|
this.peers.forEach(p => p.sendChat(room, text, mid))
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'message_received',
|
type: 'message_received',
|
||||||
network_id: this.networkId,
|
network_id: this.networkId,
|
||||||
@@ -924,6 +944,22 @@ export class BrowserAdapter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'send_reaction') {
|
||||||
|
const mid = msg.reaction_mid
|
||||||
|
const emoji = msg.reaction_emoji
|
||||||
|
if (!mid || !emoji) return
|
||||||
|
this.peers.forEach(p => p.sendReaction(mid, emoji))
|
||||||
|
// Emit locally so the sender sees their own reaction immediately
|
||||||
|
this.emit({
|
||||||
|
type: 'reaction',
|
||||||
|
network_id: this.networkId,
|
||||||
|
peer_id: this.identity.id as unknown as import('../types').PeerID,
|
||||||
|
reaction_mid: mid,
|
||||||
|
reaction_emoji: emoji,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'export_identity') {
|
if (msg.type === 'export_identity') {
|
||||||
if (!msg.passphrase) return
|
if (!msg.passphrase) return
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,16 +1,75 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useWaste } from '../store'
|
import { useWaste } from '../store'
|
||||||
|
|
||||||
export function MessagePane() {
|
const today = new Date()
|
||||||
const { messages, activeRoom, activeNetworkId, localPeer, connectedPeers, send } = useWaste()
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const todayMs = today.getTime()
|
||||||
|
|
||||||
|
function formatTs(ts: number): string {
|
||||||
|
const d = new Date(ts)
|
||||||
|
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
if (ts >= todayMs) return time
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time
|
||||||
|
}
|
||||||
|
|
||||||
|
const URL_RE = /https?:\/\/[^\s<>"']+/g
|
||||||
|
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|svg)(\?[^\s]*)?$/i
|
||||||
|
|
||||||
|
function renderText(text: string): React.ReactNode {
|
||||||
|
const parts: React.ReactNode[] = []
|
||||||
|
let last = 0
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
URL_RE.lastIndex = 0
|
||||||
|
while ((m = URL_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > last) parts.push(text.slice(last, m.index))
|
||||||
|
const url = m[0]
|
||||||
|
const isImage = IMAGE_EXT_RE.test(url) || url.startsWith('blob:') || url.startsWith('data:image')
|
||||||
|
parts.push(
|
||||||
|
<a key={m.index} href={url} target="_blank" rel="noopener noreferrer" className="msg-link">
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
if (isImage) {
|
||||||
|
parts.push(
|
||||||
|
<img key={`img-${m.index}`} src={url} alt="" className="msg-image-preview" loading="lazy" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
last = m.index + url.length
|
||||||
|
}
|
||||||
|
if (last < text.length) parts.push(text.slice(last))
|
||||||
|
return parts.length > 1 ? <>{parts}</> : text
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI_SET = ['👍', '❤️', '😂', '😮', '😢', '🙏']
|
||||||
|
|
||||||
|
export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) {
|
||||||
|
const { messages, historyCutoff, activeRoom, activeNetworkId, localPeer, connectedPeers, knownPeers, reactions, sendReaction, send } = useWaste()
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
|
const [pickerMid, setPickerMid] = useState<string | null>(null)
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
const roomMessages = messages[activeRoom] ?? []
|
const msgKey = activeNetworkId ? `${activeNetworkId}:${activeRoom}` : activeRoom
|
||||||
|
const roomMessages = messages[msgKey] ?? []
|
||||||
|
const cutoff = historyCutoff[msgKey] ?? 0
|
||||||
|
|
||||||
|
const firstLiveIdx = cutoff > 0
|
||||||
|
? roomMessages.findIndex(m => m.ts > cutoff)
|
||||||
|
: -1
|
||||||
|
const dividerIdx = cutoff > 0
|
||||||
|
? (firstLiveIdx === -1 ? 0 : firstLiveIdx)
|
||||||
|
: -1
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [roomMessages.length])
|
}, [roomMessages.length])
|
||||||
|
|
||||||
|
// Close picker when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pickerMid) return
|
||||||
|
const handler = () => setPickerMid(null)
|
||||||
|
document.addEventListener('click', handler)
|
||||||
|
return () => document.removeEventListener('click', handler)
|
||||||
|
}, [pickerMid])
|
||||||
|
|
||||||
function submit(e: React.FormEvent) {
|
function submit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const text = draft.trim()
|
const text = draft.trim()
|
||||||
@@ -30,7 +89,21 @@ export function MessagePane() {
|
|||||||
|
|
||||||
function aliasFor(fromId: string) {
|
function aliasFor(fromId: string) {
|
||||||
if (fromId === localPeer?.id) return localPeer.alias
|
if (fromId === localPeer?.id) return localPeer.alias
|
||||||
return connectedPeers.find(p => p.id === fromId)?.alias ?? fromId.slice(0, 8)
|
return connectedPeers.find(p => p.id === fromId)?.alias
|
||||||
|
?? knownPeers[fromId]
|
||||||
|
?? fromId.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReaction(mid: string, emoji: string, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!activeNetworkId || !mid) return
|
||||||
|
sendReaction(activeNetworkId, mid, emoji)
|
||||||
|
setPickerMid(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPicker(mid: string, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setPickerMid(prev => prev === mid ? null : mid)
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomLabel = activeRoom.startsWith('dm:')
|
const roomLabel = activeRoom.startsWith('dm:')
|
||||||
@@ -39,18 +112,67 @@ export function MessagePane() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="message-pane">
|
<main className="message-pane">
|
||||||
<div className="message-pane-header">{roomLabel}</div>
|
<div className="message-pane-header">
|
||||||
|
<button className="menu-btn-mobile" onClick={onMenuClick} aria-label="Menu">☰</button>
|
||||||
|
{roomLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="messages">
|
<div className="messages">
|
||||||
|
{dividerIdx === 0 && (
|
||||||
|
<div className="history-divider"><span>earlier messages</span></div>
|
||||||
|
)}
|
||||||
{roomMessages.map((msg, i) => {
|
{roomMessages.map((msg, i) => {
|
||||||
const mine = msg.from === localPeer?.id
|
const mine = msg.from === localPeer?.id
|
||||||
const alias = aliasFor(msg.from)
|
const alias = aliasFor(String(msg.from))
|
||||||
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
const ts = formatTs(msg.ts)
|
||||||
|
const mid = msg.mid ?? ''
|
||||||
|
const msgReactions = mid ? reactions[mid] : undefined
|
||||||
|
const hasReactions = msgReactions && Object.keys(msgReactions).length > 0
|
||||||
return (
|
return (
|
||||||
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}>
|
<div key={mid || i} className="message-wrapper">
|
||||||
<span className="message-ts">{time}</span>
|
{i === dividerIdx && dividerIdx > 0 && (
|
||||||
|
<div className="history-divider"><span>earlier messages</span></div>
|
||||||
|
)}
|
||||||
|
<div className={`message ${mine ? 'mine' : ''}`}>
|
||||||
|
<span className="message-ts">{ts}</span>
|
||||||
<span className="message-alias">{alias}</span>
|
<span className="message-alias">{alias}</span>
|
||||||
<span className="message-text">{msg.text}</span>
|
<span className="message-text">
|
||||||
|
{renderText(msg.text)}
|
||||||
|
</span>
|
||||||
|
{mid && (
|
||||||
|
<button
|
||||||
|
className="reaction-add"
|
||||||
|
onClick={e => openPicker(mid, e)}
|
||||||
|
title="React"
|
||||||
|
>+</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pickerMid === mid && (
|
||||||
|
<div className="reaction-picker" onClick={e => e.stopPropagation()}>
|
||||||
|
{EMOJI_SET.map(emoji => (
|
||||||
|
<button key={emoji} className="reaction-picker-btn" onClick={e => toggleReaction(mid, emoji, e)}>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasReactions && (
|
||||||
|
<div className="reaction-bar">
|
||||||
|
{Object.entries(msgReactions!).map(([emoji, fromIds]) => {
|
||||||
|
const iMine = fromIds.includes(localPeer?.id ?? '')
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
className={`reaction-chip ${iMine ? 'mine' : ''}`}
|
||||||
|
onClick={e => toggleReaction(mid, emoji, e)}
|
||||||
|
title={fromIds.map(aliasFor).join(', ')}
|
||||||
|
>
|
||||||
|
{emoji} {fromIds.length}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -26,22 +26,31 @@ function formatTs(ts: number | undefined): string {
|
|||||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar({ onClose }: { onClose: () => void }) {
|
||||||
const {
|
const {
|
||||||
localPeer, masterId, masterAlias,
|
localPeer, masterId, masterAlias,
|
||||||
networks, activeNetworkId, activeRoom,
|
networks, activeNetworkId, activeRoom,
|
||||||
connectedPeers, peerStatus,
|
connectedPeers, peerStatus,
|
||||||
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
|
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
|
||||||
customRooms, createRoom, logout,
|
customRooms, createRoom, logout, send,
|
||||||
} = useWaste()
|
} = useWaste()
|
||||||
const [addingRoom, setAddingRoom] = useState(false)
|
const [addingRoom, setAddingRoom] = useState(false)
|
||||||
const [newRoomName, setNewRoomName] = useState('')
|
const [newRoomName, setNewRoomName] = useState('')
|
||||||
|
const [addingNetwork, setAddingNetwork] = useState(false)
|
||||||
|
const [newNetName, setNewNetName] = useState('')
|
||||||
|
const [newNetAnchor, setNewNetAnchor] = useState('')
|
||||||
|
|
||||||
const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : []
|
const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : []
|
||||||
const rooms = ['general', ...netCustomRooms]
|
const rooms = ['general', ...netCustomRooms]
|
||||||
Object.keys(messages).forEach(r => {
|
if (activeNetworkId) {
|
||||||
if (r.startsWith('dm:') && !rooms.includes(r)) rooms.push(r)
|
const prefix = `${activeNetworkId}:dm:`
|
||||||
|
Object.keys(messages).forEach(k => {
|
||||||
|
if (k.startsWith(prefix)) {
|
||||||
|
const r = k.slice(activeNetworkId.length + 1)
|
||||||
|
if (!rooms.includes(r)) rooms.push(r)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function submitNewRoom(e: React.FormEvent) {
|
function submitNewRoom(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -50,10 +59,40 @@ export function Sidebar() {
|
|||||||
setAddingRoom(false)
|
setAddingRoom(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitNewNetwork(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const name = newNetName.trim()
|
||||||
|
if (!name) return
|
||||||
|
if (adapterMode === 'browser') {
|
||||||
|
// Persist to saved networks list in localStorage.
|
||||||
|
const anchor = newNetAnchor.trim() || localStorage.getItem('waste_anchor_url') || ''
|
||||||
|
const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||||
|
if (!saved.some(n => n.name === name && n.anchor === anchor)) {
|
||||||
|
saved.push({ name, anchor })
|
||||||
|
localStorage.setItem('waste_saved_networks', JSON.stringify(saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send({ type: 'join_network', network_name: name })
|
||||||
|
setNewNetName('')
|
||||||
|
setNewNetAnchor('')
|
||||||
|
setAddingNetwork(false)
|
||||||
|
}
|
||||||
|
|
||||||
const displayAlias = localPeer?.alias ?? masterAlias ?? ''
|
const displayAlias = localPeer?.alias ?? masterAlias ?? ''
|
||||||
const displayId = localPeer?.id ?? masterId ?? ''
|
const displayId = localPeer?.id ?? masterId ?? ''
|
||||||
const card = displayId ? makeYawCard(displayId, displayAlias) : null
|
const card = displayId ? makeYawCard(displayId, displayAlias) : null
|
||||||
|
|
||||||
|
function copyHangLink() {
|
||||||
|
const net = networks.find(n => n.network_id === activeNetworkId)
|
||||||
|
if (!net) return
|
||||||
|
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
|
||||||
|
const anchor = cfg?.signalURL ?? ''
|
||||||
|
const payload = btoa(JSON.stringify({ network: net.network_name, anchor }))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_')
|
||||||
|
const url = `${window.location.origin}/#waste:${payload}`
|
||||||
|
navigator.clipboard?.writeText(url)
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
||||||
logout(clearId)
|
logout(clearId)
|
||||||
@@ -79,19 +118,51 @@ export function Sidebar() {
|
|||||||
<span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span>
|
<span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="sidebar-logout" onClick={handleLogout} title="Leave network">⏻</button>
|
<button className="sidebar-logout" onClick={handleLogout} title="Leave network">⏻</button>
|
||||||
|
<button className="sidebar-close-mobile" onClick={onClose} title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-label-row">
|
||||||
<span className="sidebar-label">Networks</span>
|
<span className="sidebar-label">Networks</span>
|
||||||
|
<span style={{ display: 'flex', gap: 2 }}>
|
||||||
|
{activeNetworkId && (
|
||||||
|
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link">🔗</button>
|
||||||
|
)}
|
||||||
|
<button className="sidebar-add" onClick={() => setAddingNetwork(v => !v)} title="Join network">+</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{networks.map(n => (
|
{networks.map(n => (
|
||||||
<button
|
<button
|
||||||
key={n.network_id}
|
key={n.network_id}
|
||||||
className={`sidebar-item ${n.network_id === activeNetworkId ? 'active' : ''}`}
|
className={`sidebar-item ${n.network_id === activeNetworkId ? 'active' : ''}`}
|
||||||
onClick={() => setActiveNetwork(n.network_id)}
|
onClick={() => { setActiveNetwork(n.network_id); onClose() }}
|
||||||
>
|
>
|
||||||
{n.network_name}
|
{n.network_name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{addingNetwork && (
|
||||||
|
<form className="sidebar-new-room" onSubmit={submitNewNetwork}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newNetName}
|
||||||
|
onChange={e => setNewNetName(e.target.value)}
|
||||||
|
placeholder="network name"
|
||||||
|
onKeyDown={e => e.key === 'Escape' && (setAddingNetwork(false), setNewNetName(''))}
|
||||||
|
/>
|
||||||
|
{adapterMode === 'browser' && (
|
||||||
|
<input
|
||||||
|
value={newNetAnchor}
|
||||||
|
onChange={e => setNewNetAnchor(e.target.value)}
|
||||||
|
placeholder="anchor URL (blank = current)"
|
||||||
|
className="mono"
|
||||||
|
style={{ fontSize: '0.75rem', marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={!newNetName.trim()} style={{ marginTop: 4, width: '100%', fontSize: '12px', padding: '3px 8px' }}>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
@@ -103,7 +174,7 @@ export function Sidebar() {
|
|||||||
<button
|
<button
|
||||||
key={r}
|
key={r}
|
||||||
className={`sidebar-item ${r === activeRoom ? 'active' : ''}`}
|
className={`sidebar-item ${r === activeRoom ? 'active' : ''}`}
|
||||||
onClick={() => setActiveRoom(r)}
|
onClick={() => { setActiveRoom(r); onClose() }}
|
||||||
>
|
>
|
||||||
{r.startsWith('dm:') ? `@ ${r.slice(3, 11)}…` : `# ${r}`}
|
{r.startsWith('dm:') ? `@ ${r.slice(3, 11)}…` : `# ${r}`}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ function fmt(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Transfers() {
|
export function Transfers() {
|
||||||
const { pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
|
const { pendingOffers, fileProgress, resumableFiles, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
|
||||||
|
|
||||||
const hasPending = Object.keys(pendingOffers).length > 0
|
const hasPending = Object.keys(pendingOffers).length > 0
|
||||||
const hasActive = Object.keys(fileProgress).length > 0
|
const hasActive = Object.keys(fileProgress).length > 0
|
||||||
|
const hasResumable = Object.keys(resumableFiles).length > 0
|
||||||
|
|
||||||
if (!hasPending && !hasActive) return null
|
if (!hasPending && !hasActive && !hasResumable) return null
|
||||||
|
|
||||||
function alias(peerId: string) {
|
function alias(peerId: string) {
|
||||||
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
|
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
|
||||||
@@ -22,6 +23,24 @@ export function Transfers() {
|
|||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<span className="sidebar-label">Transfers</span>
|
<span className="sidebar-label">Transfers</span>
|
||||||
|
|
||||||
|
{hasResumable && (
|
||||||
|
<>
|
||||||
|
<span className="sidebar-label" style={{ fontSize: 10, opacity: 0.6 }}>resumable</span>
|
||||||
|
{Object.entries(resumableFiles).map(([sha256, f]) => {
|
||||||
|
const pct = f.size > 0 ? Math.round((f.offset / f.size) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={sha256} className="transfer-row">
|
||||||
|
<span className="transfer-name" title={f.name}>{f.name}</span>
|
||||||
|
<span className="transfer-meta">{fmt(f.offset)} / {fmt(f.size)} · {alias(f.from)} · will resume on reconnect</span>
|
||||||
|
<div className="transfer-progress">
|
||||||
|
<div className="transfer-progress-bar" style={{ width: `${pct}%`, opacity: 0.5 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{Object.entries(pendingOffers).map(([xid, offer]) => (
|
{Object.entries(pendingOffers).map(([xid, offer]) => (
|
||||||
<div key={xid} className="transfer-row">
|
<div key={xid} className="transfer-row">
|
||||||
<span className="transfer-name" title={offer.name}>{offer.name}</span>
|
<span className="transfer-name" title={offer.name}>{offer.name}</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Sidebar } from '../components/Sidebar'
|
import { Sidebar } from '../components/Sidebar'
|
||||||
import { MessagePane } from '../components/MessagePane'
|
import { MessagePane } from '../components/MessagePane'
|
||||||
import { FileBrowser } from '../components/FileBrowser'
|
import { FileBrowser } from '../components/FileBrowser'
|
||||||
@@ -5,10 +6,11 @@ import { useWaste } from '../store'
|
|||||||
|
|
||||||
export function Chat() {
|
export function Chat() {
|
||||||
const { activeFilePeer } = useWaste()
|
const { activeFilePeer } = useWaste()
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
return (
|
return (
|
||||||
<div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}`}>
|
<div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}${sidebarOpen ? ' sidebar-open' : ''}`}>
|
||||||
<Sidebar />
|
<Sidebar onClose={() => setSidebarOpen(false)} />
|
||||||
<MessagePane />
|
<MessagePane onMenuClick={() => setSidebarOpen(v => !v)} />
|
||||||
{activeFilePeer && <FileBrowser />}
|
{activeFilePeer && <FileBrowser />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ interface Props {
|
|||||||
// ?a=<url> anchor URL hint
|
// ?a=<url> anchor URL hint
|
||||||
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
||||||
const p = new URLSearchParams(window.location.search)
|
const p = new URLSearchParams(window.location.search)
|
||||||
const inviteString = p.get('invite') ?? ''
|
let inviteString = p.get('invite') ?? ''
|
||||||
let network = p.get('n') ?? p.get('network') ?? ''
|
let network = p.get('n') ?? p.get('network') ?? ''
|
||||||
let netHash = p.get('net') ?? ''
|
let netHash = p.get('net') ?? ''
|
||||||
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
||||||
|
|
||||||
|
// Hash-based hang link: https://host/#waste:eyJ... (opaque, not sent to server)
|
||||||
|
const hash = window.location.hash.slice(1) // strip leading #
|
||||||
|
if (!inviteString && hash.startsWith('waste:')) {
|
||||||
|
inviteString = hash
|
||||||
|
}
|
||||||
|
|
||||||
if (inviteString.startsWith('waste:')) {
|
if (inviteString.startsWith('waste:')) {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
||||||
@@ -66,8 +72,15 @@ export function Onboarding({ status }: Props) {
|
|||||||
if (adapterMode !== 'browser' || status !== 'connected') return
|
if (adapterMode !== 'browser' || status !== 'connected') return
|
||||||
const { network: n, netHash: nh } = parseInviteParams()
|
const { network: n, netHash: nh } = parseInviteParams()
|
||||||
if (n || nh) return // explicit invite — don't auto-join, show form
|
if (n || nh) return // explicit invite — don't auto-join, show form
|
||||||
|
// Rejoin all saved networks.
|
||||||
|
const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||||
|
if (saved.length > 0) {
|
||||||
|
saved.forEach(s => doJoin(s.name, ''))
|
||||||
|
} else {
|
||||||
|
// Legacy single-network fallback.
|
||||||
const savedNetwork = localStorage.getItem('waste_last_network')
|
const savedNetwork = localStorage.getItem('waste_last_network')
|
||||||
if (savedNetwork) doJoin(savedNetwork, '')
|
if (savedNetwork) doJoin(savedNetwork, '')
|
||||||
|
}
|
||||||
}, [adapterMode, status]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [adapterMode, status]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
function joinNetwork(e: React.FormEvent) {
|
function joinNetwork(e: React.FormEvent) {
|
||||||
@@ -81,8 +94,16 @@ export function Onboarding({ status }: Props) {
|
|||||||
if (adapterMode === 'browser') {
|
if (adapterMode === 'browser') {
|
||||||
localStorage.setItem('waste_anchor_url', anchorUrl)
|
localStorage.setItem('waste_anchor_url', anchorUrl)
|
||||||
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
|
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
|
||||||
if (name) localStorage.setItem('waste_last_network', name)
|
if (name) {
|
||||||
else localStorage.removeItem('waste_last_network')
|
// Persist to saved networks list.
|
||||||
|
const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||||
|
if (!saved.some(n => n.name === name)) {
|
||||||
|
saved.push({ name, anchor: anchorUrl })
|
||||||
|
localStorage.setItem('waste_saved_networks', JSON.stringify(saved))
|
||||||
|
}
|
||||||
|
// Keep legacy key for backward compat.
|
||||||
|
localStorage.setItem('waste_last_network', name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hash.length === 64 && !name) {
|
if (hash.length === 64 && !name) {
|
||||||
@@ -115,6 +136,7 @@ export function Onboarding({ status }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null
|
const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null
|
||||||
|
const savedNetworks: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||||
|
|
||||||
// ── disconnected / connecting ────────────────────────────────────────────────
|
// ── disconnected / connecting ────────────────────────────────────────────────
|
||||||
if (status !== 'connected') {
|
if (status !== 'connected') {
|
||||||
@@ -153,8 +175,21 @@ export function Onboarding({ status }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{adapterMode === 'browser' && savedNetworks.length > 0 && (
|
||||||
|
<div className="saved-networks">
|
||||||
|
<span className="join-label">Saved networks</span>
|
||||||
|
<div className="saved-network-chips">
|
||||||
|
{savedNetworks.map(n => (
|
||||||
|
<button key={n.name} className="network-chip" type="button" onClick={() => doJoin(n.name, '')}>
|
||||||
|
{n.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={joinNetwork} className="join-form">
|
<form onSubmit={joinNetwork} className="join-form">
|
||||||
<label className="join-label">Join a network</label>
|
<label className="join-label">{savedNetworks.length > 0 ? 'Join another network' : 'Join a network'}</label>
|
||||||
|
|
||||||
{adapterMode === 'browser' && (
|
{adapterMode === 'browser' && (
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ interface WasteState {
|
|||||||
|
|
||||||
// peers
|
// peers
|
||||||
connectedPeers: PeerInfo[]
|
connectedPeers: PeerInfo[]
|
||||||
|
knownPeers: Record<string, string> // id → alias for historical peers
|
||||||
|
|
||||||
// chat — keyed by room
|
// chat — keyed by room
|
||||||
messages: Record<string, ChatMessage[]>
|
messages: Record<string, ChatMessage[]>
|
||||||
|
// rooms for which we have received history: room → ts of last history message
|
||||||
|
historyCutoff: Record<string, number>
|
||||||
activeRoom: string
|
activeRoom: string
|
||||||
// user-created rooms, keyed by networkId
|
// user-created rooms, keyed by networkId
|
||||||
customRooms: Record<string, string[]>
|
customRooms: Record<string, string[]>
|
||||||
@@ -53,6 +56,10 @@ interface WasteState {
|
|||||||
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
|
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
|
||||||
// active in-progress transfers: xid → progress
|
// active in-progress transfers: xid → progress
|
||||||
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
|
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
|
||||||
|
// partial downloads found on daemon startup: sha256 → info
|
||||||
|
resumableFiles: Record<string, { name: string; from: string; size: number; offset: number }>
|
||||||
|
// reactions: mid → emoji → [fromId, ...]
|
||||||
|
reactions: Record<string, Record<string, string[]>>
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
connect: (url: string) => void
|
connect: (url: string) => void
|
||||||
@@ -69,6 +76,7 @@ interface WasteState {
|
|||||||
rejectOffer: (peerId: string, xid: string) => void
|
rejectOffer: (peerId: string, xid: string) => void
|
||||||
cancelTransfer: (peerId: string, xid: string, direction: 'recv' | 'send') => void
|
cancelTransfer: (peerId: string, xid: string, direction: 'recv' | 'send') => void
|
||||||
createRoom: (name: string) => void
|
createRoom: (name: string) => void
|
||||||
|
sendReaction: (networkId: string, mid: string, emoji: string) => void
|
||||||
logout: (clearIdentity: boolean) => void
|
logout: (clearIdentity: boolean) => void
|
||||||
handleEvent: (msg: IpcMessage) => void
|
handleEvent: (msg: IpcMessage) => void
|
||||||
}
|
}
|
||||||
@@ -83,7 +91,9 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
networks: [],
|
networks: [],
|
||||||
activeNetworkId: null,
|
activeNetworkId: null,
|
||||||
connectedPeers: [],
|
connectedPeers: [],
|
||||||
|
knownPeers: {},
|
||||||
messages: {},
|
messages: {},
|
||||||
|
historyCutoff: {},
|
||||||
activeRoom: 'general',
|
activeRoom: 'general',
|
||||||
customRooms: {},
|
customRooms: {},
|
||||||
fileLists: {},
|
fileLists: {},
|
||||||
@@ -93,6 +103,8 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
sharedFilesByNetwork: {},
|
sharedFilesByNetwork: {},
|
||||||
pendingOffers: {},
|
pendingOffers: {},
|
||||||
fileProgress: {},
|
fileProgress: {},
|
||||||
|
resumableFiles: {},
|
||||||
|
reactions: {},
|
||||||
|
|
||||||
connect(url: string) {
|
connect(url: string) {
|
||||||
const adapter = new DaemonAdapter(url)
|
const adapter = new DaemonAdapter(url)
|
||||||
@@ -181,6 +193,10 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sendReaction(networkId, mid, emoji) {
|
||||||
|
get().send({ type: 'send_reaction', network_id: networkId, reaction_mid: mid, reaction_emoji: emoji })
|
||||||
|
},
|
||||||
|
|
||||||
createRoom(name) {
|
createRoom(name) {
|
||||||
const netId = get().activeNetworkId
|
const netId = get().activeNetworkId
|
||||||
if (!netId || !name.trim()) return
|
if (!netId || !name.trim()) return
|
||||||
@@ -209,6 +225,8 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'state_snapshot': {
|
case 'state_snapshot': {
|
||||||
const networks = msg.networks ?? []
|
const networks = msg.networks ?? []
|
||||||
|
const knownPeers: Record<string, string> = {}
|
||||||
|
for (const p of msg.known_peers ?? []) knownPeers[p.id] = p.alias
|
||||||
set({
|
set({
|
||||||
masterAlias: msg.master_alias ?? null,
|
masterAlias: msg.master_alias ?? null,
|
||||||
masterId: msg.master_id ?? null,
|
masterId: msg.master_id ?? null,
|
||||||
@@ -216,6 +234,7 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
networks,
|
networks,
|
||||||
connectedPeers: msg.connected_peers ?? [],
|
connectedPeers: msg.connected_peers ?? [],
|
||||||
activeNetworkId: networks[0]?.network_id ?? null,
|
activeNetworkId: networks[0]?.network_id ?? null,
|
||||||
|
knownPeers,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -272,14 +291,14 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
case 'message_received': {
|
case 'message_received': {
|
||||||
if (msg.message) {
|
if (msg.message) {
|
||||||
const m = msg.message
|
const m = msg.message
|
||||||
const room = m.room
|
const key = `${msg.network_id}:${m.room}`
|
||||||
const fromId = String(m.from)
|
const fromId = String(m.from)
|
||||||
set(s => {
|
set(s => {
|
||||||
const existing = s.messages[room] ?? []
|
const existing = s.messages[key] ?? []
|
||||||
if (m.mid && existing.some(e => e.mid === m.mid)) return s
|
if (m.mid && existing.some(e => e.mid === m.mid)) return s
|
||||||
const prev = s.peerStatus[fromId] ?? {}
|
const prev = s.peerStatus[fromId] ?? {}
|
||||||
return {
|
return {
|
||||||
messages: { ...s.messages, [room]: [...existing, m] },
|
messages: { ...s.messages, [key]: [...existing, m] },
|
||||||
peerStatus: { ...s.peerStatus, [fromId]: { ...prev, lastSeen: m.ts } },
|
peerStatus: { ...s.peerStatus, [fromId]: { ...prev, lastSeen: m.ts } },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -341,10 +360,13 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'file_complete': {
|
case 'file_complete': {
|
||||||
if (msg.path && msg.offer?.name) {
|
// Always clear progress — transfer_id is the xid in daemon mode; offer.xid in browser mode.
|
||||||
// clear progress entry
|
const xid = msg.transfer_id ?? msg.offer?.xid
|
||||||
const xid = msg.offer.xid
|
if (xid) {
|
||||||
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
|
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
|
||||||
|
}
|
||||||
|
// Browser mode: trigger download via anchor click.
|
||||||
|
if (msg.path && msg.offer?.name) {
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = msg.path
|
a.href = msg.path
|
||||||
a.download = msg.offer.name
|
a.download = msg.offer.name
|
||||||
@@ -352,6 +374,46 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'resumable_transfers': {
|
||||||
|
const files = (msg.resumable_files ?? []) as Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
|
||||||
|
if (files.length === 0) break
|
||||||
|
const byHash: Record<string, { name: string; from: string; size: number; offset: number }> = {}
|
||||||
|
for (const f of files) byHash[f.sha256] = { name: f.name, from: f.from, size: f.size, offset: f.offset }
|
||||||
|
set(s => ({ resumableFiles: { ...s.resumableFiles, ...byHash } }))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'reaction': {
|
||||||
|
const mid = msg.reaction_mid
|
||||||
|
const emoji = msg.reaction_emoji
|
||||||
|
const from = msg.peer_id
|
||||||
|
if (!mid || !emoji || !from) break
|
||||||
|
set(s => {
|
||||||
|
const byEmoji = { ...(s.reactions[mid] ?? {}) }
|
||||||
|
const existing = byEmoji[emoji] ?? []
|
||||||
|
if (existing.includes(from)) return s
|
||||||
|
return { reactions: { ...s.reactions, [mid]: { ...byEmoji, [emoji]: [...existing, from] } } }
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'history_loaded': {
|
||||||
|
const room = msg.room
|
||||||
|
const incoming = (msg.messages ?? []) as ChatMessage[]
|
||||||
|
if (!room || incoming.length === 0) break
|
||||||
|
const key = `${msg.network_id}:${room}`
|
||||||
|
set(s => {
|
||||||
|
const existing = s.messages[key] ?? []
|
||||||
|
const existingMids = new Set(existing.map(m => m.mid).filter(Boolean))
|
||||||
|
const fresh = incoming.filter(m => !m.mid || !existingMids.has(m.mid))
|
||||||
|
if (fresh.length === 0) return s
|
||||||
|
const merged = [...fresh, ...existing].sort((a, b) => a.ts - b.ts)
|
||||||
|
const cutoff = fresh[fresh.length - 1]?.ts ?? 0
|
||||||
|
return {
|
||||||
|
messages: { ...s.messages, [key]: merged },
|
||||||
|
historyCutoff: { ...s.historyCutoff, [key]: cutoff },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ export type IpcMsgType =
|
|||||||
| 'shares_list'
|
| 'shares_list'
|
||||||
| 'peer_status'
|
| 'peer_status'
|
||||||
| 'error'
|
| 'error'
|
||||||
|
| 'history_loaded'
|
||||||
|
| 'room_created'
|
||||||
|
| 'create_room'
|
||||||
|
| 'resumable_transfers'
|
||||||
|
| 'send_reaction'
|
||||||
|
| 'reaction'
|
||||||
|
|
||||||
export interface IpcMessage {
|
export interface IpcMessage {
|
||||||
type: IpcMsgType
|
type: IpcMsgType
|
||||||
@@ -113,6 +119,7 @@ export interface IpcMessage {
|
|||||||
master_id?: string
|
master_id?: string
|
||||||
local_peer?: PeerInfo
|
local_peer?: PeerInfo
|
||||||
connected_peers?: PeerInfo[]
|
connected_peers?: PeerInfo[]
|
||||||
|
known_peers?: PeerInfo[]
|
||||||
rooms?: string[]
|
rooms?: string[]
|
||||||
networks?: NetworkInfo[]
|
networks?: NetworkInfo[]
|
||||||
error_message?: string
|
error_message?: string
|
||||||
@@ -124,4 +131,11 @@ export interface IpcMessage {
|
|||||||
conn_state?: PeerConnState
|
conn_state?: PeerConnState
|
||||||
candidate_type?: CandidateType
|
candidate_type?: CandidateType
|
||||||
remote_address?: string
|
remote_address?: string
|
||||||
|
// history_loaded
|
||||||
|
messages?: ChatMessage[]
|
||||||
|
// resumable_transfers
|
||||||
|
resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
|
||||||
|
// reaction
|
||||||
|
reaction_mid?: string
|
||||||
|
reaction_emoji?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user