v0.27.0: Cassette invites — one paste connects two friends
- Connections ▸ Share Library with a Friend… makes a one-time invite code (device id, display name, token) and the inviter's send-only folder. - Connections ▸ Add Friend Library… takes the pasted code: adds the inviter, shares our folder, and accepts theirs receive-only when it's offered back. - Syncthing hides an unknown device's folders (verified on 1.30), so the inviter probes a new knock only while an invite is open: added with nothing shared, completed on the right token, otherwise removed and never probed again. Used and cancelled codes go nowhere. - Sync Settings lists open invites (Cancel Invite) and gives each friend a page: avatar, plain-language status with a next step, last seen, last synced, Remove Friend. Removals are staged until OK. - scripts/cassette_pair.py runs throwaway Syncthings on loopback; pytest --syncthing drives a real three-machine handshake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Two throwaway Syncthing instances on this one machine, for testing Cassette
|
||||
without a friend on the other end.
|
||||
|
||||
python3 scripts/cassette_pair.py --out /tmp/pair # start, print, wait
|
||||
python3 scripts/cassette_pair.py --out /tmp/pair --stop # stop them
|
||||
|
||||
Each instance gets its own home (cert, config, database), GUI port and listen
|
||||
port, with global discovery, relays, NAT traversal and usage reporting off: two
|
||||
Syncthings on loopback find each other through local discovery and nothing ever
|
||||
leaves the machine. Neither one knows the other until Cassette introduces them,
|
||||
which is exactly the situation an invite code starts from.
|
||||
|
||||
Point a LinTunes at one of them with ``syncthing_api_key`` /
|
||||
``syncthing_address`` in its config.json (``XDG_CONFIG_HOME`` keeps each
|
||||
LinTunes' config separate). ``tests/test_round65.py`` drives the pair through a
|
||||
whole handshake when run with ``-m syncthing``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
NAMES = ("alice", "bob")
|
||||
BASE_GUI_PORT = 28384
|
||||
BASE_LISTEN_PORT = 28400
|
||||
|
||||
|
||||
@dataclass
|
||||
class Instance:
|
||||
name: str
|
||||
home: str
|
||||
gui_port: int
|
||||
listen_port: int
|
||||
api_key: str
|
||||
device_id: str = ""
|
||||
pid: int = 0
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return f"127.0.0.1:{self.gui_port}"
|
||||
|
||||
@property
|
||||
def listen_address(self) -> str:
|
||||
return f"tcp://127.0.0.1:{self.listen_port}"
|
||||
|
||||
|
||||
def _set(parent, tag, text):
|
||||
node = parent.find(tag)
|
||||
if node is None:
|
||||
node = ET.SubElement(parent, tag)
|
||||
node.text = text
|
||||
|
||||
|
||||
def _configure(home: Path, gui_port: int, listen_port: int) -> str:
|
||||
"""Rewrite a generated config for an isolated loopback instance. Returns
|
||||
the API key."""
|
||||
path = home / "config.xml"
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
gui = root.find("gui")
|
||||
_set(gui, "address", f"127.0.0.1:{gui_port}")
|
||||
api_key = gui.findtext("apikey")
|
||||
options = root.find("options")
|
||||
for listen in options.findall("listenAddress"):
|
||||
options.remove(listen)
|
||||
ET.SubElement(options, "listenAddress").text = f"tcp://127.0.0.1:{listen_port}"
|
||||
for tag, value in (("globalAnnounceEnabled", "false"),
|
||||
("localAnnounceEnabled", "true"),
|
||||
("relaysEnabled", "false"),
|
||||
("natEnabled", "false"),
|
||||
("startBrowser", "false"),
|
||||
("urAccepted", "-1"),
|
||||
("crashReportingEnabled", "false"),
|
||||
("autoUpgradeIntervalH", "0")):
|
||||
_set(options, tag, value)
|
||||
tree.write(path)
|
||||
return api_key
|
||||
|
||||
|
||||
def _wait_up(instance: Instance, timeout: float = 30) -> str:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
response = requests.get(
|
||||
f"http://{instance.address}/rest/system/status",
|
||||
headers={"X-API-Key": instance.api_key}, timeout=2)
|
||||
if response.ok:
|
||||
return response.json()["myID"]
|
||||
except requests.RequestException:
|
||||
pass
|
||||
time.sleep(0.3)
|
||||
raise RuntimeError(f"{instance.name}'s Syncthing didn't come up")
|
||||
|
||||
|
||||
def start(out: Path, names=NAMES) -> list[Instance]:
|
||||
if shutil.which("syncthing") is None:
|
||||
raise RuntimeError("syncthing isn't installed")
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
instances = []
|
||||
for i, name in enumerate(names):
|
||||
home = out / name / "syncthing"
|
||||
if not (home / "config.xml").exists():
|
||||
subprocess.run(["syncthing", "generate", f"--home={home}",
|
||||
"--no-default-folder", "--skip-port-probing"],
|
||||
check=True, capture_output=True)
|
||||
gui_port, listen_port = BASE_GUI_PORT + i, BASE_LISTEN_PORT + i
|
||||
api_key = _configure(home, gui_port, listen_port)
|
||||
log = open(out / name / "syncthing.log", "ab")
|
||||
proc = subprocess.Popen(
|
||||
["syncthing", "serve", f"--home={home}", "--no-browser",
|
||||
"--no-restart", "--no-upgrade"],
|
||||
stdout=log, stderr=subprocess.STDOUT, start_new_session=True,
|
||||
env={**os.environ, "STNOUPGRADE": "1"})
|
||||
instances.append(Instance(name, str(home), gui_port, listen_port,
|
||||
api_key, pid=proc.pid))
|
||||
for instance in instances:
|
||||
instance.device_id = _wait_up(instance)
|
||||
(out / "pair.json").write_text(
|
||||
json.dumps([asdict(i) for i in instances], indent=2))
|
||||
return instances
|
||||
|
||||
|
||||
def stop(out: Path):
|
||||
path = out / "pair.json"
|
||||
if not path.exists():
|
||||
return
|
||||
for raw in json.loads(path.read_text()):
|
||||
try:
|
||||
os.killpg(raw["pid"], signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
path.unlink()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
parser.add_argument("--stop", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.stop:
|
||||
stop(args.out)
|
||||
return
|
||||
for instance in start(args.out):
|
||||
print(f"{instance.name}: GUI http://{instance.address} "
|
||||
f"key {instance.api_key} id {instance.device_id[:7]}")
|
||||
print(f"Stop with: {sys.argv[0]} --out {args.out} --stop")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user