From f04e69f9b2cb97540513a49e085ceedb2bf022f9 Mon Sep 17 00:00:00 2001 From: trav Date: Mon, 28 Sep 2026 21:13:07 -0700 Subject: [PATCH] =?UTF-8?q?v0.27.0:=20Cassette=20invites=20=E2=80=94=20one?= =?UTF-8?q?=20paste=20connects=20two=20friends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 26 ++ lintunes/__init__.py | 2 +- lintunes/cassette/invite.py | 94 +++++ lintunes/cassette/publish.py | 78 ++++ lintunes/cassette/service.py | 554 +++++++++++++++++++++++++++ lintunes/cassette/state.py | 17 +- lintunes/cassette/syncthing_api.py | 4 +- lintunes/gui/cassette_dialogs.py | 106 +++++ lintunes/gui/cassette_ui.py | 94 ++++- lintunes/gui/main_window.py | 9 +- lintunes/gui/sync_settings_dialog.py | 187 ++++++++- lintunes/main.py | 2 + scripts/cassette_pair.py | 162 ++++++++ tests/conftest.py | 48 +++ tests/test_round65.py | 433 +++++++++++++++++++++ 15 files changed, 1783 insertions(+), 33 deletions(-) create mode 100644 lintunes/cassette/invite.py create mode 100644 lintunes/cassette/publish.py create mode 100644 lintunes/cassette/service.py create mode 100644 lintunes/gui/cassette_dialogs.py create mode 100644 scripts/cassette_pair.py create mode 100644 tests/test_round65.py diff --git a/CLAUDE.md b/CLAUDE.md index ab120e4..6c66810 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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// + {out,in,cache}`), never the synced data dir — Syncthing folders must not + nest. A friendship is two folders, `lintunes-cassette--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* diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 6136a1c..fd9949a 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.26.0" +__version__ = "0.27.0" diff --git a/lintunes/cassette/invite.py b/lintunes/cassette/invite.py new file mode 100644 index 0000000..e6c16f7 --- /dev/null +++ b/lintunes/cassette/invite.py @@ -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--a`` — the inviter's folder (inviter → invitee) +* ``lintunes-cassette--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) diff --git a/lintunes/cassette/publish.py b/lintunes/cassette/publish.py new file mode 100644 index 0000000..9f195ba --- /dev/null +++ b/lintunes/cassette/publish.py @@ -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 {} diff --git a/lintunes/cassette/service.py b/lintunes/cassette/service.py new file mode 100644 index 0000000..de6a3b2 --- /dev/null +++ b/lintunes/cassette/service.py @@ -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) # " 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 diff --git a/lintunes/cassette/state.py b/lintunes/cassette/state.py index e0f9b79..23adb68 100644 --- a/lintunes/cassette/state.py +++ b/lintunes/cassette/state.py @@ -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 diff --git a/lintunes/cassette/syncthing_api.py b/lintunes/cassette/syncthing_api.py index 0c56022..d8e7ea5 100644 --- a/lintunes/cassette/syncthing_api.py +++ b/lintunes/cassette/syncthing_api.py @@ -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}", diff --git a/lintunes/gui/cassette_dialogs.py b/lintunes/gui/cassette_dialogs.py new file mode 100644 index 0000000..f1f08a9 --- /dev/null +++ b/lintunes/gui/cassette_dialogs.py @@ -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() diff --git a/lintunes/gui/cassette_ui.py b/lintunes/gui/cassette_ui.py index 934d9ff..387d56b 100644 --- a/lintunes/gui/cassette_ui.py +++ b/lintunes/gui/cassette_ui.py @@ -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() diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 4e38064..58d8fcd 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -95,7 +95,9 @@ class MainWindow(QMainWindow): # Context playback started from: "library" or "playlist:". 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() diff --git a/lintunes/gui/sync_settings_dialog.py b/lintunes/gui/sync_settings_dialog.py index cfded5e..ab0ccc6 100644 --- a/lintunes/gui/sync_settings_dialog.py +++ b/lintunes/gui/sync_settings_dialog.py @@ -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() diff --git a/lintunes/main.py b/lintunes/main.py index e04966a..6a71ab2 100644 --- a/lintunes/main.py +++ b/lintunes/main.py @@ -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) diff --git a/scripts/cassette_pair.py b/scripts/cassette_pair.py new file mode 100644 index 0000000..d8b0231 --- /dev/null +++ b/scripts/cassette_pair.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index d09650e..cd03915 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_round65.py b/tests/test_round65.py new file mode 100644 index 0000000..6c3d9d8 --- /dev/null +++ b/tests/test_round65.py @@ -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")