Files
lintunes/scripts/cassette_pair.py
T
travandClaude Opus 5.5 f04e69f9b2 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>
2026-09-28 21:13:07 -07:00

163 lines
5.4 KiB
Python

#!/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()