Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## 2.1.0 - 2026-10-05

**Find out what actually works.**

- Anonymous reports, on by default and switched off with one checkbox in
Settings, plus a "did it work?" after each game's first share, so beta games
can be fixed and marked tested. Never names, world names, folders or saves.
- A small "Buy me a coffee" link at the bottom of every screen, and one
Windows notification after your fifth share. No pop-ups.
- Reports go to a small database that only accepts new rows; nothing can be
read back with the key inside the app.
- PRIVACY.md, automatic deletion after 12 months, and Settings → "Delete my
reports" to erase everything a PC has sent.
- Crash reports (error type and function only) and timing numbers, so problems
show up before anyone has to file an issue.

## 2.0.0 - 2026-10-05

**Dragonwilds Sync is now WorldSync.**
Expand Down
56 changes: 56 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Privacy

WorldSync is free and has no accounts. Your saves only ever go to the cloud
folder you pick. The one other thing it sends is **anonymous reports**, so I
can see which games work for real groups and fix the ones that don't.

## Turning reports off

Reports are on by default. Untick **Settings → Send anonymous reports** and
nothing more is sent. **Settings → Delete my reports** erases everything your
PC has sent so far and gives it a fresh random id.

## What a report contains

- A random id made on your PC the first time WorldSync starts. It isn't
your Steam id, your name or your PC's name, and it isn't linked to anything.
- The app version and the Windows version (for example "Windows 11").
- Which game, and what happened: a world was set up or joined, a share or
pull worked or failed and why (a short code like `pushed` or `wrong_game`),
the game started or didn't, a setup step was reached.
- Numbers: how long a session lasted in minutes, the world's size in MB and
number of files, how long a share or pull took, and how many different
players have shared that world.
- The kind of cloud drive the shared folder is in: Google Drive, Dropbox,
OneDrive or "other". Never the folder itself.
- Your system language code, for example `nl_NL`.
- Clicks on a few buttons: an invite code made, a checkpoint, a restore, a
setup guide opened, the coffee link, "missing a game".
- If you answer "Did it work?": thumbs up or down, and the comment you typed
yourself, if any.
- If the app crashes: the type of error (for example `KeyError`) and the
name of the function it happened in. Never the error message, because
messages can contain file paths.

The full list of allowed fields is in
[`app/core/telemetry.py`](app/core/telemetry.py). Anything else is dropped
before sending, and a test checks that names, world names and paths can't
get through.

## What is never sent

Your name or player name, friends' names, world names, file or folder paths,
invite codes, share links, save contents, or anything about your location.

## Where it goes and how long it stays

Reports go to a small database on Supabase (hosted in the EU, London region).
The key inside the app can only add reports; it can't read any back. Like most
web services, Supabase's servers see your IP address when a report arrives
and keep it briefly in their request logs; WorldSync does not store it. Reports
older than 12 months are deleted automatically every night.

