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.
This commit is contained in:
2026-09-28 21:13:07 -07:00
parent db9e5eab79
commit 5520eb333e
15 changed files with 1783 additions and 33 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.26.0"
__version__ = "0.27.0"
+94
View File
@@ -0,0 +1,94 @@
"""Invite codes and the folder ids that carry their token.
An invite code is one line a person pastes into a chat:
LTC1.eyJkIjoiQUJDREVGRy0uLi4iLCJuIjoidHJhdiIsInQiOiIzZjE…
``LTC1.`` + base64url (unpadded) of ``{"d": device id, "n": display name,
"t": token}``. The token is a random one-time secret; it ends up in the ids of
the two Syncthing folders the friendship is made of:
* ``lintunes-cassette-<token>-a`` — the inviter's folder (inviter → invitee)
* ``lintunes-cassette-<token>-b`` — the invitee's folder (invitee → inviter)
The inviter's LinTunes accepts a newcomer only when it offers ``-b`` for an
invite still open on this machine, which is what makes the code single-use.
"""
from __future__ import annotations
import base64
import binascii
import json
import re
import secrets
from dataclasses import dataclass
PREFIX = "LTC1."
FOLDER_PREFIX = "lintunes-cassette-"
# Syncthing device id: 8 groups of 7 base32 characters.
_DEVICE_RE = re.compile(r"^[A-Z2-7]{7}(-[A-Z2-7]{7}){7}$")
_TOKEN_RE = re.compile(r"^[0-9a-f]{16}$")
_FOLDER_RE = re.compile(r"^lintunes-cassette-([0-9a-f]{16})-([ab])$")
NOT_VALID = ("That code isn't valid. Check that the whole code was copied — it "
"starts with LTC1. and is one long line.")
class InviteError(ValueError):
"""Shown to the user as is."""
@dataclass(frozen=True)
class InviteCode:
device_id: str
name: str
token: str
def new_token() -> str:
return secrets.token_hex(8)
def inviter_folder(token: str) -> str:
return f"{FOLDER_PREFIX}{token}-a"
def invitee_folder(token: str) -> str:
return f"{FOLDER_PREFIX}{token}-b"
def parse_folder_id(folder_id: str) -> tuple[str, str] | None:
"""(token, "a"|"b") for one of our folder ids, else None."""
match = _FOLDER_RE.match(folder_id or "")
return (match.group(1), match.group(2)) if match else None
def encode(device_id: str, name: str, token: str) -> str:
payload = json.dumps({"d": device_id, "n": name, "t": token},
separators=(",", ":"), ensure_ascii=False)
body = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii")
return PREFIX + body.rstrip("=")
def decode(text: str) -> InviteCode:
"""Parse a pasted code. Forgiving about whitespace and line breaks a chat
app may have added; strict about everything else."""
compact = "".join((text or "").split())
start = compact.find(PREFIX)
if start < 0:
raise InviteError(NOT_VALID)
body = compact[start + len(PREFIX):]
try:
raw = base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))
data = json.loads(raw.decode("utf-8"))
except (binascii.Error, ValueError, UnicodeDecodeError):
raise InviteError(NOT_VALID) from None
if not isinstance(data, dict):
raise InviteError(NOT_VALID)
device_id = str(data.get("d", "")).upper()
token = str(data.get("t", "")).lower()
name = str(data.get("n", "")).strip()[:80] or "Your friend"
if not _DEVICE_RE.match(device_id) or not _TOKEN_RE.match(token):
raise InviteError(NOT_VALID)
return InviteCode(device_id=device_id, name=name, token=token)
+78
View File
@@ -0,0 +1,78 @@
"""What goes into my outgoing folder for a friend — the files they read.
``out/`` is a Syncthing send-only folder, so everything written here reaches
the friend. Writes are atomic (temp name, then rename) and **skipped when the
bytes wouldn't change**: an unchanged rewrite still moves the mtime, and
Syncthing would dutifully re-announce the file.
This round writes only the introduction — ``cassette.json`` (display name,
format version, avatar name) and ``avatar.png``. The trimmed library and
requests file join it later.
"""
from __future__ import annotations
import json
from pathlib import Path
FORMAT_VERSION = 1
INFO_FILE = "cassette.json"
AVATAR_FILE = "avatar.png"
STIGNORE = ".stignore"
# Our own temp files never need to reach the friend. (Syncthing ignores its
# own .syncthing.*.tmp already.)
STIGNORE_TEXT = "// written by LinTunes\n*.tmp\n*.part\n"
def write_bytes_if_changed(path: Path, data: bytes) -> bool:
"""Atomically write ``data`` unless ``path`` already holds exactly it.
Returns True when something was written."""
try:
if path.read_bytes() == data:
return False
except OSError:
pass
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_bytes(data)
tmp.replace(path)
return True
def write_json_if_changed(path: Path, data) -> bool:
text = json.dumps(data, indent=1, ensure_ascii=False, sort_keys=True)
return write_bytes_if_changed(path, text.encode("utf-8"))
def read_json(path: Path, default=None):
"""A friend's file, tolerating absence and a half-synced/garbled copy."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return default
def prepare_out_dir(out: Path):
out.mkdir(parents=True, exist_ok=True)
write_bytes_if_changed(out / STIGNORE, STIGNORE_TEXT.encode())
def publish_intro(out: Path, display_name: str, avatar_source: Path | None):
"""cassette.json + avatar.png. ``avatar_source`` None removes the avatar."""
prepare_out_dir(out)
has_avatar = avatar_source is not None and avatar_source.exists()
if has_avatar:
write_bytes_if_changed(out / AVATAR_FILE, avatar_source.read_bytes())
else:
(out / AVATAR_FILE).unlink(missing_ok=True)
write_json_if_changed(out / INFO_FILE, {
"format": FORMAT_VERSION,
"name": display_name,
"avatar": AVATAR_FILE if has_avatar else "",
})
def read_intro(in_dir: Path) -> dict:
"""A friend's cassette.json, or {}."""
data = read_json(in_dir / INFO_FILE, {})
return data if isinstance(data, dict) else {}
+554
View File
@@ -0,0 +1,554 @@
"""The Cassette handshake: invites, knocks, and what each friendship's status is.
``CassetteService`` runs only on the host machine. It owns the Cassette state
(``state.py``) and keeps it true to what Syncthing can actually see, on a
background thread that waits on Syncthing's event stream and reconciles after
each batch. User actions (create an invite, paste a code, remove a friend) run
on the caller's thread and raise ``SyncthingError`` / ``InviteError`` with the
step that failed, so a dialog can show it.
**The knock.** Syncthing hides an *unknown* device's folders: a stranger
connecting shows up only as a pending device, with its hostname. So while an
invite is open, a newly pending device is **probed** — added with nothing
shared, which grants it nothing but lets it tell us which folder it offers.
If that is ``-b`` for an open invite, the friendship completes (share ``-a``
with them, accept ``-b`` receive-only, invite used). If it offers anything
else, or connects and offers nothing, it is removed again and remembered as
rejected; Syncthing goes back to listing it as pending, untouched.
Everything is safe to repeat: every step checks what exists before creating
it, and reconcile rebuilds its picture from Syncthing each time.
"""
from __future__ import annotations
import logging
import shutil
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes.config import load_config
from lintunes.cassette import invite as inv
from lintunes.cassette import publish
from lintunes.cassette import syncthing_api as st
from lintunes.cassette.state import (
CANCELLED, INVITEE, INVITER, OPEN, USED, CassetteState, Friend, Invite,
cassette_root, friend_dir, in_dir, load_state, now_iso, out_dir,
save_state,
)
log = logging.getLogger(__name__)
ALREADY_USED = ("This invite has already been used. Ask your friend for a new "
"one.")
OWN_CODE = "That's your own invite code. Send it to a friend to paste."
# A probed device that connects but offers no invite folder within this long
# is let go. One that never connects is let go after the longer window, and
# may be probed again after RETRY_PROBE (it may simply have been offline).
PROBE_CONNECTED_TIMEOUT = timedelta(minutes=2)
PROBE_TIMEOUT = timedelta(minutes=15)
RETRY_PROBE = timedelta(hours=1)
EVENT_TYPES = ("PendingDevicesChanged", "PendingFoldersChanged",
"DeviceConnected", "DeviceDisconnected", "FolderCompletion",
"StateChanged", "ConfigSaved")
EVENT_TIMEOUT_S = 60
DOWN_RETRY_S = 15
# Status.code
WAITING_PASTE = "waiting_paste"
WAITING_COMPUTER = "waiting_computer"
WAITING_LINTUNES = "waiting_lintunes"
CONNECTED = "connected"
OFFLINE = "offline"
USED_CODE = "used"
CANCELLED_CODE = "cancelled"
SYNCTHING_DOWN = "syncthing_down"
NO_API = "no_api"
@dataclass
class Status:
code: str
text: str
hint: str = ""
last_seen: str = ""
last_synced: str = ""
up_to_date: bool = False
def _parse(when: str) -> datetime | None:
"""An ISO stamp as naive UTC (ours are naive UTC; Syncthing's end in Z or
carry an offset)."""
if not when:
return None
try:
parsed = datetime.fromisoformat(when.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _utc_now() -> datetime:
return datetime.fromisoformat(now_iso())
def friend_status(friend: Friend, *, connected: bool, in_accepted: bool,
remote_state: str, up_to_date: bool, last_seen: str) -> Status:
"""Plain-language status for one friendship. Pure, so it's testable."""
name = friend.name or "your friend"
computer_hint = (
f"Both computers need to be on with Syncthing running. If this stays "
f"like this, check that global discovery and relaying are enabled in "
f"Syncthing's settings (Actions ▸ Settings ▸ Connections).")
if not in_accepted:
# The paste side can't tell "their computer is off" from "their
# LinTunes isn't open": Syncthing reports a stranger's connection as
# up for the moment before the other side hangs up on it. So one
# honest status covering both.
return Status(WAITING_COMPUTER,
f"Waiting for {name}'s computer",
computer_hint + f" It also waits until {name} opens "
f"LinTunes. If it never connects, the code may already "
f"have been used — ask {name} for a new one.",
last_seen=last_seen)
if not connected:
if not friend.connected_at:
return Status(WAITING_COMPUTER, f"Waiting for {name}'s computer",
computer_hint, last_seen=last_seen,
last_synced=friend.last_synced)
return Status(OFFLINE, f"{name}'s computer is offline",
"Everything catches up the next time both computers "
"are on.", last_seen=last_seen,
last_synced=friend.last_synced)
if remote_state == "notSharing":
return Status(WAITING_LINTUNES, f"Waiting for {name} to open LinTunes",
f"{name}'s Syncthing can see you; their LinTunes accepts "
f"the connection the next time it's open.",
last_seen=last_seen, last_synced=friend.last_synced)
return Status(CONNECTED, "Connected",
"" if up_to_date else "Syncing…",
last_seen=last_seen, last_synced=friend.last_synced,
up_to_date=up_to_date)
class CassetteService(QObject):
changed = pyqtSignal() # state or statuses moved
notice = pyqtSignal(str) # "<Name> is now connected."
step_failed = pyqtSignal(str, str) # step, what Syncthing said
def __init__(self, root: Path | None = None, config_loader=load_config,
session_factory=None, addresses_for=None, parent=None):
super().__init__(parent)
self.root = root or cassette_root()
self._config_loader = config_loader
self._session_factory = session_factory
# Where to dial a new device. "dynamic" (discovery) in real life; the
# two-instance test pair runs on loopback, where discovery can't work.
self._addresses_for = addresses_for or (lambda device_id: ["dynamic"])
self._lock = threading.RLock()
self._state = load_state(self.root)
self._statuses: dict[str, Status] = {}
self._api = Status(SYNCTHING_DOWN, "Checking Syncthing…")
self._stop = threading.Event()
self._kick = threading.Event()
self._thread: threading.Thread | None = None
self._last_failure: tuple[str, str] | None = None
# ---- lifecycle ----
def start(self):
if self._thread is None:
self._thread = threading.Thread(target=self._loop, daemon=True,
name="cassette")
self._thread.start()
def stop(self):
self._stop.set()
self._kick.set()
def kick(self):
"""Reconcile soon (after the current event wait ends)."""
self._kick.set()
# ---- reading ----
def state(self) -> CassetteState:
with self._lock:
return self._state.copy()
def status(self, token: str) -> Status | None:
with self._lock:
return self._statuses.get(token)
def api_status(self) -> Status:
with self._lock:
return self._api
# ---- plumbing ----
def client(self) -> st.SyncthingClient:
endpoint = st.resolve_endpoint(self._config_loader())
if endpoint is None:
raise st.SyncthingError("Connecting to Syncthing",
"no API key was found", st.NO_KEY)
session = self._session_factory() if self._session_factory else None
return st.SyncthingClient(endpoint, session=session)
def _save(self):
save_state(self._state, self.root)
def _publish_intro(self, token: str):
avatar = self.root / self._state.avatar if self._state.avatar else None
publish.publish_intro(out_dir(self.root, token),
self._state.display_name, avatar)
# ---- user actions ----
def create_invite(self) -> str:
"""Create a token and its outgoing folder; return the code to send."""
client = self.client()
my_id = client.my_id()
with self._lock:
token = inv.new_token()
self._publish_intro(token)
folder_id = inv.inviter_folder(token)
client.add_folder(folder_id, f"LinTunes: {self._state.display_name}"
" (invite)", out_dir(self.root, token), "sendonly",
step="Creating your shared folder")
self._state.invites[token] = Invite(token=token, created=now_iso(),
folder_id=folder_id)
self._save()
code = inv.encode(my_id, self._state.display_name, token)
self.changed.emit()
self.kick()
return code
def cancel_invite(self, token: str, client=None):
with self._lock:
invite = self._state.invites.get(token)
if invite is None or invite.status != OPEN:
return
client = client or self.client()
if client.folder(invite.folder_id) is not None:
client.remove_folder(invite.folder_id,
step="Removing the invite's shared folder")
invite.status = CANCELLED
shutil.rmtree(friend_dir(self.root, token), ignore_errors=True)
self._save()
self.changed.emit()
def accept_code(self, text: str) -> Friend:
"""Paste side: add the inviter, share our folder, then wait for theirs."""
code = inv.decode(text)
client = self.client()
my_id = client.my_id()
with self._lock:
if code.device_id == my_id or code.token in self._state.invites:
raise inv.InviteError(OWN_CODE)
if code.token in self._state.friends:
raise inv.InviteError(ALREADY_USED)
if client.device(code.device_id) is None:
client.add_device(code.device_id, code.name,
addresses=self._addresses_for(code.device_id))
self._publish_intro(code.token)
client.add_folder(inv.invitee_folder(code.token),
f"LinTunes: {self._state.display_name} → {code.name}",
out_dir(self.root, code.token), "sendonly",
[code.device_id],
step=f"Sharing your folder with {code.name}")
friend = Friend(token=code.token, device_id=code.device_id,
name=code.name, role=INVITEE,
out_folder=inv.invitee_folder(code.token),
in_folder=inv.inviter_folder(code.token),
added=now_iso())
self._state.friends[code.token] = friend
self._save()
self.changed.emit()
self.kick()
return friend
def remove_friend(self, token: str, client=None):
with self._lock:
friend = self._state.friends.get(token)
if friend is None:
return
client = client or self.client()
for folder_id in (friend.out_folder, friend.in_folder):
if folder_id and client.folder(folder_id) is not None:
client.remove_folder(folder_id,
step=f"Removing {friend.name}'s folder")
# Only drop the device if no other friendship rides on it.
if not any(f.device_id == friend.device_id
for t, f in self._state.friends.items() if t != token):
if client.device(friend.device_id) is not None:
client.remove_device(friend.device_id, friend.name)
shutil.rmtree(friend_dir(self.root, token), ignore_errors=True)
del self._state.friends[token]
self._statuses.pop(token, None)
self._save()
self.changed.emit()
def apply_settings(self, edited: CassetteState, cancel_invites=(),
remove_friends=()):
"""Sync Settings pressed OK: adopt the edited fields, carry out the
staged removals, republish what changed."""
errors = []
for token in cancel_invites:
try:
self.cancel_invite(token)
except st.SyncthingError as exc:
errors.append(exc)
for token in remove_friends:
try:
self.remove_friend(token)
except st.SyncthingError as exc:
errors.append(exc)
with self._lock:
state = self._state
state.display_name = edited.display_name
state.avatar = edited.avatar
state.share_same_for_all = edited.share_same_for_all
state.general = edited.general
for token, friend in state.friends.items():
mine = edited.friends.get(token)
if mine is not None:
friend.selection = mine.selection
friend.layout = mine.layout
friend.followed_mode = mine.followed_mode
self._save()
for token in list(state.friends) + [i.token for i in state.open_invites()]:
self._publish_intro(token)
self.changed.emit()
if errors:
raise errors[0]
# ---- the background loop ----
def _loop(self):
since = 0
client = None
while not self._stop.is_set():
try:
client = client or self.client()
self.reconcile(client)
self._kick.clear()
events = client.events(since, EVENT_TYPES, EVENT_TIMEOUT_S)
if events:
since = max(e.get("id", since) for e in events)
except st.SyncthingError as exc:
client = None
since = 0
self._set_api_down(exc)
self._kick.wait(DOWN_RETRY_S)
self._kick.clear()
except Exception: # never let the thread die silently
log.exception("cassette loop")
self._kick.wait(DOWN_RETRY_S)
self._kick.clear()
def _set_api_down(self, exc: st.SyncthingError):
if exc.kind in (st.BAD_KEY, st.NO_KEY):
status = Status(NO_API, "Can't reach Syncthing's API",
"Enter Syncthing's API key in Sync Settings ▸ "
"General, or let LinTunes read it from "
"Syncthing's config.")
else:
status = Status(SYNCTHING_DOWN, "Syncthing isn't running",
"Start Syncthing, then press Retry in Sync "
"Settings ▸ General.")
with self._lock:
changed = self._api.code != status.code
self._api = status
if changed:
self.changed.emit()
def _fail(self, exc: st.SyncthingError):
"""A background step failed: say so once (not every reconcile)."""
key = (exc.step, exc.detail)
if key != self._last_failure:
self._last_failure = key
self.step_failed.emit(exc.step, exc.detail)
def reconcile(self, client: st.SyncthingClient):
"""Bring state in line with Syncthing and recompute every status."""
version = client.version()
with self._lock:
self._api = Status(CONNECTED, f"Syncthing {version} is running.")
try:
pending_devices = client.pending_devices()
pending_folders = client.pending_folders()
self._handle_offers(client, pending_folders, pending_devices)
self._handle_probes(client, pending_devices, pending_folders)
self._last_failure = None
except st.SyncthingError as exc:
if exc.kind != st.OTHER:
raise
self._fail(exc)
notices = self._refresh_statuses(client)
for message in notices:
self.notice.emit(message)
self.changed.emit()
def _handle_offers(self, client, pending_folders: dict,
pending_devices: dict):
state = self._state
for folder_id, info in pending_folders.items():
parsed = inv.parse_folder_id(folder_id)
offered_by = (info or {}).get("offeredBy", {})
for device_id in offered_by:
if parsed is None:
continue
token, side = parsed
if side == "b":
invite = state.invites.get(token)
if invite is not None and invite.status == OPEN \
and token not in state.friends:
name = (pending_devices.get(device_id, {}).get("name")
or "New friend")
self._complete_invite(client, invite, device_id, name)
elif device_id in state.probing:
# A knock with a used or cancelled code.
self._reject(client, device_id)
elif side == "a":
friend = state.friends.get(token)
if (friend is not None and friend.role == INVITEE
and friend.device_id == device_id
and client.folder(folder_id) is None):
in_path = in_dir(self.root, token)
in_path.mkdir(parents=True, exist_ok=True)
client.add_folder(
folder_id, f"LinTunes: from {friend.name}", in_path,
"receiveonly", [device_id],
step=f"Accepting {friend.name}'s folder")
# A probed device that offers folders, none of them an open invite's.
for device_id in list(state.probing):
offers = [fid for fid, info in pending_folders.items()
if device_id in (info or {}).get("offeredBy", {})]
if offers and device_id in state.probing:
self._reject(client, device_id)
def _complete_invite(self, client, invite: Invite, device_id: str,
hostname: str):
token = invite.token
client.add_device(device_id, hostname,
addresses=self._addresses_for(device_id))
client.share_folder(invite.folder_id, device_id,
step="Sharing your folder with your friend")
in_path = in_dir(self.root, token)
in_path.mkdir(parents=True, exist_ok=True)
client.add_folder(inv.invitee_folder(token), "LinTunes: from a friend",
in_path, "receiveonly", [device_id],
step="Accepting your friend's folder")
self._state.friends[token] = Friend(
token=token, device_id=device_id, name=hostname, role=INVITER,
out_folder=invite.folder_id, in_folder=inv.invitee_folder(token),
added=now_iso())
invite.status = USED
self._state.probing.pop(device_id, None)
self._save()
def _reject(self, client, device_id: str):
self._state.probing.pop(device_id, None)
self._state.rejected[device_id] = now_iso()
if client.device(device_id) is not None:
client.remove_device(device_id, "an unknown computer")
self._save()
def _handle_probes(self, client, pending_devices: dict,
pending_folders: dict):
state = self._state
now = _utc_now()
known = {f.device_id for f in state.friends.values()}
opens = state.open_invites()
if opens:
oldest = min((_parse(i.created) or now) for i in opens)
for device_id, info in pending_devices.items():
if device_id in known or device_id in state.probing:
continue
rejected = _parse(state.rejected.get(device_id, ""))
if rejected is not None:
continue
seen = _parse((info or {}).get("time", "")) or now
if seen + timedelta(seconds=5) < oldest:
continue # knocking since before any open invite
client.add_device(device_id, (info or {}).get("name") or "?",
addresses=self._addresses_for(device_id))
state.probing[device_id] = now_iso()
self._save()
if not state.probing:
return
connections = client.connections()
for device_id, started in list(state.probing.items()):
began = _parse(started) or now
connected = connections.get(device_id, {}).get("connected")
if not opens:
self._release_probe(client, device_id)
elif connected and now - began > PROBE_CONNECTED_TIMEOUT:
self._reject(client, device_id)
elif now - began > PROBE_TIMEOUT:
self._release_probe(client, device_id)
def _release_probe(self, client, device_id: str):
"""Stop probing without holding it against the device."""
self._state.probing.pop(device_id, None)
if client.device(device_id) is not None:
client.remove_device(device_id, "an unknown computer")
self._save()
def _refresh_statuses(self, client) -> list[str]:
state = self._state
notices = []
statuses = {}
for invite in state.invites.values():
if invite.status == OPEN:
statuses[invite.token] = Status(
WAITING_PASTE, "Waiting for your friend to paste the code")
elif invite.status == CANCELLED:
statuses[invite.token] = Status(CANCELLED_CODE,
"This invite was cancelled")
if not state.friends:
self._statuses = statuses
return notices
connections = client.connections()
stats = client.device_stats()
dirty = False
for token, friend in state.friends.items():
in_accepted = client.folder(friend.in_folder) is not None
connected = bool(connections.get(friend.device_id, {}).get("connected"))
remote_state, up_to_date = "", False
if in_accepted and connected:
remote = client.completion(friend.out_folder, friend.device_id)
remote_state = remote.get("remoteState", "")
local = client.completion(friend.in_folder)
up_to_date = (remote.get("completion", 0) >= 100
and local.get("completion", 0) >= 100
and remote_state != "notSharing")
# The friend's own display name, once their folder has synced.
intro = publish.read_intro(in_dir(self.root, token))
name = str(intro.get("name") or "").strip()[:80]
if name and name != friend.name:
friend.name = name
dirty = True
if in_accepted and connected and remote_state != "notSharing" \
and not friend.connected_at:
friend.connected_at = now_iso()
notices.append(f"{friend.name} is now connected.")
dirty = True
if up_to_date:
friend.last_synced = now_iso()
dirty = dirty or not self._statuses.get(token, Status("", "")).up_to_date
statuses[token] = friend_status(
friend, connected=connected, in_accepted=in_accepted,
remote_state=remote_state, up_to_date=up_to_date,
last_seen=stats.get(friend.device_id, {}).get("lastSeen", ""))
self._statuses = statuses
if dirty:
self._save()
return notices
+15 -2
View File
@@ -122,6 +122,7 @@ class Friend:
in_folder: str = "" # Syncthing folder id they write (receive-only here)
added: str = ""
connected_at: str = "" # first time the handshake completed
last_synced: str = "" # last time both folders were seen up to date
selection: Selection = field(default_factory=Selection)
layout: str = LAYOUT_FOLDER
followed_mode: str = FOLLOW_SEPARATE
@@ -133,6 +134,7 @@ class Friend:
"name": self.name, "role": self.role,
"out_folder": self.out_folder, "in_folder": self.in_folder,
"added": self.added, "connected_at": self.connected_at,
"last_synced": self.last_synced,
"selection": self.selection.to_dict(), "layout": self.layout,
"followed_mode": self.followed_mode,
"followed": list(self.followed),
@@ -146,6 +148,7 @@ class Friend:
in_folder=d.get("in_folder", ""),
added=d.get("added", ""),
connected_at=d.get("connected_at", ""),
last_synced=d.get("last_synced", ""),
selection=Selection.from_dict(d.get("selection")),
layout=d.get("layout", LAYOUT_FOLDER),
followed_mode=d.get("followed_mode", FOLLOW_SEPARATE),
@@ -161,6 +164,13 @@ class CassetteState:
general: Selection = field(default_factory=Selection)
invites: dict = field(default_factory=dict) # token -> Invite
friends: dict = field(default_factory=dict) # token -> Friend
# Unknown devices knocking while an invite is open are added with nothing
# shared, just long enough to see which folder they offer (Syncthing
# hides an unknown device's folders): device id -> when we started.
probing: dict = field(default_factory=dict)
# Devices that turned out not to hold an open invite: device id -> when.
# They stay pending in Syncthing, untouched, and aren't probed again.
rejected: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {"version": FORMAT_VERSION,
@@ -168,7 +178,8 @@ class CassetteState:
"share_same_for_all": self.share_same_for_all,
"general": self.general.to_dict(),
"invites": [i.to_dict() for i in self.invites.values()],
"friends": [f.to_dict() for f in self.friends.values()]}
"friends": [f.to_dict() for f in self.friends.values()],
"probing": dict(self.probing), "rejected": dict(self.rejected)}
@classmethod
def from_dict(cls, d: dict | None) -> "CassetteState":
@@ -176,7 +187,9 @@ class CassetteState:
state = cls(display_name=d.get("display_name") or default_display_name(),
avatar=d.get("avatar", ""),
share_same_for_all=bool(d.get("share_same_for_all", False)),
general=Selection.from_dict(d.get("general")))
general=Selection.from_dict(d.get("general")),
probing=dict(d.get("probing", {})),
rejected=dict(d.get("rejected", {})))
for raw in d.get("invites", []):
invite = Invite.from_dict(raw)
state.invites[invite.token] = invite
+2 -2
View File
@@ -180,12 +180,12 @@ class SyncthingClient:
return None
raise
def add_device(self, device_id: str, name: str):
def add_device(self, device_id: str, name: str, addresses=("dynamic",)):
"""Add (or rename) a device. Idempotent."""
self._request("PUT", f"/rest/config/devices/{device_id}",
f"Adding {name}'s computer to Syncthing",
json={"deviceID": device_id, "name": name,
"addresses": ["dynamic"]})
"addresses": list(addresses)})
def remove_device(self, device_id: str, name: str = "the device"):
self._request("DELETE", f"/rest/config/devices/{device_id}",
+106
View File
@@ -0,0 +1,106 @@
"""Share Library with a Friend… and Add Friend Library… — the one-paste
connection. Both talk to ``CassetteService``; every failure shows which step
failed and what Syncthing said."""
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QGuiApplication
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QPlainTextEdit,
QPushButton, QVBoxLayout,
)
from lintunes.cassette import invite as inv
from lintunes.cassette import syncthing_api as st
def failure_text(exc: Exception) -> str:
"""What to show for a failed action: the step and Syncthing's words, plus
the next step when the cause is Syncthing itself."""
if isinstance(exc, inv.InviteError):
return str(exc)
if isinstance(exc, st.SyncthingError):
if exc.kind == st.NOT_RUNNING:
return (f"{exc.step} failed: Syncthing isn't running. Start "
f"Syncthing and try again.")
if exc.kind in (st.BAD_KEY, st.NO_KEY):
return (f"{exc.step} failed: LinTunes can't reach Syncthing's API "
f"({exc.detail}). Check the API key in Sync Settings ▸ "
f"General.")
return f"{exc.step} failed. Syncthing said: {exc.detail}"
return f"Something went wrong: {exc}"
class ShareInviteDialog(QDialog):
"""Shows a freshly made invite code with a Copy button."""
def __init__(self, code: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Share Library with a Friend")
self.resize(520, 260)
layout = QVBoxLayout(self)
intro = QLabel(
"Send this code to one friend. They paste it into "
"Connections ▸ Add Friend Library… in their LinTunes.\n\n"
"It works once, and it doesn't expire. You don't both need to be "
"online now: you'll be connected the next time both computers are "
"on. Unused invites are listed in Sync Settings, where you can "
"cancel them.")
intro.setWordWrap(True)
layout.addWidget(intro)
self.code = QPlainTextEdit(code)
self.code.setReadOnly(True)
self.code.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
layout.addWidget(self.code, stretch=1)
row = QHBoxLayout()
self.copy = QPushButton("Copy Code")
self.copy.setDefault(True)
self.copy.clicked.connect(self._copy)
self.copied = QLabel("")
row.addWidget(self.copy)
row.addWidget(self.copied, stretch=1)
done = QPushButton("Done")
done.clicked.connect(self.accept)
row.addWidget(done)
layout.addLayout(row)
def _copy(self):
QGuiApplication.clipboard().setText(self.code.toPlainText())
self.copied.setText("Copied.")
class AddFriendDialog(QDialog):
"""Paste a code, press Connect. ``service.accept_code`` does the work."""
def __init__(self, service, parent=None):
super().__init__(parent)
self._service = service
self.friend = None
self.setWindowTitle("Add Friend Library")
self.resize(520, 240)
layout = QVBoxLayout(self)
label = QLabel("Paste the invite code your friend sent you:")
layout.addWidget(label)
self.field = QPlainTextEdit()
self.field.setPlaceholderText("LTC1.…")
layout.addWidget(self.field, stretch=1)
self.message = QLabel("")
self.message.setWordWrap(True)
self.message.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
layout.addWidget(self.message)
buttons = QDialogButtonBox()
self.connect_button = buttons.addButton(
"Connect", QDialogButtonBox.ButtonRole.AcceptRole)
buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self._connect)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _connect(self):
try:
self.friend = self._service.accept_code(self.field.toPlainText())
except (inv.InviteError, st.SyncthingError) as exc:
self.message.setText(failure_text(exc))
return
self.accept()
+83 -11
View File
@@ -1,8 +1,9 @@
"""Cassette's place in the main window: its Connections-menu group, the
"which machine is the host" gate, and the dialogs those items open.
"which machine is the host" gate, the service that runs the handshake, and
the dialogs those menu items open.
Kept out of main_window.py, which is big enough; MainWindow owns one of these
and calls ``build_menu`` / ``refresh_menu``.
and calls ``build_menu`` / ``refresh_menu`` / ``shutdown``.
"""
from __future__ import annotations
@@ -11,17 +12,52 @@ from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QDialog, QMessageBox
from lintunes.cassette import host
from lintunes.cassette.state import cassette_root, load_state, save_state
from lintunes.cassette import syncthing_api as st
from lintunes.config import load_config, save_config
NOTICE_MS = 10000
class CassetteUi(QObject):
def __init__(self, window, prefs, parent=None):
def __init__(self, window, prefs, parent=None, service_factory=None):
super().__init__(parent or window)
self._window = window
self._prefs = prefs
self._elsewhere_action: QAction | None = None
self._actions: list[QAction] = []
self._service_factory = service_factory
self.service = None
# ---- the service (host machine only) ----
def start_if_host(self):
"""At startup: run the handshake loop if this machine is the host."""
if host.is_host(self._prefs):
self._ensure_service()
def _ensure_service(self):
if self.service is None:
if self._service_factory is not None:
self.service = self._service_factory()
else:
from lintunes.cassette.service import CassetteService
self.service = CassetteService(parent=self)
self.service.notice.connect(self._on_notice)
self.service.step_failed.connect(self._on_step_failed)
self.service.start()
return self.service
def shutdown(self):
if self.service is not None:
self.service.stop()
def _on_notice(self, message: str):
self._window.statusBar().showMessage(message, NOTICE_MS)
def _on_step_failed(self, step: str, detail: str):
QMessageBox.warning(
self._window, "Friend Sharing",
f"{step} failed.\n\nSyncthing said: {detail}")
# ---- menu ----
@@ -32,10 +68,13 @@ class CassetteUi(QObject):
self._elsewhere_action.setEnabled(False)
self._elsewhere_action.setVisible(False)
menu.addAction(self._elsewhere_action)
settings = QAction("Sync Settings…", self._window)
settings.triggered.connect(self.show_sync_settings)
menu.addAction(settings)
self._actions.append(settings)
for text, slot in (("Share Library with a Friend…", self.share_library),
("Add Friend Library…", self.add_friend),
("Sync Settings…", self.show_sync_settings)):
action = QAction(text, self._window)
action.triggered.connect(slot)
menu.addAction(action)
self._actions.append(action)
menu.addSeparator()
def refresh_menu(self):
@@ -54,6 +93,7 @@ class CassetteUi(QObject):
no machine has yet."""
status, name = host.host_status(self._prefs)
if status == host.HERE:
self._ensure_service()
return True
if status == host.ELSEWHERE:
QMessageBox.information(
@@ -72,17 +112,43 @@ class CassetteUi(QObject):
if answer != QMessageBox.StandardButton.Yes:
return False
host.claim(self._prefs)
self._ensure_service()
return True
# ---- dialogs ----
def share_library(self):
if not self.ensure_host():
return
from lintunes.gui.cassette_dialogs import ShareInviteDialog, failure_text
try:
code = self.service.create_invite()
except st.SyncthingError as exc:
QMessageBox.warning(self._window, "Share Library with a Friend",
failure_text(exc))
return
ShareInviteDialog(code, self._window).exec()
def add_friend(self):
if not self.ensure_host():
return
from lintunes.gui.cassette_dialogs import AddFriendDialog
dialog = AddFriendDialog(self.service, self._window)
if dialog.exec() == QDialog.DialogCode.Accepted and dialog.friend:
self._window.statusBar().showMessage(
f"Connecting to {dialog.friend.name}… You'll see "
f"\"{dialog.friend.name} is now connected.\" when it's done.",
NOTICE_MS)
def show_sync_settings(self):
if not self.ensure_host():
return
from lintunes.gui.cassette_dialogs import failure_text
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
root = cassette_root()
service = self.service
config = load_config()
dialog = SyncSettingsDialog(load_state(root), root, config, self._window)
dialog = SyncSettingsDialog(service.state(), service.root, config,
self._window, service=service)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
dialog.apply()
@@ -91,4 +157,10 @@ class CassetteUi(QObject):
config = load_config()
config.update(changes)
save_config(config)
save_state(dialog.state, root)
try:
service.apply_settings(dialog.state,
cancel_invites=dialog.cancelled_invites,
remove_friends=dialog.removed_friends)
except st.SyncthingError as exc:
QMessageBox.warning(self._window, "Sync Settings", failure_text(exc))
service.kick()
+8 -1
View File
@@ -95,7 +95,9 @@ class MainWindow(QMainWindow):
# Context playback started from: "library" or "playlist:<pid>". Scopes
# the now-playing speaker icon to where the song is actually playing.
self._now_playing_context = ""
# Friend sharing's menu group and dialogs.
# Friend sharing's menu group and dialogs. Its background service is
# started by run_gui (host machine only), never by constructing the
# window — tests build windows by the dozen.
self._cassette = CassetteUi(self, prefs)
self.setWindowTitle("LinTunes")
@@ -386,6 +388,10 @@ class MainWindow(QMainWindow):
# always reflects plug/unplug, the cast session and the current view.
connections_menu.aboutToShow.connect(self._refresh_connection_actions)
@property
def cassette(self) -> CassetteUi:
return self._cassette
def _add_action(self, menu, text, shortcut, slot):
action = QAction(text, self)
if shortcut:
@@ -1707,6 +1713,7 @@ class MainWindow(QMainWindow):
self._sync_inhibitor.release()
# Before the player: this stops the device and kills the HTTP server it
# is fetching from. Skipped, the Chromecast sits buffering a dead URL.
self._cassette.shutdown()
self._cast.shutdown()
self.player.shutdown()
self._manager.flush()
+171 -16
View File
@@ -7,18 +7,21 @@ None" can wipe a careful selection, and Cancel has to be able to undo that.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QPushButton, QSplitter,
QStackedWidget, QVBoxLayout, QWidget,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton,
QSplitter, QStackedWidget, QVBoxLayout, QWidget,
)
from lintunes.cassette import probe as probe_mod
from lintunes.cassette import publish
from lintunes.cassette import syncthing_api as st
from lintunes.cassette.state import OPEN, in_dir
AVATAR_PX = 256
AVATAR_PREVIEW_PX = 72
@@ -39,6 +42,29 @@ def load_avatar_image(path: Path) -> QImage | None:
Qt.TransformationMode.SmoothTransformation)
def format_when(stamp: str) -> str:
"""A stored UTC stamp (ours naive, Syncthing's with Z) as local time a
person reads: "today 14:02", "yesterday 09:15", "3 Sep 2026 18:40"."""
if not stamp:
return "never"
try:
when = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
except ValueError:
return stamp
if when.year < 2000: # Syncthing's zero time: never seen
return "never"
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
local = when.astimezone()
today = datetime.now().astimezone().date()
clock = local.strftime("%H:%M")
if local.date() == today:
return f"today {clock}"
if (today - local.date()).days == 1:
return f"yesterday {clock}"
return f"{local.day} {local.strftime('%b %Y')} {clock}"
class AvatarSquare(QLabel):
"""Fixed square preview; gray with a hint when empty."""
@@ -119,6 +145,12 @@ class GeneralPage(QWidget):
form.addWidget(self._retry, alignment=Qt.AlignmentFlag.AlignLeft)
layout.addWidget(box)
self.invites_box = QGroupBox("Invites waiting to be used")
self._invites_layout = QVBoxLayout(self.invites_box)
layout.addWidget(self.invites_box)
self.cancelled_invites: set[str] = set()
self.refresh_invites(None)
you = QGroupBox("You")
you_form = QFormLayout(you)
self.display_name = QLineEdit(state.display_name)
@@ -146,6 +178,38 @@ class GeneralPage(QWidget):
self.show_probe(None)
def refresh_invites(self, service):
"""One row per open invite: when it was made, its status, and a
Cancel Invite button (staged — carried out on OK)."""
while self._invites_layout.count():
item = self._invites_layout.takeAt(0)
if item.widget() is not None:
item.widget().deleteLater()
invites = sorted((i for i in self._state.invites.values()
if i.status == OPEN), key=lambda i: i.created)
self.invites_box.setVisible(bool(invites))
for invite in invites:
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
status = service.status(invite.token) if service else None
text = status.text if status else "Waiting for your friend to paste the code"
label = QLabel(f"Made {format_when(invite.created)} — {text}")
label.setWordWrap(True)
row_layout.addWidget(label, stretch=1)
if invite.token in self.cancelled_invites:
row_layout.addWidget(QLabel("Cancelled when you press OK"))
else:
cancel = QPushButton("Cancel Invite")
cancel.clicked.connect(
lambda _=False, t=invite.token: self._cancel(t, service))
row_layout.addWidget(cancel)
self._invites_layout.addWidget(row)
def _cancel(self, token: str, service):
self.cancelled_invites.add(token)
self.refresh_invites(service)
def show_probe(self, result):
headline, hint = status_text(result)
self._status.setText(headline)
@@ -187,6 +251,84 @@ class GeneralPage(QWidget):
self._avatar.set_image(None)
class FriendPage(QWidget):
"""One friend: who they are, how the connection is doing, Remove Friend.
(Their share selector joins this page later.)"""
def __init__(self, friend, root: Path, parent=None):
super().__init__(parent)
self.friend = friend
self.removed = False
layout = QVBoxLayout(self)
header = QHBoxLayout()
self.avatar = AvatarSquare()
avatar_path = in_dir(root, friend.token) / publish.AVATAR_FILE
if avatar_path.exists():
self.avatar.set_image(QImage(str(avatar_path)))
header.addWidget(self.avatar)
title = QLabel(friend.name)
font = title.font()
font.setPointSize(font.pointSize() + 3)
font.setBold(True)
title.setFont(font)
header.addWidget(title, stretch=1)
layout.addLayout(header)
box = QGroupBox("Connection")
box_layout = QVBoxLayout(box)
self.status = QLabel()
self.status.setWordWrap(True)
self.hint = QLabel()
self.hint.setWordWrap(True)
self.details = QLabel()
box_layout.addWidget(self.status)
box_layout.addWidget(self.hint)
box_layout.addWidget(self.details)
layout.addWidget(box)
layout.addStretch(1)
row = QHBoxLayout()
row.addStretch(1)
self.removed_note = QLabel("Removed when you press OK")
self.removed_note.setVisible(False)
self.remove_button = QPushButton("Remove Friend…")
self.remove_button.clicked.connect(self._remove)
row.addWidget(self.removed_note)
row.addWidget(self.remove_button)
layout.addLayout(row)
self.show_status(None)
def show_status(self, status):
if status is None:
self.status.setText("Checking…")
self.hint.setVisible(False)
self.details.setText(f"Last synced: {format_when(self.friend.last_synced)}")
return
text = status.text
if status.code == "connected":
text += " — up to date" if status.up_to_date else " — syncing"
self.status.setText(text)
self.hint.setText(status.hint)
self.hint.setVisible(bool(status.hint))
self.details.setText(
f"Last seen: {format_when(status.last_seen)}\n"
f"Last synced: {format_when(status.last_synced)}")
def _remove(self):
answer = QMessageBox.question(
self, "Remove Friend",
f"Remove {self.friend.name}?\n\nYou'll stop sharing with each "
f"other, and the copy of their library on this computer is "
f"deleted. Songs you already brought into your library stay.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel)
if answer == QMessageBox.StandardButton.Yes:
self.removed = True
self.removed_note.setVisible(True)
self.remove_button.setEnabled(False)
class SyncSettingsDialog(QDialog):
"""Edits ``state`` (a copy — the caller's is untouched until accepted).
@@ -195,8 +337,9 @@ class SyncSettingsDialog(QDialog):
the change affects."""
def __init__(self, state, root: Path, config: dict, parent=None,
probe=None):
probe=None, service=None):
super().__init__(parent)
self._service = service
self.setWindowTitle("Sync Settings")
self.resize(820, 560)
self.state = state.copy()
@@ -216,7 +359,7 @@ class SyncSettingsDialog(QDialog):
self.friend_pages = {}
for friend in sorted(self.state.friends.values(),
key=lambda f: f.name.casefold()):
page = self._friend_page(friend)
page = FriendPage(friend, root)
self.friend_pages[friend.token] = page
self._add_page(friend.name, page)
self.pages_list.currentRowChanged.connect(self.pages.setCurrentIndex)
@@ -235,23 +378,35 @@ class SyncSettingsDialog(QDialog):
self._probe.finished.connect(self.general.show_probe)
self.general.retry_requested.connect(self._retry)
self._probe.start(self._probe_config())
if service is not None:
service.changed.connect(self._refresh_statuses)
self._refresh_statuses()
def _refresh_statuses(self):
self.general.refresh_invites(self._service)
for token, page in self.friend_pages.items():
page.show_status(self._service.status(token))
@property
def cancelled_invites(self) -> list[str]:
return sorted(self.general.cancelled_invites)
@property
def removed_friends(self) -> list[str]:
return [t for t, page in self.friend_pages.items() if page.removed]
def done(self, result):
if self._service is not None:
try:
self._service.changed.disconnect(self._refresh_statuses)
except TypeError:
pass
super().done(result)
def _add_page(self, label: str, page: QWidget):
self.pages_list.addItem(label)
self.pages.addWidget(page)
def _friend_page(self, friend) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
title = QLabel(friend.name)
font = title.font()
font.setPointSize(font.pointSize() + 3)
font.setBold(True)
title.setFont(font)
layout.addWidget(title)
layout.addStretch(1)
return page
def _probe_config(self) -> dict:
config = dict(self._config)
key = self.general.api_key.text().strip()
+2
View File
@@ -170,6 +170,8 @@ def run_gui(data_dir: Path, files: list[Path],
watcher.changed.connect(manager.check_for_external_changes)
window.show()
# Friend sharing's handshake loop — only on the machine that hosts it.
window.cassette.start_if_host()
# Ask where the music goes only once the window is up, so a new user is
# never met by a dialog floating in front of nothing.
QTimer.singleShot(0, window.check_music_folder)