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:
@@ -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
|
registered in `setup.py` via `package_data`; loaded with
|
||||||
`importlib.resources` so an installed copy finds them.
|
`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
|
- **`lintunes/cast/`** — Chromecast playback (Connections menu), using the
|
||||||
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
|
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
|
||||||
ephemeral port for the life of a session and the device fetches the *original*
|
ephemeral port for the life of a session and the device fetches the *original*
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.26.0"
|
__version__ = "0.27.0"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 {}
|
||||||
@@ -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
|
||||||
@@ -122,6 +122,7 @@ class Friend:
|
|||||||
in_folder: str = "" # Syncthing folder id they write (receive-only here)
|
in_folder: str = "" # Syncthing folder id they write (receive-only here)
|
||||||
added: str = ""
|
added: str = ""
|
||||||
connected_at: str = "" # first time the handshake completed
|
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)
|
selection: Selection = field(default_factory=Selection)
|
||||||
layout: str = LAYOUT_FOLDER
|
layout: str = LAYOUT_FOLDER
|
||||||
followed_mode: str = FOLLOW_SEPARATE
|
followed_mode: str = FOLLOW_SEPARATE
|
||||||
@@ -133,6 +134,7 @@ class Friend:
|
|||||||
"name": self.name, "role": self.role,
|
"name": self.name, "role": self.role,
|
||||||
"out_folder": self.out_folder, "in_folder": self.in_folder,
|
"out_folder": self.out_folder, "in_folder": self.in_folder,
|
||||||
"added": self.added, "connected_at": self.connected_at,
|
"added": self.added, "connected_at": self.connected_at,
|
||||||
|
"last_synced": self.last_synced,
|
||||||
"selection": self.selection.to_dict(), "layout": self.layout,
|
"selection": self.selection.to_dict(), "layout": self.layout,
|
||||||
"followed_mode": self.followed_mode,
|
"followed_mode": self.followed_mode,
|
||||||
"followed": list(self.followed),
|
"followed": list(self.followed),
|
||||||
@@ -146,6 +148,7 @@ class Friend:
|
|||||||
in_folder=d.get("in_folder", ""),
|
in_folder=d.get("in_folder", ""),
|
||||||
added=d.get("added", ""),
|
added=d.get("added", ""),
|
||||||
connected_at=d.get("connected_at", ""),
|
connected_at=d.get("connected_at", ""),
|
||||||
|
last_synced=d.get("last_synced", ""),
|
||||||
selection=Selection.from_dict(d.get("selection")),
|
selection=Selection.from_dict(d.get("selection")),
|
||||||
layout=d.get("layout", LAYOUT_FOLDER),
|
layout=d.get("layout", LAYOUT_FOLDER),
|
||||||
followed_mode=d.get("followed_mode", FOLLOW_SEPARATE),
|
followed_mode=d.get("followed_mode", FOLLOW_SEPARATE),
|
||||||
@@ -161,6 +164,13 @@ class CassetteState:
|
|||||||
general: Selection = field(default_factory=Selection)
|
general: Selection = field(default_factory=Selection)
|
||||||
invites: dict = field(default_factory=dict) # token -> Invite
|
invites: dict = field(default_factory=dict) # token -> Invite
|
||||||
friends: dict = field(default_factory=dict) # token -> Friend
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {"version": FORMAT_VERSION,
|
return {"version": FORMAT_VERSION,
|
||||||
@@ -168,7 +178,8 @@ class CassetteState:
|
|||||||
"share_same_for_all": self.share_same_for_all,
|
"share_same_for_all": self.share_same_for_all,
|
||||||
"general": self.general.to_dict(),
|
"general": self.general.to_dict(),
|
||||||
"invites": [i.to_dict() for i in self.invites.values()],
|
"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
|
@classmethod
|
||||||
def from_dict(cls, d: dict | None) -> "CassetteState":
|
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(),
|
state = cls(display_name=d.get("display_name") or default_display_name(),
|
||||||
avatar=d.get("avatar", ""),
|
avatar=d.get("avatar", ""),
|
||||||
share_same_for_all=bool(d.get("share_same_for_all", False)),
|
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", []):
|
for raw in d.get("invites", []):
|
||||||
invite = Invite.from_dict(raw)
|
invite = Invite.from_dict(raw)
|
||||||
state.invites[invite.token] = invite
|
state.invites[invite.token] = invite
|
||||||
|
|||||||
@@ -180,12 +180,12 @@ class SyncthingClient:
|
|||||||
return None
|
return None
|
||||||
raise
|
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."""
|
"""Add (or rename) a device. Idempotent."""
|
||||||
self._request("PUT", f"/rest/config/devices/{device_id}",
|
self._request("PUT", f"/rest/config/devices/{device_id}",
|
||||||
f"Adding {name}'s computer to Syncthing",
|
f"Adding {name}'s computer to Syncthing",
|
||||||
json={"deviceID": device_id, "name": name,
|
json={"deviceID": device_id, "name": name,
|
||||||
"addresses": ["dynamic"]})
|
"addresses": list(addresses)})
|
||||||
|
|
||||||
def remove_device(self, device_id: str, name: str = "the device"):
|
def remove_device(self, device_id: str, name: str = "the device"):
|
||||||
self._request("DELETE", f"/rest/config/devices/{device_id}",
|
self._request("DELETE", f"/rest/config/devices/{device_id}",
|
||||||
|
|||||||
@@ -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
@@ -1,8 +1,9 @@
|
|||||||
"""Cassette's place in the main window: its Connections-menu group, the
|
"""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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,17 +12,52 @@ from PyQt6.QtGui import QAction
|
|||||||
from PyQt6.QtWidgets import QDialog, QMessageBox
|
from PyQt6.QtWidgets import QDialog, QMessageBox
|
||||||
|
|
||||||
from lintunes.cassette import host
|
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
|
from lintunes.config import load_config, save_config
|
||||||
|
|
||||||
|
NOTICE_MS = 10000
|
||||||
|
|
||||||
|
|
||||||
class CassetteUi(QObject):
|
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)
|
super().__init__(parent or window)
|
||||||
self._window = window
|
self._window = window
|
||||||
self._prefs = prefs
|
self._prefs = prefs
|
||||||
self._elsewhere_action: QAction | None = None
|
self._elsewhere_action: QAction | None = None
|
||||||
self._actions: list[QAction] = []
|
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 ----
|
# ---- menu ----
|
||||||
|
|
||||||
@@ -32,10 +68,13 @@ class CassetteUi(QObject):
|
|||||||
self._elsewhere_action.setEnabled(False)
|
self._elsewhere_action.setEnabled(False)
|
||||||
self._elsewhere_action.setVisible(False)
|
self._elsewhere_action.setVisible(False)
|
||||||
menu.addAction(self._elsewhere_action)
|
menu.addAction(self._elsewhere_action)
|
||||||
settings = QAction("Sync Settings…", self._window)
|
for text, slot in (("Share Library with a Friend…", self.share_library),
|
||||||
settings.triggered.connect(self.show_sync_settings)
|
("Add Friend Library…", self.add_friend),
|
||||||
menu.addAction(settings)
|
("Sync Settings…", self.show_sync_settings)):
|
||||||
self._actions.append(settings)
|
action = QAction(text, self._window)
|
||||||
|
action.triggered.connect(slot)
|
||||||
|
menu.addAction(action)
|
||||||
|
self._actions.append(action)
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
def refresh_menu(self):
|
def refresh_menu(self):
|
||||||
@@ -54,6 +93,7 @@ class CassetteUi(QObject):
|
|||||||
no machine has yet."""
|
no machine has yet."""
|
||||||
status, name = host.host_status(self._prefs)
|
status, name = host.host_status(self._prefs)
|
||||||
if status == host.HERE:
|
if status == host.HERE:
|
||||||
|
self._ensure_service()
|
||||||
return True
|
return True
|
||||||
if status == host.ELSEWHERE:
|
if status == host.ELSEWHERE:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
@@ -72,17 +112,43 @@ class CassetteUi(QObject):
|
|||||||
if answer != QMessageBox.StandardButton.Yes:
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
return False
|
return False
|
||||||
host.claim(self._prefs)
|
host.claim(self._prefs)
|
||||||
|
self._ensure_service()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# ---- dialogs ----
|
# ---- 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):
|
def show_sync_settings(self):
|
||||||
if not self.ensure_host():
|
if not self.ensure_host():
|
||||||
return
|
return
|
||||||
|
from lintunes.gui.cassette_dialogs import failure_text
|
||||||
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
|
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
|
||||||
root = cassette_root()
|
service = self.service
|
||||||
config = load_config()
|
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:
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
dialog.apply()
|
dialog.apply()
|
||||||
@@ -91,4 +157,10 @@ class CassetteUi(QObject):
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
config.update(changes)
|
config.update(changes)
|
||||||
save_config(config)
|
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()
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ class MainWindow(QMainWindow):
|
|||||||
# Context playback started from: "library" or "playlist:<pid>". Scopes
|
# Context playback started from: "library" or "playlist:<pid>". Scopes
|
||||||
# the now-playing speaker icon to where the song is actually playing.
|
# the now-playing speaker icon to where the song is actually playing.
|
||||||
self._now_playing_context = ""
|
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._cassette = CassetteUi(self, prefs)
|
||||||
|
|
||||||
self.setWindowTitle("LinTunes")
|
self.setWindowTitle("LinTunes")
|
||||||
@@ -386,6 +388,10 @@ class MainWindow(QMainWindow):
|
|||||||
# always reflects plug/unplug, the cast session and the current view.
|
# always reflects plug/unplug, the cast session and the current view.
|
||||||
connections_menu.aboutToShow.connect(self._refresh_connection_actions)
|
connections_menu.aboutToShow.connect(self._refresh_connection_actions)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cassette(self) -> CassetteUi:
|
||||||
|
return self._cassette
|
||||||
|
|
||||||
def _add_action(self, menu, text, shortcut, slot):
|
def _add_action(self, menu, text, shortcut, slot):
|
||||||
action = QAction(text, self)
|
action = QAction(text, self)
|
||||||
if shortcut:
|
if shortcut:
|
||||||
@@ -1707,6 +1713,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._sync_inhibitor.release()
|
self._sync_inhibitor.release()
|
||||||
# Before the player: this stops the device and kills the HTTP server it
|
# Before the player: this stops the device and kills the HTTP server it
|
||||||
# is fetching from. Skipped, the Chromecast sits buffering a dead URL.
|
# is fetching from. Skipped, the Chromecast sits buffering a dead URL.
|
||||||
|
self._cassette.shutdown()
|
||||||
self._cast.shutdown()
|
self._cast.shutdown()
|
||||||
self.player.shutdown()
|
self.player.shutdown()
|
||||||
self._manager.flush()
|
self._manager.flush()
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ None" can wipe a careful selection, and Cancel has to be able to undo that.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
from PyQt6.QtGui import QImage, QPixmap
|
from PyQt6.QtGui import QImage, QPixmap
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
|
QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
|
||||||
QHBoxLayout, QLabel, QLineEdit, QListWidget, QPushButton, QSplitter,
|
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton,
|
||||||
QStackedWidget, QVBoxLayout, QWidget,
|
QSplitter, QStackedWidget, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from lintunes.cassette import probe as probe_mod
|
from lintunes.cassette import probe as probe_mod
|
||||||
|
from lintunes.cassette import publish
|
||||||
from lintunes.cassette import syncthing_api as st
|
from lintunes.cassette import syncthing_api as st
|
||||||
|
from lintunes.cassette.state import OPEN, in_dir
|
||||||
|
|
||||||
AVATAR_PX = 256
|
AVATAR_PX = 256
|
||||||
AVATAR_PREVIEW_PX = 72
|
AVATAR_PREVIEW_PX = 72
|
||||||
@@ -39,6 +42,29 @@ def load_avatar_image(path: Path) -> QImage | None:
|
|||||||
Qt.TransformationMode.SmoothTransformation)
|
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):
|
class AvatarSquare(QLabel):
|
||||||
"""Fixed square preview; gray with a hint when empty."""
|
"""Fixed square preview; gray with a hint when empty."""
|
||||||
|
|
||||||
@@ -119,6 +145,12 @@ class GeneralPage(QWidget):
|
|||||||
form.addWidget(self._retry, alignment=Qt.AlignmentFlag.AlignLeft)
|
form.addWidget(self._retry, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||||
layout.addWidget(box)
|
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 = QGroupBox("You")
|
||||||
you_form = QFormLayout(you)
|
you_form = QFormLayout(you)
|
||||||
self.display_name = QLineEdit(state.display_name)
|
self.display_name = QLineEdit(state.display_name)
|
||||||
@@ -146,6 +178,38 @@ class GeneralPage(QWidget):
|
|||||||
|
|
||||||
self.show_probe(None)
|
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):
|
def show_probe(self, result):
|
||||||
headline, hint = status_text(result)
|
headline, hint = status_text(result)
|
||||||
self._status.setText(headline)
|
self._status.setText(headline)
|
||||||
@@ -187,6 +251,84 @@ class GeneralPage(QWidget):
|
|||||||
self._avatar.set_image(None)
|
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):
|
class SyncSettingsDialog(QDialog):
|
||||||
"""Edits ``state`` (a copy — the caller's is untouched until accepted).
|
"""Edits ``state`` (a copy — the caller's is untouched until accepted).
|
||||||
|
|
||||||
@@ -195,8 +337,9 @@ class SyncSettingsDialog(QDialog):
|
|||||||
the change affects."""
|
the change affects."""
|
||||||
|
|
||||||
def __init__(self, state, root: Path, config: dict, parent=None,
|
def __init__(self, state, root: Path, config: dict, parent=None,
|
||||||
probe=None):
|
probe=None, service=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self._service = service
|
||||||
self.setWindowTitle("Sync Settings")
|
self.setWindowTitle("Sync Settings")
|
||||||
self.resize(820, 560)
|
self.resize(820, 560)
|
||||||
self.state = state.copy()
|
self.state = state.copy()
|
||||||
@@ -216,7 +359,7 @@ class SyncSettingsDialog(QDialog):
|
|||||||
self.friend_pages = {}
|
self.friend_pages = {}
|
||||||
for friend in sorted(self.state.friends.values(),
|
for friend in sorted(self.state.friends.values(),
|
||||||
key=lambda f: f.name.casefold()):
|
key=lambda f: f.name.casefold()):
|
||||||
page = self._friend_page(friend)
|
page = FriendPage(friend, root)
|
||||||
self.friend_pages[friend.token] = page
|
self.friend_pages[friend.token] = page
|
||||||
self._add_page(friend.name, page)
|
self._add_page(friend.name, page)
|
||||||
self.pages_list.currentRowChanged.connect(self.pages.setCurrentIndex)
|
self.pages_list.currentRowChanged.connect(self.pages.setCurrentIndex)
|
||||||
@@ -235,23 +378,35 @@ class SyncSettingsDialog(QDialog):
|
|||||||
self._probe.finished.connect(self.general.show_probe)
|
self._probe.finished.connect(self.general.show_probe)
|
||||||
self.general.retry_requested.connect(self._retry)
|
self.general.retry_requested.connect(self._retry)
|
||||||
self._probe.start(self._probe_config())
|
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):
|
def _add_page(self, label: str, page: QWidget):
|
||||||
self.pages_list.addItem(label)
|
self.pages_list.addItem(label)
|
||||||
self.pages.addWidget(page)
|
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:
|
def _probe_config(self) -> dict:
|
||||||
config = dict(self._config)
|
config = dict(self._config)
|
||||||
key = self.general.api_key.text().strip()
|
key = self.general.api_key.text().strip()
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ def run_gui(data_dir: Path, files: list[Path],
|
|||||||
watcher.changed.connect(manager.check_for_external_changes)
|
watcher.changed.connect(manager.check_for_external_changes)
|
||||||
|
|
||||||
window.show()
|
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
|
# 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.
|
# never met by a dialog floating in front of nothing.
|
||||||
QTimer.singleShot(0, window.check_music_folder)
|
QTimer.singleShot(0, window.check_music_folder)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -79,3 +79,51 @@ def jpeg_bytes(tmp_path):
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
pytest.skip("ffmpeg could not generate jpeg fixture")
|
pytest.skip("ffmpeg could not generate jpeg fixture")
|
||||||
return path.read_bytes()
|
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)
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user