- Sending: a friend's requests ∩ what I still offer is copied into my outbox (via a .part name Syncthing never sees); whatever they no longer request is deleted, so the outbox cleans itself up. - Receiving: a finished file I cassetted is imported exactly like Add to Library — organized into Artist/Album, tags read, date added now, none of their listening history — and a song wanted only for a followed playlist lands in the cache. Then requests.json is rewritten without what arrived and without what they stopped offering. - Safe to repeat: Syncthing temp files ignored, Syncthing asked whether a file is whole, and an exact match already in my library is never imported twice. - Folders are watched (re-armed after each rename), with a slow fallback. - file_importer split into stage_file (off the GUI thread) and track_from_file (date added = now). - pytest --syncthing now runs a real invite → share → request → deliver → cleanup between two Syncthing instances. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
639 lines
28 KiB
Python
639 lines
28 KiB
Python
"""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
|
|
# A friendship was added (their copy of my share needs writing). Emitted
|
|
# from the background thread; the GUI side does the writing, because
|
|
# that reads the library.
|
|
friends_added = pyqtSignal()
|
|
|
|
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
|
|
self._library_provider = 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)
|
|
|
|
# ---- publishing my share ----
|
|
|
|
def set_library_provider(self, provider):
|
|
"""``provider()`` returns the live Library. Only ever called on the
|
|
GUI thread (republish), which is the thread that mutates it."""
|
|
self._library_provider = provider
|
|
|
|
def republish(self) -> list[str]:
|
|
"""Write every friend's trimmed copy of my library; unchanged files
|
|
aren't touched. Returns the tokens whose copy changed."""
|
|
if self._library_provider is None:
|
|
return []
|
|
from lintunes.cassette import share
|
|
library = self._library_provider()
|
|
changed = []
|
|
with self._lock:
|
|
for token, friend in self._state.friends.items():
|
|
selection = self._state.selection_for(friend)
|
|
if share.publish_share(out_dir(self.root, token), library,
|
|
selection):
|
|
changed.append(token)
|
|
return changed
|
|
|
|
# ---- requests ----
|
|
|
|
def save_requests(self, token: str, cassetted, followed, requested):
|
|
"""Friend mode's Save and Close: remember what I cassetted and which
|
|
playlists I follow, and tell the friend (``requests.json`` in my
|
|
folder to them) which of their track ids to send."""
|
|
from lintunes.cassette import requests as req
|
|
with self._lock:
|
|
friend = self._state.friends.get(token)
|
|
if friend is None:
|
|
return
|
|
friend.cassetted = sorted(int(t) for t in cassetted)
|
|
friend.followed = sorted(followed)
|
|
self._save()
|
|
req.write_requests(out_dir(self.root, token), requested)
|
|
self.changed.emit()
|
|
|
|
def apply_delivery(self, token: str, cassetted, requested):
|
|
"""After a batch arrived: what's still wanted, and the requests file
|
|
that says so (rewritten only if it changed)."""
|
|
from lintunes.cassette import requests as req
|
|
with self._lock:
|
|
friend = self._state.friends.get(token)
|
|
if friend is None:
|
|
return
|
|
cassetted = sorted(int(t) for t in cassetted)
|
|
if cassetted != friend.cassetted:
|
|
friend.cassetted = cassetted
|
|
self._save()
|
|
req.write_requests(out_dir(self.root, token), requested)
|
|
|
|
def is_complete(self, token: str, path) -> bool:
|
|
"""Has Syncthing finished writing this file of the friend's? It only
|
|
renames a download into place once it's whole, so a finished name is
|
|
normally enough; this double-checks with Syncthing when it can."""
|
|
with self._lock:
|
|
friend = self._state.friends.get(token)
|
|
if friend is None:
|
|
return False
|
|
try:
|
|
client = self.client()
|
|
relative = path.relative_to(in_dir(self.root, token)).as_posix()
|
|
info = client.file_info(friend.in_folder, relative)
|
|
except (st.SyncthingError, ValueError):
|
|
return True
|
|
if not info:
|
|
return True
|
|
local, global_ = info.get("local") or {}, info.get("global") or {}
|
|
if local.get("deleted") or global_.get("deleted"):
|
|
return False
|
|
return (local.get("version") == global_.get("version")
|
|
and local.get("size") == global_.get("size"))
|
|
|
|
# ---- 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.republish()
|
|
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.republish()
|
|
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()
|
|
self.friends_added.emit()
|
|
|
|
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
|