- Sync Settings gets a share selector on each friend page (and on General when "Share the same selection with all friends" is on; each friend's own selection is kept, greyed, and comes back when it's turned off). - Library: a genre/artist/album column browser with a checkbox beside every artist, album and track, search, Select All / Select None and "Share whole library". Playlists: a checklist with "Share all playlists". Nothing is shared by default. - Checking an artist or album is a snapshot of its tracks; the whole library and shared playlists are live. A shared playlist's tracks are checked and locked, with a tooltip naming the playlist(s). - cassette/share.py writes the trimmed copy (library.json, playlists.json) into each friend's folder: tags yes, listening history and local paths no. Written on OK and after library edits (debounced), and only when the bytes would change.
586 lines
25 KiB
Python
586 lines
25 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
|
|
|
|
# ---- 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
|