v0.27.0: Cassette invites — one paste connects two friends

- Connections ▸ Share Library with a Friend… makes a one-time invite code
  (device id, display name, token) and the inviter's send-only folder.
- Connections ▸ Add Friend Library… takes the pasted code: adds the
  inviter, shares our folder, and accepts theirs receive-only when it's
  offered back.
- Syncthing hides an unknown device's folders (verified on 1.30), so the
  inviter probes a new knock only while an invite is open: added with
  nothing shared, completed on the right token, otherwise removed and
  never probed again. Used and cancelled codes go nowhere.
- Sync Settings lists open invites (Cancel Invite) and gives each friend a
  page: avatar, plain-language status with a next step, last seen, last
  synced, Remove Friend. Removals are staged until OK.
- scripts/cassette_pair.py runs throwaway Syncthings on loopback;
  pytest --syncthing drives a real three-machine handshake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:13:07 -07:00
co-authored by Claude Opus 5.5
parent 0dad4cc95e
commit f04e69f9b2
15 changed files with 1783 additions and 33 deletions
+26
View File
@@ -456,6 +456,32 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
registered in `setup.py` via `package_data`; loaded with
`importlib.resources` so an installed copy finds them.
- **`lintunes/cassette/`** — friend library sharing (Round 63+; the spec is
`Cassette friend library.md`). LinTunes never talks to a friend: it drives
the **local** Syncthing's REST API (`syncthing_api.py`, key/address from
Syncthing's `config.xml`, overridable in `config.json`) and Syncthing moves
the bytes. Every failure is a `SyncthingError(step, detail, kind)`, because
the UI promises never to fail silently. One of trav's machines is the
**host** (`host.py`: `config.json` flag + a synced `preferences.json` record
naming it); everything lives in the machine-local
`~/.local/share/lintunes/cassette/` (`state.json` + `friends/<token>/
{out,in,cache}`), never the synced data dir — Syncthing folders must not
nest. A friendship is two folders, `lintunes-cassette-<token>-a` (inviter →
invitee) and `-b`, each send-only for its writer and receive-only for the
reader. **Syncthing hides an unknown device's folders** (verified on 1.30: a
stranger is only a pending *device*), so while an invite is open the
inviter's `CassetteService` *probes* a new knock — adds it with nothing
shared, reads which folder it offers, completes on the right `-b` token or
removes and remembers it (`state.rejected`). With no open invite nothing is
probed and knocks stay pending, untouched. The paste side can't tell "their
computer is off" from "their LinTunes is closed" (Syncthing briefly reports a
stranger's connection as up), so it shows one combined status.
`scripts/cassette_pair.py` starts throwaway Syncthings on loopback (no
discovery there, hence `CassetteService(addresses_for=…)`); tests marked
`syncthing` run a real handshake with `pytest --syncthing`. The service
starts from `run_gui`, never from constructing `MainWindow` — tests build
windows by the dozen and must not reach a real Syncthing.
- **`lintunes/cast/`** — Chromecast playback (Connections menu), using the
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
ephemeral port for the life of a session and the device fetches the *original*