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*
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.26.0"
__version__ = "0.27.0"
+94
View File
@@ -0,0 +1,94 @@
"""Invite codes and the folder ids that carry their token.
An invite code is one line a person pastes into a chat:
LTC1.eyJkIjoiQUJDREVGRy0uLi4iLCJuIjoidHJhdiIsInQiOiIzZjE…
``LTC1.`` + base64url (unpadded) of ``{"d": device id, "n": display name,
"t": token}``. The token is a random one-time secret; it ends up in the ids of
the two Syncthing folders the friendship is made of:
* ``lintunes-cassette-<token>-a`` — the inviter's folder (inviter → invitee)
* ``lintunes-cassette-<token>-b`` — the invitee's folder (invitee → inviter)
The inviter's LinTunes accepts a newcomer only when it offers ``-b`` for an
invite still open on this machine, which is what makes the code single-use.
"""
from __future__ import annotations
import base64
import binascii
import json
import re
import secrets
from dataclasses import dataclass
PREFIX = "LTC1."
FOLDER_PREFIX = "lintunes-cassette-"
# Syncthing device id: 8 groups of 7 base32 characters.
_DEVICE_RE = re.compile(r"^[A-Z2-7]{7}(-[A-Z2-7]{7}){7}$")
_TOKEN_RE = re.compile(r"^[0-9a-f]{16}$")
_FOLDER_RE = re.compile(r"^lintunes-cassette-([0-9a-f]{16})-([ab])$")
NOT_VALID = ("That code isn't valid. Check that the whole code was copied — it "
"starts with LTC1. and is one long line.")
class InviteError(ValueError):
"""Shown to the user as is."""
@dataclass(frozen=True)
class InviteCode:
device_id: str
name: str
token: str
def new_token() -> str:
return secrets.token_hex(8)
def inviter_folder(token: str) -> str:
return f"{FOLDER_PREFIX}{token}-a"
def invitee_folder(token: str) -> str:
return f"{FOLDER_PREFIX}{token}-b"
def parse_folder_id(folder_id: str) -> tuple[str, str] | None:
"""(token, "a"|"b") for one of our folder ids, else None."""
match = _FOLDER_RE.match(folder_id or "")
return (match.group(1), match.group(2)) if match else None
def encode(device_id: str, name: str, token: str) -> str:
payload = json.dumps({"d": device_id, "n": name, "t": token},
separators=(",", ":"), ensure_ascii=False)
body = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii")
return PREFIX + body.rstrip("=")
def decode(text: str) -> InviteCode:
"""Parse a pasted code. Forgiving about whitespace and line breaks a chat
app may have added; strict about everything else."""
compact = "".join((text or "").split())
start = compact.find(PREFIX)
if start < 0:
raise InviteError(NOT_VALID)
body = compact[start + len(PREFIX):]
try:
raw = base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))
data = json.loads(raw.decode("utf-8"))
except (binascii.Error, ValueError, UnicodeDecodeError):
raise InviteError(NOT_VALID) from None
if not isinstance(data, dict):
raise InviteError(NOT_VALID)
device_id = str(data.get("d", "")).upper()
token = str(data.get("t", "")).lower()
name = str(data.get("n", "")).strip()[:80] or "Your friend"
if not _DEVICE_RE.match(device_id) or not _TOKEN_RE.match(token):
raise InviteError(NOT_VALID)
return InviteCode(device_id=device_id, name=name, token=token)
+78
View File
@@ -0,0 +1,78 @@
"""What goes into my outgoing folder for a friend — the files they read.
``out/`` is a Syncthing send-only folder, so everything written here reaches
the friend. Writes are atomic (temp name, then rename) and **skipped when the
bytes wouldn't change**: an unchanged rewrite still moves the mtime, and
Syncthing would dutifully re-announce the file.
This round writes only the introduction — ``cassette.json`` (display name,
format version, avatar name) and ``avatar.png``. The trimmed library and
requests file join it later.
"""
from __future__ import annotations
import json
from pathlib import Path
FORMAT_VERSION = 1
INFO_FILE = "cassette.json"
AVATAR_FILE = "avatar.png"
STIGNORE = ".stignore"
# Our own temp files never need to reach the friend. (Syncthing ignores its
# own .syncthing.*.tmp already.)
STIGNORE_TEXT = "// written by LinTunes\n*.tmp\n*.part\n"
def write_bytes_if_changed(path: Path, data: bytes) -> bool:
"""Atomically write ``data`` unless ``path`` already holds exactly it.
Returns True when something was written."""
try:
if path.read_bytes() == data:
return False
except OSError:
pass
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_bytes(data)
tmp.replace(path)
return True
def write_json_if_changed(path: Path, data) -> bool:
text = json.dumps(data, indent=1, ensure_ascii=False, sort_keys=True)
return write_bytes_if_changed(path, text.encode("utf-8"))
def read_json(path: Path, default=None):
"""A friend's file, tolerating absence and a half-synced/garbled copy."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return default
def prepare_out_dir(out: Path):
out.mkdir(parents=True, exist_ok=True)
write_bytes_if_changed(out / STIGNORE, STIGNORE_TEXT.encode())
def publish_intro(out: Path, display_name: str, avatar_source: Path | None):
"""cassette.json + avatar.png. ``avatar_source`` None removes the avatar."""
prepare_out_dir(out)
has_avatar = avatar_source is not None and avatar_source.exists()
if has_avatar:
write_bytes_if_changed(out / AVATAR_FILE, avatar_source.read_bytes())
else:
(out / AVATAR_FILE).unlink(missing_ok=True)
write_json_if_changed(out / INFO_FILE, {
"format": FORMAT_VERSION,
"name": display_name,
"avatar": AVATAR_FILE if has_avatar else "",
})
def read_intro(in_dir: Path) -> dict:
"""A friend's cassette.json, or {}."""
data = read_json(in_dir / INFO_FILE, {})
return data if isinstance(data, dict) else {}
+554
View File
@@ -0,0 +1,554 @@
"""The Cassette handshake: invites, knocks, and what each friendship's status is.
``CassetteService`` runs only on the host machine. It owns the Cassette state
(``state.py``) and keeps it true to what Syncthing can actually see, on a
background thread that waits on Syncthing's event stream and reconciles after
each batch. User actions (create an invite, paste a code, remove a friend) run
on the caller's thread and raise ``SyncthingError`` / ``InviteError`` with the
step that failed, so a dialog can show it.
**The knock.** Syncthing hides an *unknown* device's folders: a stranger
connecting shows up only as a pending device, with its hostname. So while an
invite is open, a newly pending device is **probed** — added with nothing
shared, which grants it nothing but lets it tell us which folder it offers.
If that is ``-b`` for an open invite, the friendship completes (share ``-a``
with them, accept ``-b`` receive-only, invite used). If it offers anything
else, or connects and offers nothing, it is removed again and remembered as
rejected; Syncthing goes back to listing it as pending, untouched.
Everything is safe to repeat: every step checks what exists before creating
it, and reconcile rebuilds its picture from Syncthing each time.
"""
from __future__ import annotations
import logging
import shutil
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes.config import load_config
from lintunes.cassette import invite as inv
from lintunes.cassette import publish
from lintunes.cassette import syncthing_api as st
from lintunes.cassette.state import (
CANCELLED, INVITEE, INVITER, OPEN, USED, CassetteState, Friend, Invite,
cassette_root, friend_dir, in_dir, load_state, now_iso, out_dir,
save_state,
)
log = logging.getLogger(__name__)
ALREADY_USED = ("This invite has already been used. Ask your friend for a new "
"one.")
OWN_CODE = "That's your own invite code. Send it to a friend to paste."
# A probed device that connects but offers no invite folder within this long
# is let go. One that never connects is let go after the longer window, and
# may be probed again after RETRY_PROBE (it may simply have been offline).
PROBE_CONNECTED_TIMEOUT = timedelta(minutes=2)
PROBE_TIMEOUT = timedelta(minutes=15)
RETRY_PROBE = timedelta(hours=1)
EVENT_TYPES = ("PendingDevicesChanged", "PendingFoldersChanged",
"DeviceConnected", "DeviceDisconnected", "FolderCompletion",
"StateChanged", "ConfigSaved")
EVENT_TIMEOUT_S = 60
DOWN_RETRY_S = 15
# Status.code
WAITING_PASTE = "waiting_paste"
WAITING_COMPUTER = "waiting_computer"
WAITING_LINTUNES = "waiting_lintunes"
CONNECTED = "connected"
OFFLINE = "offline"
USED_CODE = "used"
CANCELLED_CODE = "cancelled"
SYNCTHING_DOWN = "syncthing_down"
NO_API = "no_api"
@dataclass
class Status:
code: str
text: str
hint: str = ""
last_seen: str = ""
last_synced: str = ""
up_to_date: bool = False
def _parse(when: str) -> datetime | None:
"""An ISO stamp as naive UTC (ours are naive UTC; Syncthing's end in Z or
carry an offset)."""
if not when:
return None
try:
parsed = datetime.fromisoformat(when.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _utc_now() -> datetime:
return datetime.fromisoformat(now_iso())
def friend_status(friend: Friend, *, connected: bool, in_accepted: bool,
remote_state: str, up_to_date: bool, last_seen: str) -> Status:
"""Plain-language status for one friendship. Pure, so it's testable."""
name = friend.name or "your friend"
computer_hint = (
f"Both computers need to be on with Syncthing running. If this stays "
f"like this, check that global discovery and relaying are enabled in "
f"Syncthing's settings (Actions ▸ Settings ▸ Connections).")
if not in_accepted:
# The paste side can't tell "their computer is off" from "their
# LinTunes isn't open": Syncthing reports a stranger's connection as
# up for the moment before the other side hangs up on it. So one
# honest status covering both.
return Status(WAITING_COMPUTER,
f"Waiting for {name}'s computer",
computer_hint + f" It also waits until {name} opens "
f"LinTunes. If it never connects, the code may already "
f"have been used — ask {name} for a new one.",
last_seen=last_seen)
if not connected:
if not friend.connected_at:
return Status(WAITING_COMPUTER, f"Waiting for {name}'s computer",
computer_hint, last_seen=last_seen,
last_synced=friend.last_synced)
return Status(OFFLINE, f"{name}'s computer is offline",
"Everything catches up the next time both computers "
"are on.", last_seen=last_seen,
last_synced=friend.last_synced)
if remote_state == "notSharing":
return Status(WAITING_LINTUNES, f"Waiting for {name} to open LinTunes",
f"{name}'s Syncthing can see you; their LinTunes accepts "
f"the connection the next time it's open.",
last_seen=last_seen, last_synced=friend.last_synced)
return Status(CONNECTED, "Connected",
"" if up_to_date else "Syncing…",
last_seen=last_seen, last_synced=friend.last_synced,
up_to_date=up_to_date)
class CassetteService(QObject):
changed = pyqtSignal() # state or statuses moved
notice = pyqtSignal(str) # "<Name> is now connected."
step_failed = pyqtSignal(str, str) # step, what Syncthing said
def __init__(self, root: Path | None = None, config_loader=load_config,
session_factory=None, addresses_for=None, parent=None):
super().__init__(parent)
self.root = root or cassette_root()
self._config_loader = config_loader
self._session_factory = session_factory
# Where to dial a new device. "dynamic" (discovery) in real life; the
# two-instance test pair runs on loopback, where discovery can't work.
self._addresses_for = addresses_for or (lambda device_id: ["dynamic"])
self._lock = threading.RLock()
self._state = load_state(self.root)
self._statuses: dict[str, Status] = {}
self._api = Status(SYNCTHING_DOWN, "Checking Syncthing…")
self._stop = threading.Event()
self._kick = threading.Event()
self._thread: threading.Thread | None = None
self._last_failure: tuple[str, str] | None = None
# ---- lifecycle ----
def start(self):
if self._thread is None:
self._thread = threading.Thread(target=self._loop, daemon=True,
name="cassette")
self._thread.start()
def stop(self):
self._stop.set()
self._kick.set()
def kick(self):
"""Reconcile soon (after the current event wait ends)."""
self._kick.set()
# ---- reading ----
def state(self) -> CassetteState:
with self._lock:
return self._state.copy()
def status(self, token: str) -> Status | None:
with self._lock:
return self._statuses.get(token)
def api_status(self) -> Status:
with self._lock:
return self._api
# ---- plumbing ----
def client(self) -> st.SyncthingClient:
endpoint = st.resolve_endpoint(self._config_loader())
if endpoint is None:
raise st.SyncthingError("Connecting to Syncthing",
"no API key was found", st.NO_KEY)
session = self._session_factory() if self._session_factory else None
return st.SyncthingClient(endpoint, session=session)
def _save(self):
save_state(self._state, self.root)
def _publish_intro(self, token: str):
avatar = self.root / self._state.avatar if self._state.avatar else None
publish.publish_intro(out_dir(self.root, token),
self._state.display_name, avatar)
# ---- user actions ----
def create_invite(self) -> str:
"""Create a token and its outgoing folder; return the code to send."""
client = self.client()
my_id = client.my_id()
with self._lock:
token = inv.new_token()
self._publish_intro(token)
folder_id = inv.inviter_folder(token)
client.add_folder(folder_id, f"LinTunes: {self._state.display_name}"
" (invite)", out_dir(self.root, token), "sendonly",
step="Creating your shared folder")
self._state.invites[token] = Invite(token=token, created=now_iso(),
folder_id=folder_id)
self._save()
code = inv.encode(my_id, self._state.display_name, token)
self.changed.emit()
self.kick()
return code
def cancel_invite(self, token: str, client=None):
with self._lock:
invite = self._state.invites.get(token)
if invite is None or invite.status != OPEN:
return
client = client or self.client()
if client.folder(invite.folder_id) is not None:
client.remove_folder(invite.folder_id,
step="Removing the invite's shared folder")
invite.status = CANCELLED
shutil.rmtree(friend_dir(self.root, token), ignore_errors=True)
self._save()
self.changed.emit()
def accept_code(self, text: str) -> Friend:
"""Paste side: add the inviter, share our folder, then wait for theirs."""
code = inv.decode(text)
client = self.client()
my_id = client.my_id()
with self._lock:
if code.device_id == my_id or code.token in self._state.invites:
raise inv.InviteError(OWN_CODE)
if code.token in self._state.friends:
raise inv.InviteError(ALREADY_USED)
if client.device(code.device_id) is None:
client.add_device(code.device_id, code.name,
addresses=self._addresses_for(code.device_id))
self._publish_intro(code.token)
client.add_folder(inv.invitee_folder(code.token),
f"LinTunes: {self._state.display_name} → {code.name}",
out_dir(self.root, code.token), "sendonly",
[code.device_id],
step=f"Sharing your folder with {code.name}")
friend = Friend(token=code.token, device_id=code.device_id,
name=code.name, role=INVITEE,
out_folder=inv.invitee_folder(code.token),
in_folder=inv.inviter_folder(code.token),
added=now_iso())
self._state.friends[code.token] = friend
self._save()
self.changed.emit()
self.kick()
return friend
def remove_friend(self, token: str, client=None):
with self._lock:
friend = self._state.friends.get(token)
if friend is None:
return
client = client or self.client()
for folder_id in (friend.out_folder, friend.in_folder):
if folder_id and client.folder(folder_id) is not None:
client.remove_folder(folder_id,
step=f"Removing {friend.name}'s folder")
# Only drop the device if no other friendship rides on it.
if not any(f.device_id == friend.device_id
for t, f in self._state.friends.items() if t != token):
if client.device(friend.device_id) is not None:
client.remove_device(friend.device_id, friend.name)
shutil.rmtree(friend_dir(self.root, token), ignore_errors=True)
del self._state.friends[token]
self._statuses.pop(token, None)
self._save()
self.changed.emit()
def apply_settings(self, edited: CassetteState, cancel_invites=(),
remove_friends=()):
"""Sync Settings pressed OK: adopt the edited fields, carry out the
staged removals, republish what changed."""
errors = []
for token in cancel_invites:
try:
self.cancel_invite(token)
except st.SyncthingError as exc:
errors.append(exc)
for token in remove_friends:
try:
self.remove_friend(token)
except st.SyncthingError as exc:
errors.append(exc)
with self._lock:
state = self._state
state.display_name = edited.display_name
state.avatar = edited.avatar
state.share_same_for_all = edited.share_same_for_all
state.general = edited.general
for token, friend in state.friends.items():
mine = edited.friends.get(token)
if mine is not None:
friend.selection = mine.selection
friend.layout = mine.layout
friend.followed_mode = mine.followed_mode
self._save()
for token in list(state.friends) + [i.token for i in state.open_invites()]:
self._publish_intro(token)
self.changed.emit()
if errors:
raise errors[0]
# ---- the background loop ----
def _loop(self):
since = 0
client = None
while not self._stop.is_set():
try:
client = client or self.client()
self.reconcile(client)
self._kick.clear()
events = client.events(since, EVENT_TYPES, EVENT_TIMEOUT_S)
if events:
since = max(e.get("id", since) for e in events)
except st.SyncthingError as exc:
client = None
since = 0
self._set_api_down(exc)
self._kick.wait(DOWN_RETRY_S)
self._kick.clear()
except Exception: # never let the thread die silently
log.exception("cassette loop")
self._kick.wait(DOWN_RETRY_S)
self._kick.clear()
def _set_api_down(self, exc: st.SyncthingError):
if exc.kind in (st.BAD_KEY, st.NO_KEY):
status = Status(NO_API, "Can't reach Syncthing's API",
"Enter Syncthing's API key in Sync Settings ▸ "
"General, or let LinTunes read it from "
"Syncthing's config.")
else:
status = Status(SYNCTHING_DOWN, "Syncthing isn't running",
"Start Syncthing, then press Retry in Sync "
"Settings ▸ General.")
with self._lock:
changed = self._api.code != status.code
self._api = status
if changed:
self.changed.emit()
def _fail(self, exc: st.SyncthingError):
"""A background step failed: say so once (not every reconcile)."""
key = (exc.step, exc.detail)
if key != self._last_failure:
self._last_failure = key
self.step_failed.emit(exc.step, exc.detail)
def reconcile(self, client: st.SyncthingClient):
"""Bring state in line with Syncthing and recompute every status."""
version = client.version()
with self._lock:
self._api = Status(CONNECTED, f"Syncthing {version} is running.")
try:
pending_devices = client.pending_devices()
pending_folders = client.pending_folders()
self._handle_offers(client, pending_folders, pending_devices)
self._handle_probes(client, pending_devices, pending_folders)
self._last_failure = None
except st.SyncthingError as exc:
if exc.kind != st.OTHER:
raise
self._fail(exc)
notices = self._refresh_statuses(client)
for message in notices:
self.notice.emit(message)
self.changed.emit()
def _handle_offers(self, client, pending_folders: dict,
pending_devices: dict):
state = self._state
for folder_id, info in pending_folders.items():
parsed = inv.parse_folder_id(folder_id)
offered_by = (info or {}).get("offeredBy", {})
for device_id in offered_by:
if parsed is None:
continue
token, side = parsed
if side == "b":
invite = state.invites.get(token)
if invite is not None and invite.status == OPEN \
and token not in state.friends:
name = (pending_devices.get(device_id, {}).get("name")
or "New friend")
self._complete_invite(client, invite, device_id, name)
elif device_id in state.probing:
# A knock with a used or cancelled code.
self._reject(client, device_id)
elif side == "a":
friend = state.friends.get(token)
if (friend is not None and friend.role == INVITEE
and friend.device_id == device_id
and client.folder(folder_id) is None):
in_path = in_dir(self.root, token)
in_path.mkdir(parents=True, exist_ok=True)
client.add_folder(
folder_id, f"LinTunes: from {friend.name}", in_path,
"receiveonly", [device_id],
step=f"Accepting {friend.name}'s folder")
# A probed device that offers folders, none of them an open invite's.
for device_id in list(state.probing):
offers = [fid for fid, info in pending_folders.items()
if device_id in (info or {}).get("offeredBy", {})]
if offers and device_id in state.probing:
self._reject(client, device_id)
def _complete_invite(self, client, invite: Invite, device_id: str,
hostname: str):
token = invite.token
client.add_device(device_id, hostname,
addresses=self._addresses_for(device_id))
client.share_folder(invite.folder_id, device_id,
step="Sharing your folder with your friend")
in_path = in_dir(self.root, token)
in_path.mkdir(parents=True, exist_ok=True)
client.add_folder(inv.invitee_folder(token), "LinTunes: from a friend",
in_path, "receiveonly", [device_id],
step="Accepting your friend's folder")
self._state.friends[token] = Friend(
token=token, device_id=device_id, name=hostname, role=INVITER,
out_folder=invite.folder_id, in_folder=inv.invitee_folder(token),
added=now_iso())
invite.status = USED
self._state.probing.pop(device_id, None)
self._save()
def _reject(self, client, device_id: str):
self._state.probing.pop(device_id, None)
self._state.rejected[device_id] = now_iso()
if client.device(device_id) is not None:
client.remove_device(device_id, "an unknown computer")
self._save()
def _handle_probes(self, client, pending_devices: dict,
pending_folders: dict):
state = self._state
now = _utc_now()
known = {f.device_id for f in state.friends.values()}
opens = state.open_invites()
if opens:
oldest = min((_parse(i.created) or now) for i in opens)
for device_id, info in pending_devices.items():
if device_id in known or device_id in state.probing:
continue
rejected = _parse(state.rejected.get(device_id, ""))
if rejected is not None:
continue
seen = _parse((info or {}).get("time", "")) or now
if seen + timedelta(seconds=5) < oldest:
continue # knocking since before any open invite
client.add_device(device_id, (info or {}).get("name") or "?",
addresses=self._addresses_for(device_id))
state.probing[device_id] = now_iso()
self._save()
if not state.probing:
return
connections = client.connections()
for device_id, started in list(state.probing.items()):
began = _parse(started) or now
connected = connections.get(device_id, {}).get("connected")
if not opens:
self._release_probe(client, device_id)
elif connected and now - began > PROBE_CONNECTED_TIMEOUT:
self._reject(client, device_id)
elif now - began > PROBE_TIMEOUT:
self._release_probe(client, device_id)
def _release_probe(self, client, device_id: str):
"""Stop probing without holding it against the device."""
self._state.probing.pop(device_id, None)
if client.device(device_id) is not None:
client.remove_device(device_id, "an unknown computer")
self._save()
def _refresh_statuses(self, client) -> list[str]:
state = self._state
notices = []
statuses = {}
for invite in state.invites.values():
if invite.status == OPEN:
statuses[invite.token] = Status(
WAITING_PASTE, "Waiting for your friend to paste the code")
elif invite.status == CANCELLED:
statuses[invite.token] = Status(CANCELLED_CODE,
"This invite was cancelled")
if not state.friends:
self._statuses = statuses
return notices
connections = client.connections()
stats = client.device_stats()
dirty = False
for token, friend in state.friends.items():
in_accepted = client.folder(friend.in_folder) is not None
connected = bool(connections.get(friend.device_id, {}).get("connected"))
remote_state, up_to_date = "", False
if in_accepted and connected:
remote = client.completion(friend.out_folder, friend.device_id)
remote_state = remote.get("remoteState", "")
local = client.completion(friend.in_folder)
up_to_date = (remote.get("completion", 0) >= 100
and local.get("completion", 0) >= 100
and remote_state != "notSharing")
# The friend's own display name, once their folder has synced.
intro = publish.read_intro(in_dir(self.root, token))
name = str(intro.get("name") or "").strip()[:80]
if name and name != friend.name:
friend.name = name
dirty = True
if in_accepted and connected and remote_state != "notSharing" \
and not friend.connected_at:
friend.connected_at = now_iso()
notices.append(f"{friend.name} is now connected.")
dirty = True
if up_to_date:
friend.last_synced = now_iso()
dirty = dirty or not self._statuses.get(token, Status("", "")).up_to_date
statuses[token] = friend_status(
friend, connected=connected, in_accepted=in_accepted,
remote_state=remote_state, up_to_date=up_to_date,
last_seen=stats.get(friend.device_id, {}).get("lastSeen", ""))
self._statuses = statuses
if dirty:
self._save()
return notices
+15 -2
View File
@@ -122,6 +122,7 @@ class Friend:
in_folder: str = "" # Syncthing folder id they write (receive-only here)
added: str = ""
connected_at: str = "" # first time the handshake completed
last_synced: str = "" # last time both folders were seen up to date
selection: Selection = field(default_factory=Selection)
layout: str = LAYOUT_FOLDER
followed_mode: str = FOLLOW_SEPARATE
@@ -133,6 +134,7 @@ class Friend:
"name": self.name, "role": self.role,
"out_folder": self.out_folder, "in_folder": self.in_folder,
"added": self.added, "connected_at": self.connected_at,
"last_synced": self.last_synced,
"selection": self.selection.to_dict(), "layout": self.layout,
"followed_mode": self.followed_mode,
"followed": list(self.followed),
@@ -146,6 +148,7 @@ class Friend:
in_folder=d.get("in_folder", ""),
added=d.get("added", ""),
connected_at=d.get("connected_at", ""),
last_synced=d.get("last_synced", ""),
selection=Selection.from_dict(d.get("selection")),
layout=d.get("layout", LAYOUT_FOLDER),
followed_mode=d.get("followed_mode", FOLLOW_SEPARATE),
@@ -161,6 +164,13 @@ class CassetteState:
general: Selection = field(default_factory=Selection)
invites: dict = field(default_factory=dict) # token -> Invite
friends: dict = field(default_factory=dict) # token -> Friend
# Unknown devices knocking while an invite is open are added with nothing
# shared, just long enough to see which folder they offer (Syncthing
# hides an unknown device's folders): device id -> when we started.
probing: dict = field(default_factory=dict)
# Devices that turned out not to hold an open invite: device id -> when.
# They stay pending in Syncthing, untouched, and aren't probed again.
rejected: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {"version": FORMAT_VERSION,
@@ -168,7 +178,8 @@ class CassetteState:
"share_same_for_all": self.share_same_for_all,
"general": self.general.to_dict(),
"invites": [i.to_dict() for i in self.invites.values()],
"friends": [f.to_dict() for f in self.friends.values()]}
"friends": [f.to_dict() for f in self.friends.values()],
"probing": dict(self.probing), "rejected": dict(self.rejected)}
@classmethod
def from_dict(cls, d: dict | None) -> "CassetteState":
@@ -176,7 +187,9 @@ class CassetteState:
state = cls(display_name=d.get("display_name") or default_display_name(),
avatar=d.get("avatar", ""),
share_same_for_all=bool(d.get("share_same_for_all", False)),
general=Selection.from_dict(d.get("general")))
general=Selection.from_dict(d.get("general")),
probing=dict(d.get("probing", {})),
rejected=dict(d.get("rejected", {})))
for raw in d.get("invites", []):
invite = Invite.from_dict(raw)
state.invites[invite.token] = invite
+2 -2
View File
@@ -180,12 +180,12 @@ class SyncthingClient:
return None
raise
def add_device(self, device_id: str, name: str):
def add_device(self, device_id: str, name: str, addresses=("dynamic",)):
"""Add (or rename) a device. Idempotent."""
self._request("PUT", f"/rest/config/devices/{device_id}",
f"Adding {name}'s computer to Syncthing",
json={"deviceID": device_id, "name": name,
"addresses": ["dynamic"]})
"addresses": list(addresses)})
def remove_device(self, device_id: str, name: str = "the device"):
self._request("DELETE", f"/rest/config/devices/{device_id}",
+106
View File
@@ -0,0 +1,106 @@
"""Share Library with a Friend… and Add Friend Library… — the one-paste
connection. Both talk to ``CassetteService``; every failure shows which step
failed and what Syncthing said."""
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QGuiApplication
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QPlainTextEdit,
QPushButton, QVBoxLayout,
)
from lintunes.cassette import invite as inv
from lintunes.cassette import syncthing_api as st
def failure_text(exc: Exception) -> str:
"""What to show for a failed action: the step and Syncthing's words, plus
the next step when the cause is Syncthing itself."""
if isinstance(exc, inv.InviteError):
return str(exc)
if isinstance(exc, st.SyncthingError):
if exc.kind == st.NOT_RUNNING:
return (f"{exc.step} failed: Syncthing isn't running. Start "
f"Syncthing and try again.")
if exc.kind in (st.BAD_KEY, st.NO_KEY):
return (f"{exc.step} failed: LinTunes can't reach Syncthing's API "
f"({exc.detail}). Check the API key in Sync Settings ▸ "
f"General.")
return f"{exc.step} failed. Syncthing said: {exc.detail}"
return f"Something went wrong: {exc}"
class ShareInviteDialog(QDialog):
"""Shows a freshly made invite code with a Copy button."""
def __init__(self, code: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Share Library with a Friend")
self.resize(520, 260)
layout = QVBoxLayout(self)
intro = QLabel(
"Send this code to one friend. They paste it into "
"Connections ▸ Add Friend Library… in their LinTunes.\n\n"
"It works once, and it doesn't expire. You don't both need to be "
"online now: you'll be connected the next time both computers are "
"on. Unused invites are listed in Sync Settings, where you can "
"cancel them.")
intro.setWordWrap(True)
layout.addWidget(intro)
self.code = QPlainTextEdit(code)
self.code.setReadOnly(True)
self.code.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
layout.addWidget(self.code, stretch=1)
row = QHBoxLayout()
self.copy = QPushButton("Copy Code")
self.copy.setDefault(True)
self.copy.clicked.connect(self._copy)
self.copied = QLabel("")
row.addWidget(self.copy)
row.addWidget(self.copied, stretch=1)
done = QPushButton("Done")
done.clicked.connect(self.accept)
row.addWidget(done)
layout.addLayout(row)
def _copy(self):
QGuiApplication.clipboard().setText(self.code.toPlainText())
self.copied.setText("Copied.")
class AddFriendDialog(QDialog):
"""Paste a code, press Connect. ``service.accept_code`` does the work."""
def __init__(self, service, parent=None):
super().__init__(parent)
self._service = service
self.friend = None
self.setWindowTitle("Add Friend Library")
self.resize(520, 240)
layout = QVBoxLayout(self)
label = QLabel("Paste the invite code your friend sent you:")
layout.addWidget(label)
self.field = QPlainTextEdit()
self.field.setPlaceholderText("LTC1.…")
layout.addWidget(self.field, stretch=1)
self.message = QLabel("")
self.message.setWordWrap(True)
self.message.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
layout.addWidget(self.message)
buttons = QDialogButtonBox()
self.connect_button = buttons.addButton(
"Connect", QDialogButtonBox.ButtonRole.AcceptRole)
buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self._connect)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _connect(self):
try:
self.friend = self._service.accept_code(self.field.toPlainText())
except (inv.InviteError, st.SyncthingError) as exc:
self.message.setText(failure_text(exc))
return
self.accept()
+83 -11
View File
@@ -1,8 +1,9 @@
"""Cassette's place in the main window: its Connections-menu group, the
"which machine is the host" gate, and the dialogs those items open.
"which machine is the host" gate, the service that runs the handshake, and
the dialogs those menu items open.
Kept out of main_window.py, which is big enough; MainWindow owns one of these
and calls ``build_menu`` / ``refresh_menu``.
and calls ``build_menu`` / ``refresh_menu`` / ``shutdown``.
"""
from __future__ import annotations
@@ -11,17 +12,52 @@ from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QDialog, QMessageBox
from lintunes.cassette import host
from lintunes.cassette.state import cassette_root, load_state, save_state
from lintunes.cassette import syncthing_api as st
from lintunes.config import load_config, save_config
NOTICE_MS = 10000
class CassetteUi(QObject):
def __init__(self, window, prefs, parent=None):
def __init__(self, window, prefs, parent=None, service_factory=None):
super().__init__(parent or window)
self._window = window
self._prefs = prefs
self._elsewhere_action: QAction | None = None
self._actions: list[QAction] = []
self._service_factory = service_factory
self.service = None
# ---- the service (host machine only) ----
def start_if_host(self):
"""At startup: run the handshake loop if this machine is the host."""
if host.is_host(self._prefs):
self._ensure_service()
def _ensure_service(self):
if self.service is None:
if self._service_factory is not None:
self.service = self._service_factory()
else:
from lintunes.cassette.service import CassetteService
self.service = CassetteService(parent=self)
self.service.notice.connect(self._on_notice)
self.service.step_failed.connect(self._on_step_failed)
self.service.start()
return self.service
def shutdown(self):
if self.service is not None:
self.service.stop()
def _on_notice(self, message: str):
self._window.statusBar().showMessage(message, NOTICE_MS)
def _on_step_failed(self, step: str, detail: str):
QMessageBox.warning(
self._window, "Friend Sharing",
f"{step} failed.\n\nSyncthing said: {detail}")
# ---- menu ----
@@ -32,10 +68,13 @@ class CassetteUi(QObject):
self._elsewhere_action.setEnabled(False)
self._elsewhere_action.setVisible(False)
menu.addAction(self._elsewhere_action)
settings = QAction("Sync Settings…", self._window)
settings.triggered.connect(self.show_sync_settings)
menu.addAction(settings)
self._actions.append(settings)
for text, slot in (("Share Library with a Friend…", self.share_library),
("Add Friend Library…", self.add_friend),
("Sync Settings…", self.show_sync_settings)):
action = QAction(text, self._window)
action.triggered.connect(slot)
menu.addAction(action)
self._actions.append(action)
menu.addSeparator()
def refresh_menu(self):
@@ -54,6 +93,7 @@ class CassetteUi(QObject):
no machine has yet."""
status, name = host.host_status(self._prefs)
if status == host.HERE:
self._ensure_service()
return True
if status == host.ELSEWHERE:
QMessageBox.information(
@@ -72,17 +112,43 @@ class CassetteUi(QObject):
if answer != QMessageBox.StandardButton.Yes:
return False
host.claim(self._prefs)
self._ensure_service()
return True
# ---- dialogs ----
def share_library(self):
if not self.ensure_host():
return
from lintunes.gui.cassette_dialogs import ShareInviteDialog, failure_text
try:
code = self.service.create_invite()
except st.SyncthingError as exc:
QMessageBox.warning(self._window, "Share Library with a Friend",
failure_text(exc))
return
ShareInviteDialog(code, self._window).exec()
def add_friend(self):
if not self.ensure_host():
return
from lintunes.gui.cassette_dialogs import AddFriendDialog
dialog = AddFriendDialog(self.service, self._window)
if dialog.exec() == QDialog.DialogCode.Accepted and dialog.friend:
self._window.statusBar().showMessage(
f"Connecting to {dialog.friend.name}… You'll see "
f"\"{dialog.friend.name} is now connected.\" when it's done.",
NOTICE_MS)
def show_sync_settings(self):
if not self.ensure_host():
return
from lintunes.gui.cassette_dialogs import failure_text
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
root = cassette_root()
service = self.service
config = load_config()
dialog = SyncSettingsDialog(load_state(root), root, config, self._window)
dialog = SyncSettingsDialog(service.state(), service.root, config,
self._window, service=service)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
dialog.apply()
@@ -91,4 +157,10 @@ class CassetteUi(QObject):
config = load_config()
config.update(changes)
save_config(config)
save_state(dialog.state, root)
try:
service.apply_settings(dialog.state,
cancel_invites=dialog.cancelled_invites,
remove_friends=dialog.removed_friends)
except st.SyncthingError as exc:
QMessageBox.warning(self._window, "Sync Settings", failure_text(exc))
service.kick()
+8 -1
View File
@@ -95,7 +95,9 @@ class MainWindow(QMainWindow):
# Context playback started from: "library" or "playlist:<pid>". Scopes
# the now-playing speaker icon to where the song is actually playing.
self._now_playing_context = ""
# Friend sharing's menu group and dialogs.
# Friend sharing's menu group and dialogs. Its background service is
# started by run_gui (host machine only), never by constructing the
# window — tests build windows by the dozen.
self._cassette = CassetteUi(self, prefs)
self.setWindowTitle("LinTunes")
@@ -386,6 +388,10 @@ class MainWindow(QMainWindow):
# always reflects plug/unplug, the cast session and the current view.
connections_menu.aboutToShow.connect(self._refresh_connection_actions)
@property
def cassette(self) -> CassetteUi:
return self._cassette
def _add_action(self, menu, text, shortcut, slot):
action = QAction(text, self)
if shortcut:
@@ -1707,6 +1713,7 @@ class MainWindow(QMainWindow):
self._sync_inhibitor.release()
# Before the player: this stops the device and kills the HTTP server it
# is fetching from. Skipped, the Chromecast sits buffering a dead URL.
self._cassette.shutdown()
self._cast.shutdown()
self.player.shutdown()
self._manager.flush()
+171 -16
View File
@@ -7,18 +7,21 @@ None" can wipe a careful selection, and Cancel has to be able to undo that.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QPushButton, QSplitter,
QStackedWidget, QVBoxLayout, QWidget,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton,
QSplitter, QStackedWidget, QVBoxLayout, QWidget,
)
from lintunes.cassette import probe as probe_mod
from lintunes.cassette import publish
from lintunes.cassette import syncthing_api as st
from lintunes.cassette.state import OPEN, in_dir
AVATAR_PX = 256
AVATAR_PREVIEW_PX = 72
@@ -39,6 +42,29 @@ def load_avatar_image(path: Path) -> QImage | None:
Qt.TransformationMode.SmoothTransformation)
def format_when(stamp: str) -> str:
"""A stored UTC stamp (ours naive, Syncthing's with Z) as local time a
person reads: "today 14:02", "yesterday 09:15", "3 Sep 2026 18:40"."""
if not stamp:
return "never"
try:
when = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
except ValueError:
return stamp
if when.year < 2000: # Syncthing's zero time: never seen
return "never"
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
local = when.astimezone()
today = datetime.now().astimezone().date()
clock = local.strftime("%H:%M")
if local.date() == today:
return f"today {clock}"
if (today - local.date()).days == 1:
return f"yesterday {clock}"
return f"{local.day} {local.strftime('%b %Y')} {clock}"
class AvatarSquare(QLabel):
"""Fixed square preview; gray with a hint when empty."""
@@ -119,6 +145,12 @@ class GeneralPage(QWidget):
form.addWidget(self._retry, alignment=Qt.AlignmentFlag.AlignLeft)
layout.addWidget(box)
self.invites_box = QGroupBox("Invites waiting to be used")
self._invites_layout = QVBoxLayout(self.invites_box)
layout.addWidget(self.invites_box)
self.cancelled_invites: set[str] = set()
self.refresh_invites(None)
you = QGroupBox("You")
you_form = QFormLayout(you)
self.display_name = QLineEdit(state.display_name)
@@ -146,6 +178,38 @@ class GeneralPage(QWidget):
self.show_probe(None)
def refresh_invites(self, service):
"""One row per open invite: when it was made, its status, and a
Cancel Invite button (staged — carried out on OK)."""
while self._invites_layout.count():
item = self._invites_layout.takeAt(0)
if item.widget() is not None:
item.widget().deleteLater()
invites = sorted((i for i in self._state.invites.values()
if i.status == OPEN), key=lambda i: i.created)
self.invites_box.setVisible(bool(invites))
for invite in invites:
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
status = service.status(invite.token) if service else None
text = status.text if status else "Waiting for your friend to paste the code"
label = QLabel(f"Made {format_when(invite.created)} — {text}")
label.setWordWrap(True)
row_layout.addWidget(label, stretch=1)
if invite.token in self.cancelled_invites:
row_layout.addWidget(QLabel("Cancelled when you press OK"))
else:
cancel = QPushButton("Cancel Invite")
cancel.clicked.connect(
lambda _=False, t=invite.token: self._cancel(t, service))
row_layout.addWidget(cancel)
self._invites_layout.addWidget(row)
def _cancel(self, token: str, service):
self.cancelled_invites.add(token)
self.refresh_invites(service)
def show_probe(self, result):
headline, hint = status_text(result)
self._status.setText(headline)
@@ -187,6 +251,84 @@ class GeneralPage(QWidget):
self._avatar.set_image(None)
class FriendPage(QWidget):
"""One friend: who they are, how the connection is doing, Remove Friend.
(Their share selector joins this page later.)"""
def __init__(self, friend, root: Path, parent=None):
super().__init__(parent)
self.friend = friend
self.removed = False
layout = QVBoxLayout(self)
header = QHBoxLayout()
self.avatar = AvatarSquare()
avatar_path = in_dir(root, friend.token) / publish.AVATAR_FILE
if avatar_path.exists():
self.avatar.set_image(QImage(str(avatar_path)))
header.addWidget(self.avatar)
title = QLabel(friend.name)
font = title.font()
font.setPointSize(font.pointSize() + 3)
font.setBold(True)
title.setFont(font)
header.addWidget(title, stretch=1)
layout.addLayout(header)
box = QGroupBox("Connection")
box_layout = QVBoxLayout(box)
self.status = QLabel()
self.status.setWordWrap(True)
self.hint = QLabel()
self.hint.setWordWrap(True)
self.details = QLabel()
box_layout.addWidget(self.status)
box_layout.addWidget(self.hint)
box_layout.addWidget(self.details)
layout.addWidget(box)
layout.addStretch(1)
row = QHBoxLayout()
row.addStretch(1)
self.removed_note = QLabel("Removed when you press OK")
self.removed_note.setVisible(False)
self.remove_button = QPushButton("Remove Friend…")
self.remove_button.clicked.connect(self._remove)
row.addWidget(self.removed_note)
row.addWidget(self.remove_button)
layout.addLayout(row)
self.show_status(None)
def show_status(self, status):
if status is None:
self.status.setText("Checking…")
self.hint.setVisible(False)
self.details.setText(f"Last synced: {format_when(self.friend.last_synced)}")
return
text = status.text
if status.code == "connected":
text += " — up to date" if status.up_to_date else " — syncing"
self.status.setText(text)
self.hint.setText(status.hint)
self.hint.setVisible(bool(status.hint))
self.details.setText(
f"Last seen: {format_when(status.last_seen)}\n"
f"Last synced: {format_when(status.last_synced)}")
def _remove(self):
answer = QMessageBox.question(
self, "Remove Friend",
f"Remove {self.friend.name}?\n\nYou'll stop sharing with each "
f"other, and the copy of their library on this computer is "
f"deleted. Songs you already brought into your library stay.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel)
if answer == QMessageBox.StandardButton.Yes:
self.removed = True
self.removed_note.setVisible(True)
self.remove_button.setEnabled(False)
class SyncSettingsDialog(QDialog):
"""Edits ``state`` (a copy — the caller's is untouched until accepted).
@@ -195,8 +337,9 @@ class SyncSettingsDialog(QDialog):
the change affects."""
def __init__(self, state, root: Path, config: dict, parent=None,
probe=None):
probe=None, service=None):
super().__init__(parent)
self._service = service
self.setWindowTitle("Sync Settings")
self.resize(820, 560)
self.state = state.copy()
@@ -216,7 +359,7 @@ class SyncSettingsDialog(QDialog):
self.friend_pages = {}
for friend in sorted(self.state.friends.values(),
key=lambda f: f.name.casefold()):
page = self._friend_page(friend)
page = FriendPage(friend, root)
self.friend_pages[friend.token] = page
self._add_page(friend.name, page)
self.pages_list.currentRowChanged.connect(self.pages.setCurrentIndex)
@@ -235,23 +378,35 @@ class SyncSettingsDialog(QDialog):
self._probe.finished.connect(self.general.show_probe)
self.general.retry_requested.connect(self._retry)
self._probe.start(self._probe_config())
if service is not None:
service.changed.connect(self._refresh_statuses)
self._refresh_statuses()
def _refresh_statuses(self):
self.general.refresh_invites(self._service)
for token, page in self.friend_pages.items():
page.show_status(self._service.status(token))
@property
def cancelled_invites(self) -> list[str]:
return sorted(self.general.cancelled_invites)
@property
def removed_friends(self) -> list[str]:
return [t for t, page in self.friend_pages.items() if page.removed]
def done(self, result):
if self._service is not None:
try:
self._service.changed.disconnect(self._refresh_statuses)
except TypeError:
pass
super().done(result)
def _add_page(self, label: str, page: QWidget):
self.pages_list.addItem(label)
self.pages.addWidget(page)
def _friend_page(self, friend) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
title = QLabel(friend.name)
font = title.font()
font.setPointSize(font.pointSize() + 3)
font.setBold(True)
title.setFont(font)
layout.addWidget(title)
layout.addStretch(1)
return page
def _probe_config(self) -> dict:
config = dict(self._config)
key = self.general.api_key.text().strip()
+2
View File
@@ -170,6 +170,8 @@ def run_gui(data_dir: Path, files: list[Path],
watcher.changed.connect(manager.check_for_external_changes)
window.show()
# Friend sharing's handshake loop — only on the machine that hosts it.
window.cassette.start_if_host()
# Ask where the music goes only once the window is up, so a new user is
# never met by a dialog floating in front of nothing.
QTimer.singleShot(0, window.check_music_folder)
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Two throwaway Syncthing instances on this one machine, for testing Cassette
without a friend on the other end.
python3 scripts/cassette_pair.py --out /tmp/pair # start, print, wait
python3 scripts/cassette_pair.py --out /tmp/pair --stop # stop them
Each instance gets its own home (cert, config, database), GUI port and listen
port, with global discovery, relays, NAT traversal and usage reporting off: two
Syncthings on loopback find each other through local discovery and nothing ever
leaves the machine. Neither one knows the other until Cassette introduces them,
which is exactly the situation an invite code starts from.
Point a LinTunes at one of them with ``syncthing_api_key`` /
``syncthing_address`` in its config.json (``XDG_CONFIG_HOME`` keeps each
LinTunes' config separate). ``tests/test_round65.py`` drives the pair through a
whole handshake when run with ``-m syncthing``.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import signal
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass
from pathlib import Path
import requests
NAMES = ("alice", "bob")
BASE_GUI_PORT = 28384
BASE_LISTEN_PORT = 28400
@dataclass
class Instance:
name: str
home: str
gui_port: int
listen_port: int
api_key: str
device_id: str = ""
pid: int = 0
@property
def address(self) -> str:
return f"127.0.0.1:{self.gui_port}"
@property
def listen_address(self) -> str:
return f"tcp://127.0.0.1:{self.listen_port}"
def _set(parent, tag, text):
node = parent.find(tag)
if node is None:
node = ET.SubElement(parent, tag)
node.text = text
def _configure(home: Path, gui_port: int, listen_port: int) -> str:
"""Rewrite a generated config for an isolated loopback instance. Returns
the API key."""
path = home / "config.xml"
tree = ET.parse(path)
root = tree.getroot()
gui = root.find("gui")
_set(gui, "address", f"127.0.0.1:{gui_port}")
api_key = gui.findtext("apikey")
options = root.find("options")
for listen in options.findall("listenAddress"):
options.remove(listen)
ET.SubElement(options, "listenAddress").text = f"tcp://127.0.0.1:{listen_port}"
for tag, value in (("globalAnnounceEnabled", "false"),
("localAnnounceEnabled", "true"),
("relaysEnabled", "false"),
("natEnabled", "false"),
("startBrowser", "false"),
("urAccepted", "-1"),
("crashReportingEnabled", "false"),
("autoUpgradeIntervalH", "0")):
_set(options, tag, value)
tree.write(path)
return api_key
def _wait_up(instance: Instance, timeout: float = 30) -> str:
deadline = time.time() + timeout
while time.time() < deadline:
try:
response = requests.get(
f"http://{instance.address}/rest/system/status",
headers={"X-API-Key": instance.api_key}, timeout=2)
if response.ok:
return response.json()["myID"]
except requests.RequestException:
pass
time.sleep(0.3)
raise RuntimeError(f"{instance.name}'s Syncthing didn't come up")
def start(out: Path, names=NAMES) -> list[Instance]:
if shutil.which("syncthing") is None:
raise RuntimeError("syncthing isn't installed")
out.mkdir(parents=True, exist_ok=True)
instances = []
for i, name in enumerate(names):
home = out / name / "syncthing"
if not (home / "config.xml").exists():
subprocess.run(["syncthing", "generate", f"--home={home}",
"--no-default-folder", "--skip-port-probing"],
check=True, capture_output=True)
gui_port, listen_port = BASE_GUI_PORT + i, BASE_LISTEN_PORT + i
api_key = _configure(home, gui_port, listen_port)
log = open(out / name / "syncthing.log", "ab")
proc = subprocess.Popen(
["syncthing", "serve", f"--home={home}", "--no-browser",
"--no-restart", "--no-upgrade"],
stdout=log, stderr=subprocess.STDOUT, start_new_session=True,
env={**os.environ, "STNOUPGRADE": "1"})
instances.append(Instance(name, str(home), gui_port, listen_port,
api_key, pid=proc.pid))
for instance in instances:
instance.device_id = _wait_up(instance)
(out / "pair.json").write_text(
json.dumps([asdict(i) for i in instances], indent=2))
return instances
def stop(out: Path):
path = out / "pair.json"
if not path.exists():
return
for raw in json.loads(path.read_text()):
try:
os.killpg(raw["pid"], signal.SIGTERM)
except (ProcessLookupError, PermissionError):
pass
path.unlink()
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--stop", action="store_true")
args = parser.parse_args()
if args.stop:
stop(args.out)
return
for instance in start(args.out):
print(f"{instance.name}: GUI http://{instance.address} "
f"key {instance.api_key} id {instance.device_id[:7]}")
print(f"Stop with: {sys.argv[0]} --out {args.out} --stop")
if __name__ == "__main__":
main()
+48
View File
@@ -79,3 +79,51 @@ def jpeg_bytes(tmp_path):
if result.returncode != 0:
pytest.skip("ffmpeg could not generate jpeg fixture")
return path.read_bytes()
# ---- real-Syncthing tests (Cassette) ----
# Two throwaway Syncthing instances on loopback (scripts/cassette_pair.py).
# Slow-ish (a handshake takes Syncthing some seconds), so opt-in:
# python3 -m pytest -m syncthing --syncthing
def pytest_addoption(parser):
parser.addoption("--syncthing", action="store_true",
help="run tests that start two real Syncthing instances")
def pytest_configure(config):
config.addinivalue_line(
"markers", "syncthing: starts two real Syncthing instances "
"(opt in with --syncthing)")
def pytest_collection_modifyitems(config, items):
if config.getoption("--syncthing"):
return
skip = pytest.mark.skip(reason="needs --syncthing")
for item in items:
if "syncthing" in item.keywords:
item.add_marker(skip)
@pytest.fixture
def syncthing_pair(tmp_path):
"""[alice, bob, carol] Instances from scripts/cassette_pair.py, stopped
after."""
if shutil.which("syncthing") is None:
pytest.skip("syncthing isn't installed")
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"cassette_pair",
Path(__file__).resolve().parent.parent / "scripts" / "cassette_pair.py")
pair = importlib.util.module_from_spec(spec)
import sys
sys.modules[spec.name] = pair # dataclasses look their module up there
spec.loader.exec_module(pair)
out = tmp_path / "pair"
instances = pair.start(out, names=("alice", "bob", "carol"))
try:
yield instances
finally:
pair.stop(out)
+433
View File
@@ -0,0 +1,433 @@
"""Round 65: Cassette invites and the handshake.
* An invite code is one pasteable line carrying the inviter's device id,
display name and a one-time token. The token is embedded in both folder ids.
* Syncthing hides an *unknown* device's folders (verified against v1.30: a
stranger shows only as a pending device), so while an invite is open the
inviter **probes** a new knock — adds it with nothing shared — to see which
folder it offers. The right ``-b`` completes the friendship and uses the
invite; anything else is removed again and never probed twice.
* Every friendship and invite has a plain-language status with a next step.
The ``syncthing``-marked test runs the whole thing between three real
Syncthing instances on loopback (``--syncthing`` to opt in).
"""
import time
from pathlib import Path
import pytest
from lintunes.cassette import invite as inv
from lintunes.cassette import publish
from lintunes.cassette import service as svc_mod
from lintunes.cassette import syncthing_api as st
from lintunes.cassette.service import CassetteService, friend_status
from lintunes.cassette.state import (
CANCELLED, INVITEE, INVITER, OPEN, USED, Friend, in_dir, out_dir,
)
DEV_A = "AAAAAAA-BBBBBBB-CCCCCCC-DDDDDDD-EEEEEEE-FFFFFFF-GGGGGGG-HHHHHHH"
DEV_B = "BBBBBBB-BBBBBBB-CCCCCCC-DDDDDDD-EEEEEEE-FFFFFFF-GGGGGGG-HHHHHHH"
DEV_C = "CCCCCCC-BBBBBBB-CCCCCCC-DDDDDDD-EEEEEEE-FFFFFFF-GGGGGGG-HHHHHHH"
# ---- invite codes ----
class TestInviteCode:
def test_round_trip(self):
code = inv.encode(DEV_A, "trav ✨", "0123456789abcdef")
assert code.startswith("LTC1.")
assert "=" not in code
parsed = inv.decode(code)
assert parsed == inv.InviteCode(DEV_A, "trav ✨", "0123456789abcdef")
def test_survives_chat_app_line_breaks(self):
code = inv.encode(DEV_A, "trav", "0123456789abcdef")
mangled = " " + code[:20] + "\n" + code[20:40] + " \r\n" + code[40:] + "\n"
assert inv.decode(mangled).device_id == DEV_A
@pytest.mark.parametrize("text", [
"", "hello", "LTC1.", "LTC1.!!!notbase64", "LTC1.bm90IGpzb24",
])
def test_garbage_is_not_valid(self, text):
with pytest.raises(inv.InviteError) as info:
inv.decode(text)
assert "whole code" in str(info.value)
def test_truncated_code_is_not_valid(self):
code = inv.encode(DEV_A, "trav", "0123456789abcdef")
with pytest.raises(inv.InviteError):
inv.decode(code[:-12])
def test_folder_ids(self):
token = "0123456789abcdef"
assert inv.parse_folder_id(inv.inviter_folder(token)) == (token, "a")
assert inv.parse_folder_id(inv.invitee_folder(token)) == (token, "b")
assert inv.parse_folder_id("music") is None
assert inv.parse_folder_id("lintunes-cassette-xyz-b") is None
# ---- statuses ----
class TestStatus:
def _friend(self, **kw):
return Friend(token="t", device_id=DEV_B, name="Sam", **kw)
def test_waiting_for_computer_names_the_fixes(self):
status = friend_status(self._friend(), connected=False, in_accepted=False,
remote_state="", up_to_date=False, last_seen="")
assert status.code == svc_mod.WAITING_COMPUTER
assert status.text == "Waiting for Sam's computer"
assert "global discovery" in status.hint and "relaying" in status.hint
def test_waiting_for_lintunes(self):
status = friend_status(self._friend(connected_at=""), connected=True,
in_accepted=True, remote_state="notSharing",
up_to_date=False, last_seen="")
assert status.text == "Waiting for Sam to open LinTunes"
def test_connected_and_offline(self):
friend = self._friend(connected_at="2026-09-01T00:00:00",
last_synced="2026-09-02T00:00:00")
up = friend_status(friend, connected=True, in_accepted=True,
remote_state="valid", up_to_date=True, last_seen="x")
assert (up.code, up.up_to_date, up.last_synced) == \
(svc_mod.CONNECTED, True, "2026-09-02T00:00:00")
down = friend_status(friend, connected=False, in_accepted=True,
remote_state="", up_to_date=False, last_seen="x")
assert down.code == svc_mod.OFFLINE
# ---- an in-memory Syncthing ----
class FakeSyncthing:
"""Enough of Syncthing's model for the handshake: devices, folders and
what's pending. ``offer`` plays a remote device knocking."""
def __init__(self, my_id):
self.id = my_id
self.devices = {}
self.folders = {}
self.pending_dev = {}
self.offers = {} # folder id -> {device id: {...}}
self.connected = set()
def version(self): return "v1.30.0"
def my_id(self): return self.id
def device(self, d): return self.devices.get(d)
def add_device(self, d, name, addresses=("dynamic",)):
self.devices[d] = {"deviceID": d, "name": name}
self.pending_dev.pop(d, None)
def remove_device(self, d, name=""):
self.devices.pop(d, None)
def folder(self, f): return self.folders.get(f)
def add_folder(self, f, label, path, kind, device_ids=(), step=""):
self.folders[f] = {"id": f, "type": kind, "path": str(path),
"devices": [{"deviceID": d} for d in device_ids]}
self.offers.pop(f, None)
def share_folder(self, f, d, step=""):
devices = self.folders[f]["devices"]
if not any(x["deviceID"] == d for x in devices):
devices.append({"deviceID": d})
def remove_folder(self, f, step=""):
self.folders.pop(f, None)
def pending_devices(self):
return {d: dict(v) for d, v in self.pending_dev.items()}
def pending_folders(self, device_id=None):
# Syncthing only reports folders from devices it knows.
return {f: {"offeredBy": {d: {} for d in by if d in self.devices}}
for f, by in self.offers.items()
if any(d in self.devices for d in by)}
def connections(self):
return {d: {"connected": d in self.connected} for d in self.devices}
def device_stats(self): return {}
def completion(self, folder, device=None):
return {"completion": 100, "remoteState": "valid"}
def knock(self, device_id, name="stranger-laptop", offers=()):
self.pending_dev[device_id] = {"name": name,
"time": svc_mod.now_iso() + "Z"}
for folder_id in offers:
self.offers.setdefault(folder_id, {})[device_id] = {}
def _service(tmp_path, fake, name="trav"):
service = CassetteService(root=tmp_path / name, config_loader=lambda: {})
service.client = lambda: fake
service._state.display_name = name
return service
class TestHandshakeLogic:
def test_create_invite_makes_a_sendonly_folder_and_intro(self, qapp, tmp_path):
fake = FakeSyncthing(DEV_A)
service = _service(tmp_path, fake)
code = service.create_invite()
parsed = inv.decode(code)
assert parsed.device_id == DEV_A and parsed.name == "trav"
folder = fake.folders[inv.inviter_folder(parsed.token)]
assert folder["type"] == "sendonly" and folder["devices"] == []
intro = publish.read_intro(out_dir(service.root, parsed.token))
assert intro["name"] == "trav"
assert (out_dir(service.root, parsed.token) / ".stignore").exists()
assert service.state().invites[parsed.token].status == OPEN
def test_matching_knock_completes_and_uses_the_invite(self, qapp, tmp_path):
fake = FakeSyncthing(DEV_A)
service = _service(tmp_path, fake)
token = inv.decode(service.create_invite()).token
fake.knock(DEV_B, offers=[inv.invitee_folder(token)])
service.reconcile(fake) # probe: add with nothing shared
assert DEV_B in fake.devices
assert DEV_B in service.state().probing
service.reconcile(fake) # now its folder is visible: complete
state = service.state()
assert state.invites[token].status == USED
friend = state.friends[token]
assert friend.role == INVITER and friend.device_id == DEV_B
assert fake.folders[inv.invitee_folder(token)]["type"] == "receiveonly"
assert {"deviceID": DEV_B} in fake.folders[inv.inviter_folder(token)]["devices"]
assert not state.probing
def test_stranger_is_let_go_and_left_pending(self, qapp, tmp_path):
fake = FakeSyncthing(DEV_A)
service = _service(tmp_path, fake)
service.create_invite()
fake.knock(DEV_C, offers=["some-other-folder"])
service.reconcile(fake)
service.reconcile(fake)
assert DEV_C not in fake.devices
assert DEV_C in service.state().rejected
fake.knock(DEV_C) # Syncthing lists it again…
service.reconcile(fake)
assert DEV_C not in fake.devices # …and it isn't probed again
assert not service.state().friends
def test_used_code_from_a_second_device_is_refused(self, qapp, tmp_path):
fake = FakeSyncthing(DEV_A)
service = _service(tmp_path, fake)
token = inv.decode(service.create_invite()).token
fake.knock(DEV_B, offers=[inv.invitee_folder(token)])
service.reconcile(fake)
service.reconcile(fake)
fake.knock(DEV_C, offers=[inv.invitee_folder(token)])
service.reconcile(fake)
service.reconcile(fake)
assert DEV_C not in fake.devices
assert [f.device_id for f in service.state().friends.values()] == [DEV_B]
def test_no_open_invite_means_no_probing(self, qapp, tmp_path):
fake = FakeSyncthing(DEV_A)
service = _service(tmp_path, fake)
fake.knock(DEV_B)
service.reconcile(fake)
assert DEV_B not in fake.devices
def test_paste_side(self, qapp, tmp_path):
fake = FakeSyncthing(DEV_B)
service = _service(tmp_path, fake, name="sam")
code = inv.encode(DEV_A, "trav", "0123456789abcdef")
friend = service.accept_code(code)
assert friend.role == INVITEE and friend.name == "trav"
assert fake.devices[DEV_A]["name"] == "trav"
out = fake.folders[inv.invitee_folder("0123456789abcdef")]
assert out["type"] == "sendonly" and out["devices"] == [{"deviceID": DEV_A}]
with pytest.raises(inv.InviteError) as info:
service.accept_code(code)
assert str(info.value) == svc_mod.ALREADY_USED
# The inviter shares -a back: accepted receive-only.
fake.offers[inv.inviter_folder("0123456789abcdef")] = {DEV_A: {}}
service.reconcile(fake)
assert fake.folders[inv.inviter_folder("0123456789abcdef")]["type"] \
== "receiveonly"
def test_own_code_is_refused(self, qapp, tmp_path):
fake = FakeSyncthing(DEV_A)
service = _service(tmp_path, fake)
code = service.create_invite()
with pytest.raises(inv.InviteError) as info:
service.accept_code(code)
assert str(info.value) == svc_mod.OWN_CODE
def test_cancel_and_remove(self, qapp, tmp_path):
fake = FakeSyncthing(DEV_A)
service = _service(tmp_path, fake)
token = inv.decode(service.create_invite()).token
service.cancel_invite(token)
assert service.state().invites[token].status == CANCELLED
assert inv.inviter_folder(token) not in fake.folders
token = inv.decode(service.create_invite()).token
fake.knock(DEV_B, offers=[inv.invitee_folder(token)])
service.reconcile(fake)
service.reconcile(fake)
(in_dir(service.root, token) / "x").write_text("x")
service.remove_friend(token)
assert not service.state().friends
assert DEV_B not in fake.devices
assert not fake.folders.get(inv.invitee_folder(token))
assert not in_dir(service.root, token).exists()
def test_connected_notice_once(self, qapp, tmp_path):
fake = FakeSyncthing(DEV_A)
service = _service(tmp_path, fake)
notices = []
service.notice.connect(notices.append)
token = inv.decode(service.create_invite()).token
fake.knock(DEV_B, offers=[inv.invitee_folder(token)])
service.reconcile(fake)
service.reconcile(fake)
fake.connected.add(DEV_B)
# Their intro arrives: the friend's display name replaces the hostname.
publish.publish_intro(in_dir(service.root, token), "Sam", None)
service.reconcile(fake)
service.reconcile(fake)
assert notices == ["Sam is now connected."]
assert service.status(token).code == svc_mod.CONNECTED
# ---- the real thing ----
def _wait(condition, services, timeout=120):
deadline = time.time() + timeout
while time.time() < deadline:
for service, client in services:
service.reconcile(client)
if condition():
return True
time.sleep(1)
return False
@pytest.mark.syncthing
def test_real_handshake(qapp, tmp_path, syncthing_pair):
alice, bob, carol = syncthing_pair
listen = {i.device_id: [i.listen_address] for i in syncthing_pair}
def make(instance):
config = {st.API_KEY_KEY: instance.api_key, st.ADDRESS_KEY: instance.address}
service = CassetteService(root=tmp_path / instance.name,
config_loader=lambda: config,
addresses_for=lambda d: listen[d])
service._state.display_name = instance.name.title()
return service, service.client()
a, a_client = make(alice)
b, b_client = make(bob)
c, c_client = make(carol)
code = a.create_invite()
b.accept_code(code)
token = inv.decode(code).token
def both_connected():
sa, sb = a.status(token), b.status(token)
return (sa and sb and sa.code == svc_mod.CONNECTED
and sb.code == svc_mod.CONNECTED)
assert _wait(both_connected, [(a, a_client), (b, b_client)]), \
(a.status(token), b.status(token))
assert _wait(lambda: a.state().friends[token].name == "Bob"
and b.state().friends[token].name == "Alice",
[(a, a_client), (b, b_client)], timeout=60)
assert a.state().invites[token].status == USED
# Carol got hold of the same code. Alice has no open invite left, so
# Carol's knock stays pending in Alice's Syncthing, untouched, and Carol
# is told it may have been used.
c.accept_code(code)
assert _wait(lambda: carol.device_id in a_client.pending_devices(),
[(a, a_client), (c, c_client)], timeout=90)
for _ in range(3):
a.reconcile(a_client)
assert a_client.device(carol.device_id) is None
assert len(a.state().friends) == 1
carol_status = c.status(token)
assert carol_status.code == svc_mod.WAITING_COMPUTER
assert "already have been used" in carol_status.hint
# ---- the dialogs ----
class TestDialogs:
def test_failure_text_names_step_and_next_move(self):
from lintunes.gui.cassette_dialogs import failure_text
down = failure_text(st.SyncthingError("Adding Sam's computer", "x",
st.NOT_RUNNING))
assert down.startswith("Adding Sam's computer failed") and "Start" in down
said = failure_text(st.SyncthingError("Sharing", "invalid device ID"))
assert said == "Sharing failed. Syncthing said: invalid device ID"
assert failure_text(inv.InviteError(inv.NOT_VALID)) == inv.NOT_VALID
def test_add_friend_shows_bad_code_inline(self, qapp, tmp_path):
from lintunes.gui.cassette_dialogs import AddFriendDialog
service = _service(tmp_path, FakeSyncthing(DEV_B))
dialog = AddFriendDialog(service)
dialog.field.setPlainText("hello there")
dialog._connect()
assert "whole code" in dialog.message.text()
assert dialog.friend is None
def test_add_friend_connects(self, qapp, tmp_path):
from lintunes.gui.cassette_dialogs import AddFriendDialog
service = _service(tmp_path, FakeSyncthing(DEV_B))
dialog = AddFriendDialog(service)
dialog.field.setPlainText(inv.encode(DEV_A, "trav", "0123456789abcdef"))
dialog._connect()
assert dialog.friend.name == "trav"
def test_share_dialog_copies(self, qapp):
from PyQt6.QtGui import QGuiApplication
from lintunes.gui.cassette_dialogs import ShareInviteDialog
dialog = ShareInviteDialog("LTC1.abc")
dialog._copy()
assert QGuiApplication.clipboard().text() == "LTC1.abc"
def test_settings_stage_cancel_and_remove_until_ok(self, qapp, tmp_path,
monkeypatch):
from PyQt6.QtWidgets import QMessageBox
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
from tests.test_round64 import _NoProbe
fake = FakeSyncthing(DEV_A)
service = _service(tmp_path, fake)
kept = inv.decode(service.create_invite()).token
friend_token = inv.decode(service.create_invite()).token
fake.knock(DEV_B, offers=[inv.invitee_folder(friend_token)])
service.reconcile(fake)
service.reconcile(fake)
dialog = SyncSettingsDialog(service.state(), service.root, {},
probe=_NoProbe(), service=service)
dialog.general._cancel(kept, service)
monkeypatch.setattr(QMessageBox, "question",
lambda *a, **k: QMessageBox.StandardButton.Yes)
dialog.friend_pages[friend_token]._remove()
# Nothing has happened yet…
assert service.state().invites[kept].status == OPEN
assert friend_token in service.state().friends
# …until OK.
service.apply_settings(dialog.state, dialog.cancelled_invites,
dialog.removed_friends)
assert service.state().invites[kept].status == CANCELLED
assert friend_token not in service.state().friends
def test_format_when(self):
from lintunes.gui.sync_settings_dialog import format_when
assert format_when("") == "never"
assert format_when("0001-01-01T00:00:00Z") == "never"
assert format_when(svc_mod.now_iso()).startswith("today ")
assert "2020" in format_when("2020-03-04T05:06:07Z")