Only the maintainer ([AndorManu](https://github.com/AndorManu)) can see the
results, and only as totals and anonymous lists. Nothing is sold or shared.

Questions: open an [issue](https://github.com/AndorManu/worldsync/issues).
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ in place on confirm. Worlds and settings are kept.

- **Test my setup** (Settings) - a checklist for folders, cloud sync and
game launch, so setup problems are obvious, not mysterious.
- **"Did it work?"** - after a game's first share, one click tells the
maintainer whether it worked for you (only while reports are on).
- **Checkpoints** (world menu → Backups) - name a snapshot before something
risky; restore it any time.
- **Group history** - the last few shared versions stay in the shared
Expand Down Expand Up @@ -211,9 +213,16 @@ originals are kept as `config.v1.bak` / `config.v2.bak`.

## Security notes, honestly

- **Nothing leaves your PC except into the shared folder you chose.** No
telemetry, no accounts, no calls to any server of mine (there isn't one).
Webhooks and Discord presence are off unless you turn them on.
- **Anonymous reports are on by default, and one checkbox in Settings turns
them off.** Apart from those, nothing leaves your PC except into the shared
folder you chose. A report says which game,
whether a share, pull or launch worked, the app version and Windows version,
under a random id made on your PC. Never your name, world names, folders,
invite codes or saves. It's how beta games get fixed and marked tested. The
exact list of fields is in [`app/core/telemetry.py`](app/core/telemetry.py),
and a test checks that nothing else gets through. Plain-language details,
retention (12 months) and how to erase your reports: [PRIVACY.md](PRIVACY.md). No accounts. Webhooks and
Discord presence are off unless you turn them on.
- **The shared folder is the trust boundary.** Anyone who can write to it can
change the world save, and - because updates travel through the same
folder - can publish an app update that everyone else's app will offer to
Expand Down
2 changes: 1 addition & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""WorldSync - take turns hosting one co-op world through a shared cloud folder."""

__version__ = "2.0.0"
__version__ = "2.1.0"
APP_NAME = "WorldSync"
105 changes: 104 additions & 1 deletion app/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from . import __version__
from .core import (backups, characters, chime, config, discordrp, game, games,
health, paths, presence, saga, statuspage, storage, sync,
update, webhook, worldhistory)
telemetry, update, webhook, worldhistory)
from .core.sync import MANIFEST_SCHEMA, SyncResult, world_files


Expand Down Expand Up @@ -62,6 +62,8 @@ class Controller(QObject):
nudge_received = Signal(str, str) # from_player, world_name
quit_for_update = Signal()
library_summary = Signal(object) # {game_id: {...}} for the library cards
feedback_prompt = Signal(str) # game id: ask "did it work?"
tip_prompt = Signal(int) # 5th share: one tip notification
# Worker threads must never touch widgets. Emitting a callable through
# this signal marshals it onto the GUI thread (queued connection).
run_on_ui = Signal(object)
Expand All @@ -79,10 +81,26 @@ def __init__(self, parent=None):
self._poll = QTimer(self)
self._poll.setInterval(STATUS_POLL_MS)
self._poll.timeout.connect(self._poll_tick)
if self.cfg and telemetry.ensure_id(self.cfg):
storage.save_config(self.cfg)
# first-run reports need an id before a config exists; it moves into the
# config when setup finishes
self._pre_cfg = {"telemetry": True}
new_install = False
if self.cfg and telemetry.ensure_id(self.cfg):
storage.save_config(self.cfg)
new_install = True
if not self.cfg:
telemetry.ensure_id(self._pre_cfg)
new_install = True
telemetry.set_context(self.cfg or self._pre_cfg, __version__)
if new_install:
self.report("first_run", locale=telemetry.locale_label())
if self.cfg:
self._prime_seen_versions()
self._poll.start()
self.check_updates()
self.report("app_start", locale=telemetry.locale_label())

# -- config & worlds --------------------------------------------------------
@property
Expand Down Expand Up @@ -120,7 +138,11 @@ def setup_first_config(self, player_name, local_save_dir, world):
base["worlds"] = config.worlds(base) + [world]
base["active_world"] = world["id"]
base["last_view"] = game_id
if not base.get("install_id") and self._pre_cfg.get("install_id"):
base["install_id"] = self._pre_cfg["install_id"]
telemetry.ensure_id(base)
self.cfg = base
telemetry.set_context(self.cfg, __version__)
self._save_all()
self._prime_seen_versions()
self._poll.start()
Expand Down Expand Up @@ -151,6 +173,70 @@ def remove_world(self, world_id):
self.worlds_changed.emit()
self.refresh_status()

# -- anonymous reports (opt-in) --------------------------------------------------
def report(self, event, game=None, **props):
try:
telemetry.send(self.cfg or self._pre_cfg, event, game, __version__, **props)
except Exception:
log.debug("report failed", exc_info=True)

def set_telemetry(self, on: bool):
if on:
telemetry.opt_in(self.cfg)
else:
telemetry.opt_out(self.cfg)
self._save_all()
if on:
self.report("app_start")

def forget_reports(self, done=None):
def worker():
ok = telemetry.forget(self.cfg)
self._save_all()
self.toast.emit("success" if ok else "warning",
"Your reports are erased and this PC has a fresh anonymous id."
if ok else "Couldn't reach the report server - try again when online.")
if done:
self.run_on_ui.emit(done)
threading.Thread(target=worker, daemon=True, name="forget").start()

def _push_extras(self, world, flat, duration_s) -> dict:
"""Size, players and cloud kind for a share report. Never names or paths."""
extras = {"cloud": telemetry.cloud_label(world.get("sync_dir", ""))}
try:
files = world_files(Path(flat["local_save_dir"]), flat["world_name"], flat["patterns"])
extras["files"] = len(files)
extras["size_mb"] = round(sum(f.stat().st_size for f in files) / 1048576, 1)
except OSError:
pass
try:
manifest = sync.get_shared_manifest(world["sync_dir"]) or {}
extras["group_size"] = len({h.get("editor") for h in manifest.get("history", [])
if h.get("editor")})
except Exception:
pass
if duration_s:
extras["duration_min"] = int(duration_s // 60)
return extras

def send_feedback(self, game_id, rating, comment):
self.report("feedback", game_id, rating=rating, comment=comment)
self.toast.emit("success", "Thanks - that goes straight into making it better.")

def _count_share(self, world):
"""After a successful share: "did it work?" once per game, and a single
tip notification at the fifth share."""
self.cfg["shares_count"] = self.cfg.get("shares_count", 0) + 1
gid = config.world_game(world).id
asked = self.cfg.setdefault("feedback_asked", [])
if telemetry.enabled(self.cfg) and gid not in asked:
asked.append(gid)
self.feedback_prompt.emit(gid)
if self.cfg["shares_count"] >= 5 and not self.cfg.get("tip_notified"):
self.cfg["tip_notified"] = True
self.tip_prompt.emit(self.cfg["shares_count"])
self._save_all()

# -- library ------------------------------------------------------------------
def active_game(self) -> games.GameProfile:
return config.world_game(self.active_world())
Expand Down Expand Up @@ -344,6 +430,7 @@ def worker():
def apply_update(self, info):
try:
if update.apply_update(info, paths.APP_DIR / "update"):
self.report("update_applied")
self.quit_for_update.emit()
else:
self.toast.emit("info", "Updates apply from the installed app - "
Expand Down Expand Up @@ -863,9 +950,12 @@ def _play_flow(self):
self.toast.emit("warning", "The shared folder is still downloading the "
"latest save - launching your current copy for now.")
else:
t0 = time.monotonic()
result, wstate = sync.do_pull(flat, wstate, self._log, self._confirm,
self._backup_root(world))
storage.save_state(self.state)
self.report("pull", profile.id, result=result.name.lower(),
sync_ms=int((time.monotonic() - t0) * 1000))
if result == SyncResult.PULLED:
self.toast.emit("success", "Latest save pulled in - you're starting "
"fresh off your friends' progress.")
Expand Down Expand Up @@ -915,11 +1005,13 @@ def _play_flow(self):
presence.stop_playing(sync_dir, self.player_name)
self._write_status(world)
self._set_phase("idle")
self.report("game_launch", profile.id, result="never_started")
self.toast.emit("warning", "Never saw the game start. If you are playing, "
"use “Save my progress now” when you're done.")
return

self._set_phase("ingame")
self.report("game_launch", profile.id, result="started")
try:
self._rp.set_playing(config.world_label(world),
recent.name if recent else "",
Expand All @@ -940,6 +1032,7 @@ def _play_flow(self):
self._do_push_guarded(world, flat, wstate, duration, played_char)
except Exception:
log.exception("Play flow failed")
self.report("error", config.world_game(world).id, kind="play_flow")
self.toast.emit("error", "Something went wrong during the session. Your save "
"is still on this PC - try “Save my progress now”.")
finally:
Expand All @@ -965,6 +1058,7 @@ def _push_flow(self):
self._do_push_guarded(world, self._flat_cfg(world), self._wstate(world), None)
except Exception:
log.exception("Manual push failed")
self.report("error", config.world_game(world).id, kind="push_flow")
self.toast.emit("error", "Couldn't share your save. Check that your cloud "
"folder is reachable, then try again.")
finally:
Expand All @@ -979,13 +1073,18 @@ def _do_push_guarded(self, world, flat, wstate, duration_s, played_char=None):
if world_files(save_dir, world_name, flat["patterns"]):
hp = health.check_local_save(save_dir, world_name, flat["patterns"])
if not hp.ok:
self.report("push", flat.get("game"), result="unhealthy_save")
self.toast.emit("warning", f"Your save looks {hp.reason}, so I didn't share "
f"it - that protects everyone from a bad file. "
f"Your friends keep the last good save.")
return
t0 = time.monotonic()
result, wstate = sync.do_push(flat, wstate, self._log, self._confirm,
self._backup_root(world))
storage.save_state(self.state)
extras = self._push_extras(world, flat, duration_s) if result == SyncResult.PUSHED else {}
self.report("push", flat.get("game"), result=result.name.lower(),
sync_ms=int((time.monotonic() - t0) * 1000), **extras)
self._after_push(world, result, duration_s, played_char)

def _after_push(self, world, result, duration_s, played_char=None):
Expand Down Expand Up @@ -1025,6 +1124,7 @@ def _after_push(self, world, result, duration_s, played_char=None):
"warning", "Save shared fine, but the webhook didn't go "
"through - check the URL in Settings."))
self.note_prompt.emit(world["id"], version)
self._count_share(world)
elif result == SyncResult.NOTHING_TO_PUSH:
self.toast.emit("warning", "No save files found for this world yet, so "
"there was nothing to share.")
Expand Down Expand Up @@ -1070,6 +1170,7 @@ def worker():
else:
presence.claim_next(world["sync_dir"], self.player_name,
self.cfg.get("player_emoji", ""))
self.report("turn", config.world_game(world).id, kind="claim")
self.toast.emit("success", "Next turn is yours - friends will "
"see it before they hit Play.")
self._write_status(world)
Expand Down Expand Up @@ -1100,6 +1201,7 @@ def worker():
try:
presence.send_nudge(world["sync_dir"], self.player_name, to_player,
self.cfg.get("player_emoji", ""))
self.report("turn", config.world_game(world).id, kind="pass")
self.toast.emit("success", f"Nudged {to_player} - they'll get a ping "
f"that it's their turn.")
if world.get("webhook_url"):
Expand All @@ -1126,6 +1228,7 @@ def worker():
except Exception:
log.exception("Checkpoint failed")
if info:
self.report("checkpoint", config.world_game(world).id)
self.toast.emit("success", f"Checkpoint “{info.name}” saved. Restore it "
f"any time from Backups.")
else:
Expand Down
5 changes: 5 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
"characters_dir": None, # None -> derived beside the save folder
"travel_characters": [], # character file stems that follow you across PCs
"play_chime": True,
"telemetry": True, # anonymous reports, on unless switched off in Settings
"install_id": None, # random id for reports, made on first start (core/telemetry.py)
"feedback_asked": [], # games we already asked "did it work?" about
"shares_count": 0, # successful shares on this PC, for the tip jar timing
"tip_notified": False, # the one "buy me a coffee" notification after 5 shares
"discord_app_id": "",
"active_world": None,
"worlds": [],
Expand Down
5 changes: 5 additions & 0 deletions app/core/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ def setup_logging():

def excepthook(exc_type, exc, tb):
root.critical("Unhandled exception", exc_info=(exc_type, exc, tb))
try:
from . import telemetry
telemetry.send_crash(exc_type, tb)
except Exception:
pass
sys.__excepthook__(exc_type, exc, tb)

sys.excepthook = excepthook
Expand Down
Loading
Loading