diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aedffe..119686e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..0d62dcb --- /dev/null +++ b/PRIVACY.md @@ -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). diff --git a/README.md b/README.md index 46537a5..b1307af 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/app/__init__.py b/app/__init__.py index e6ac9fc..03b0b30 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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" diff --git a/app/controller.py b/app/controller.py index e1512db..858e4ba 100644 --- a/app/controller.py +++ b/app/controller.py @@ -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 @@ -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) @@ -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 @@ -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() @@ -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()) @@ -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 - " @@ -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.") @@ -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 "", @@ -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: @@ -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: @@ -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): @@ -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.") @@ -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) @@ -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"): @@ -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: diff --git a/app/core/config.py b/app/core/config.py index 0330998..bd9cf5c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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": [], diff --git a/app/core/logs.py b/app/core/logs.py index 1bbffb5..65569a1 100644 --- a/app/core/logs.py +++ b/app/core/logs.py @@ -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 diff --git a/app/core/telemetry.py b/app/core/telemetry.py new file mode 100644 index 0000000..a11f6b8 --- /dev/null +++ b/app/core/telemetry.py @@ -0,0 +1,201 @@ +"""Anonymous reports: does WorldSync actually work for each game? + +On by default, switched off with one checkbox in Settings (and said so on the +welcome screen and in the README). When on, the app sends small events such as "valheim: shared OK" or "palworld: pull +failed" plus an optional thumbs up/down after a game's first share. That's +how beta games earn "tested" from real groups instead of guesses. + +What a report contains, and nothing else: + install_id a random id made on this PC (not your Steam id, + not your name, not tied to anything) + app_version, os (e.g. "Windows 11"), game id, event name + props only whitelisted keys below: a result code, a setup step, counts + and timings (session minutes, world size in MB, sync time), how + many players share the world, which kind of cloud drive (Google + Drive / Dropbox / OneDrive / other), the system language (e.g. + nl_NL), a rating, and for feedback a comment you typed yourself. + For a crash: the error's type and the function it happened in, + never the message (messages can contain file paths). + +Never sent: player names, world names, file paths, shared-folder names, +invite codes, IP-derived location or save contents. Reports are deleted +after 12 months, and Settings → "Delete my reports" erases this PC's rows. +See PRIVACY.md. Sending is fire-and-forget on a background +thread with a short timeout; a failure is silently dropped and can never +affect a sync. +""" + +import json +import locale +import logging +import platform +import threading +import urllib.request +import uuid + +log = logging.getLogger("dwsync.telemetry") + +# The reporting backend (Supabase). The key is the public "publishable" key: the +# database only lets it add rows to the events table, it can't read anything back. +ENDPOINT = "https://gqlcpgosdkwfgmiqcymy.supabase.co/rest/v1/events" +API_KEY = "sb_publishable_HBviDO2TiQkI4npN7ACZOQ_8b-iCBlF" +TIMEOUT_S = 4 + +EVENTS = { + "app_start", "first_run", "game_added", "world_created", "world_joined", "push", + "pull", "conflict", "game_launch", "feedback", "error", "crash", "onboarding", + "invite_created", "checkpoint", "restore", "guide_opened", "preflight", + "update_applied", "tip_clicked", "game_requested", "turn", +} +PROP_KEYS = {"result", "found_folder", "worlds_found", "rating", "comment", + "direction", "kind", "first_time", "step", "duration_min", "size_mb", + "files", "sync_ms", "group_size", "cloud", "locale", "worst", "where", + "source"} +FORGET_ENDPOINT = "https://gqlcpgosdkwfgmiqcymy.supabase.co/rest/v1/rpc/worldsync_forget" + +# set by the controller so a crash anywhere can still be reported +_CTX = {"cfg": None, "version": ""} +COMMENT_MAX = 500 + + +def available() -> bool: + return bool(ENDPOINT and API_KEY) + + +def enabled(cfg: dict | None) -> bool: + """On unless the player switched it off (None = never touched = on).""" + cfg = cfg or {} + return available() and cfg.get("telemetry") is not False and bool(cfg.get("install_id")) + + +def ensure_id(cfg: dict) -> bool: + """Give this PC its random id. Returns True when the config changed.""" + if cfg.get("install_id"): + return False + cfg["install_id"] = uuid.uuid4().hex + return True + + +def opt_in(cfg: dict): + cfg["telemetry"] = True + ensure_id(cfg) + + +def opt_out(cfg: dict): + cfg["telemetry"] = False + + +def set_context(cfg: dict | None, version: str): + _CTX["cfg"], _CTX["version"] = cfg, version + + +def locale_label() -> str: + try: + name = locale.getlocale()[0] or "" + except ValueError: + name = "" + return (name or "unknown")[:20] + + +def cloud_label(sync_dir: str) -> str: + """Which kind of cloud drive holds the shared folder - never the path.""" + try: + from . import clouds + low = str(sync_dir).lower() + for label, root in clouds.detect_cloud_roots(): + if low.startswith(str(root).lower()): + return label.lower() + except Exception: + pass + return "other" + + +def send_crash(exc_type, tb): + """Report an unhandled error by its type and the app function it hit.""" + where = "?" + while tb is not None: + code = tb.tb_frame.f_code + if "app" in code.co_filename.replace("\\", "/").split("/"): + where = code.co_name + tb = tb.tb_next + return send(_CTX["cfg"], "crash", None, _CTX["version"], + kind=getattr(exc_type, "__name__", "Exception"), where=where) + + +def forget(cfg: dict) -> bool: + """Erase every report this PC sent, then start over with a fresh random id.""" + install_id = cfg.get("install_id") + ok = True + if install_id and API_KEY: + try: + req = urllib.request.Request( + FORGET_ENDPOINT, data=json.dumps({"p_install_id": install_id}).encode("utf-8"), + method="POST", headers={"Content-Type": "application/json", "apikey": API_KEY, + "Authorization": f"Bearer {API_KEY}", + "User-Agent": "WorldSync"}) + urllib.request.urlopen(req, timeout=8).close() + except Exception as e: + log.warning("could not erase reports: %s", e) + ok = False + if ok: + cfg["install_id"] = uuid.uuid4().hex + return ok + + +def os_label() -> str: + if platform.system() != "Windows": + return platform.system() or "unknown" + try: + build = int(platform.version().split(".")[-1]) + except ValueError: + build = 0 + return "Windows 11" if build >= 22000 else f"Windows {platform.release()}" + + +def build_payload(cfg: dict, event: str, game: str | None, version: str, **props) -> dict: + """The exact JSON that leaves the PC. Unknown keys are dropped here.""" + clean = {} + for key, value in props.items(): + if key not in PROP_KEYS or value is None: + continue + if key == "comment": + value = str(value).strip()[:COMMENT_MAX] + if not value: + continue + elif isinstance(value, bool): + pass + elif isinstance(value, (int, float)): + value = round(value, 1) if isinstance(value, float) else value + else: + value = str(value)[:40] + clean[key] = value + return { + "install_id": cfg.get("install_id"), + "app_version": version, + "os": os_label(), + "game": game, + "event": event, + "props": clean, + } + + +def _post(payload: dict): + try: + req = urllib.request.Request( + ENDPOINT, data=json.dumps(payload).encode("utf-8"), method="POST", + headers={"Content-Type": "application/json", "apikey": API_KEY, + "Authorization": f"Bearer {API_KEY}", "Prefer": "return=minimal", + "User-Agent": "WorldSync"}) + urllib.request.urlopen(req, timeout=TIMEOUT_S).close() + except Exception as e: # never let reporting matter + log.debug("report not sent: %s", e) + + +def send(cfg: dict | None, event: str, game: str | None = None, version: str = "", + **props) -> dict | None: + """Queue one report unless reports are switched off. Returns the payload (for tests).""" + if event not in EVENTS or not enabled(cfg): + return None + payload = build_payload(cfg, event, game, version, **props) + threading.Thread(target=_post, args=(payload,), daemon=True, name="report").start() + return payload diff --git a/app/ui/about_page.py b/app/ui/about_page.py index a126bc6..6c927ac 100644 --- a/app/ui/about_page.py +++ b/app/ui/about_page.py @@ -11,6 +11,11 @@ from .library_page import TIP_URL CHANGELOG = [ + ("2.1.0", [ + "Anonymous reports so new games can be fixed and marked tested (Settings to switch off)", + "\"Did it work?\" after each game's first share", + "A small coffee link on every screen, no pop-ups", + ]), ("2.0.0", [ "Dragonwilds Sync is now WorldSync: one app for every co-op world", "Ten games to start: Dragonwilds, Valheim, Enshrouded, Palworld, Core Keeper, " @@ -68,6 +73,7 @@ class AboutPage(QWidget): back_requested = Signal() + tip_clicked = Signal(str) def __init__(self, parent=None): super().__init__(parent) @@ -108,7 +114,7 @@ def __init__(self, parent=None): root.addSpacing(10) tip = widgets.make_button("Buy me a coffee", "ghost", "heart", height=32) tip.setToolTip("WorldSync stays free. Tips keep new games coming.") - tip.clicked.connect(lambda: webbrowser.open(TIP_URL)) + tip.clicked.connect(lambda: self.tip_clicked.emit("about")) root.addWidget(tip, 0, Qt.AlignHCenter) root.addSpacing(16) diff --git a/app/ui/addgame_page.py b/app/ui/addgame_page.py index 2f8d149..4013ee2 100644 --- a/app/ui/addgame_page.py +++ b/app/ui/addgame_page.py @@ -103,6 +103,7 @@ class AddGamePage(QWidget): game_chosen = Signal(str) back_requested = Signal() installed_ready = Signal(object) + request_clicked = Signal() def __init__(self, parent=None): super().__init__(parent) @@ -160,7 +161,7 @@ def __init__(self, parent=None): foot = QHBoxLayout() foot.addStretch(1) req = widgets.make_button("Missing a game? Ask for it", "subtle", "external", height=28) - req.clicked.connect(lambda: webbrowser.open(REQUEST_URL)) + req.clicked.connect(lambda: (self.request_clicked.emit(), webbrowser.open(REQUEST_URL))) foot.addWidget(req) root.addLayout(foot) diff --git a/app/ui/invite_page.py b/app/ui/invite_page.py index e2d6325..f948e10 100644 --- a/app/ui/invite_page.py +++ b/app/ui/invite_page.py @@ -21,6 +21,7 @@ class InvitePage(QWidget): back_requested = Signal() share_link_saved = Signal(str, str) # world_id, link + code_generated = Signal(str) # game id def __init__(self, parent=None): super().__init__(parent) @@ -144,6 +145,7 @@ def _generate(self): game=self._world.get("game", "dragonwilds"), label=self._world.get("label")) self.code_box.setPlainText(code) + self.code_generated.emit(self._world.get("game", "dragonwilds")) self.code_box.setVisible(True) self.copy_btn.setVisible(True) self.outro.setVisible(True) diff --git a/app/ui/library_page.py b/app/ui/library_page.py index 31f1a66..ed7fcfa 100644 --- a/app/ui/library_page.py +++ b/app/ui/library_page.py @@ -177,6 +177,7 @@ class LibraryPage(QWidget): game_opened = Signal(str) add_game_clicked = Signal() join_clicked = Signal() + tip_clicked = Signal(str) def __init__(self, parent=None): super().__init__(parent) @@ -249,10 +250,10 @@ def __init__(self, parent=None): bb.addLayout(actions) footer = QHBoxLayout() - self.tip_btn = widgets.make_button("Support WorldSync", "subtle", "heart", height=26) + self.tip_btn = widgets.make_button("Buy me a coffee", "subtle", "heart", height=26) self.tip_btn.setToolTip("WorldSync is free. If it saved your group a server bill, " "a tip keeps it going.") - self.tip_btn.clicked.connect(lambda: webbrowser.open(TIP_URL)) + self.tip_btn.clicked.connect(lambda: self.tip_clicked.emit("library")) self.tip_btn.setVisible(bool(TIP_URL)) footer.addWidget(self.tip_btn) footer.addStretch(1) diff --git a/app/ui/main_screen.py b/app/ui/main_screen.py index 7416d72..1224bd1 100644 --- a/app/ui/main_screen.py +++ b/app/ui/main_screen.py @@ -190,6 +190,7 @@ class MainPage(QWidget): characters_clicked = Signal() saga_clicked = Signal() guide_clicked = Signal() + tip_clicked = Signal(str) library_clicked = Signal() def __init__(self, parent=None): @@ -312,11 +313,21 @@ def __init__(self, parent=None): self.conn_dot.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 9px;") self.conn_text = QLabel("") self.conn_text.setObjectName("FooterText") + from .library_page import TIP_URL + self.coffee_btn = QPushButton("☕ Buy me a coffee") + self.coffee_btn.setObjectName("CoffeeLink") + self.coffee_btn.setCursor(Qt.PointingHandCursor) + self.coffee_btn.setFlat(True) + self.coffee_btn.setToolTip("WorldSync is free. A coffee keeps new games coming.") + self.coffee_btn.setVisible(bool(TIP_URL)) + self.coffee_btn.clicked.connect(lambda: self.tip_clicked.emit("game_page")) version = QLabel(f"v{__version__}") version.setObjectName("FooterText") footer.addWidget(self.conn_dot) footer.addWidget(self.conn_text) footer.addStretch(1) + footer.addWidget(self.coffee_btn) + footer.addSpacing(6) footer.addWidget(version) body_box.addLayout(footer) @@ -342,6 +353,10 @@ def retheme(self): self.play_btn.setIcon(icons.icon("play", theme.ON_ACCENT, 20)) self.play_btn.retheme() self.feed_title.setText(theme.caps("Recent sessions")) + self.coffee_btn.setStyleSheet( + "QPushButton#CoffeeLink { background: transparent; border: none; padding: 0;" + f"color: {theme.TEXT_FAINT}; font-size: 11px; }}" + f"QPushButton#CoffeeLink:hover {{ color: {theme.EMBER}; }}") def set_game(self, name: str, has_characters: bool): self._game_name = name diff --git a/app/ui/onboarding.py b/app/ui/onboarding.py index e987e41..e5b6587 100644 --- a/app/ui/onboarding.py +++ b/app/ui/onboarding.py @@ -62,6 +62,7 @@ class OnboardingPage(QWidget): cancelled = Signal() # Back out of a game's flow name_chosen = Signal(str) # first run: the name step is done, pick a game guide_requested = Signal(str) # game id: open that game's setup guide + step_reached = Signal(str, str) # step, game id - for the anonymous setup funnel def __init__(self, parent=None): super().__init__(parent) @@ -250,7 +251,8 @@ def _welcome_step(self): btn.clicked.connect(lambda: self._go(STEP_NAME)) box.addWidget(btn, 0, Qt.AlignHCenter) - cap = QLabel("Free · No account · Your own cloud drive") + cap = QLabel("Free · No account · Your own cloud drive\n" + "Sends anonymous reports to help fix games - switch off in Settings") cap.setAlignment(Qt.AlignHCenter) cap.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 11px;") box.addSpacing(12) @@ -309,6 +311,7 @@ def _choice_step(self): return page def _back_from_choice(self): + self.step_reached.emit("abandoned_choice", self.profile.id) self.cancelled.emit() # -- step 3: create - world -------------------------------------------------------- @@ -346,6 +349,8 @@ def _world_step(self): def _rescan_worlds(self): word = self.profile.world_word names = self.world_field.refresh(self.save_dir_field.value(), profile=self.profile) + if not names and self.isVisible(): + self.step_reached.emit("no_worlds_found", self.profile.id) if names: self.world_field.note.setText( f"Found {len(names)} {word}{'s' if len(names) != 1 else ''}, most recently " @@ -371,6 +376,7 @@ def _submit_world(self): ok = False if ok: self.world_field.clear_error() + self.step_reached.emit("world_picked", self.profile.id) self._suggest_shared_names() self._go(STEP_CREATE_SHARED) @@ -479,6 +485,7 @@ def _submit_code(self): self._join_info = invite.decode(self.code_field.value()) except invite.InviteError as e: self.code_field.set_error(str(e)) + self.step_reached.emit("join_code_bad", "") return if self._join_info["game"] not in games.BY_ID: self.code_field.set_error("That invite is for a game this version of WorldSync " diff --git a/app/ui/report_overlay.py b/app/ui/report_overlay.py new file mode 100644 index 0000000..48eab75 --- /dev/null +++ b/app/ui/report_overlay.py @@ -0,0 +1,138 @@ +"""After a game's first successful share: "did it work?" + +A thumbs up or down and an optional line, only while anonymous reports are +on. That's the signal that moves a beta game to tested. +""" + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor, QPainter +from PySide6.QtWidgets import (QFrame, QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, + QWidget) + +from . import theme, widgets + + +class ReportOverlay(QWidget): + feedback_given = Signal(str, str, str) # game_id, "up"|"down", comment + + def __init__(self, parent): + super().__init__(parent) + self.hide() + self._mode = None + self._game_id = None + self._rating = None + parent.installEventFilter(self) + + outer = QVBoxLayout(self) + outer.setContentsMargins(32, 32, 32, 32) + outer.addStretch(1) + self.card = QFrame() + self.card.setObjectName("ReportCard") + box = QVBoxLayout(self.card) + box.setContentsMargins(24, 20, 24, 18) + box.setSpacing(10) + + self.title = QLabel("") + self.title.setWordWrap(True) + self.title.setStyleSheet("background: transparent; border: none;" + "font-size: 15px; font-weight: 650;") + box.addWidget(self.title) + self.body = QLabel("") + self.body.setWordWrap(True) + self.body.setStyleSheet(f"background: transparent; border: none;" + f"color: {theme.TEXT_DIM}; font-size: 12px;") + box.addWidget(self.body) + + # feedback: thumbs + optional comment + self.thumbs = QWidget() + self.thumbs.setStyleSheet("background: transparent;") + trow = QHBoxLayout(self.thumbs) + trow.setContentsMargins(0, 2, 0, 0) + trow.setSpacing(8) + self.up_btn = widgets.make_button("👍 It worked", "ghost", height=38) + self.down_btn = widgets.make_button("👎 Something's off", "ghost", height=38) + self.up_btn.clicked.connect(lambda: self._pick("up")) + self.down_btn.clicked.connect(lambda: self._pick("down")) + trow.addWidget(self.up_btn, 1) + trow.addWidget(self.down_btn, 1) + box.addWidget(self.thumbs) + self.comment = QLineEdit() + self.comment.setPlaceholderText("Anything that went wrong or felt confusing? (optional)") + self.comment.setMaxLength(500) + self.comment.setFixedHeight(38) + box.addWidget(self.comment) + + buttons = QHBoxLayout() + buttons.addStretch(1) + self.no_btn = widgets.make_button("Skip", "ghost", height=36) + self.yes_btn = widgets.make_button("Send", "primary", height=36) + self.no_btn.clicked.connect(lambda: self._finish(False)) + self.yes_btn.clicked.connect(lambda: self._finish(True)) + buttons.addWidget(self.no_btn) + buttons.addSpacing(8) + buttons.addWidget(self.yes_btn) + box.addLayout(buttons) + + outer.addWidget(self.card) + outer.addStretch(1) + + # -- plumbing ---------------------------------------------------------------- + def eventFilter(self, obj, e): + if obj is self.parent() and e.type() == e.Type.Resize: + self.setGeometry(self.parent().rect()) + return False + + def paintEvent(self, e): + QPainter(self).fillRect(self.rect(), QColor(5, 9, 13, 185)) + + def keyPressEvent(self, e): + if e.key() == Qt.Key_Escape: + self._finish(False) + else: + super().keyPressEvent(e) + + def _style(self): + self.card.setStyleSheet( + f"#ReportCard {{ background: {theme.SURFACE_2}; border: 1px solid {theme.BORDER};" + f"border-radius: 14px; }}") + + def _show(self): + self._style() + self.yes_btn.setMinimumWidth(self.yes_btn.fontMetrics().horizontalAdvance( + self.yes_btn.text()) + 64) + self.setGeometry(self.parent().rect()) + self.show() + self.raise_() + + # -- the two asks ------------------------------------------------------------ + def ask_feedback(self, game_id: str, game_name: str): + self._mode = "feedback" + self._game_id = game_id + self._rating = None + self.title.setText(f"Did WorldSync work for {game_name}?") + self.body.setText("Your first share went through. One click tells me whether " + "the whole thing worked the way it should.") + self.thumbs.show() + self.comment.clear() + self.comment.show() + for b in (self.up_btn, self.down_btn): + b.setStyleSheet("") + self.no_btn.setText("Skip") + self.yes_btn.setText("Send") + self.yes_btn.setEnabled(False) + self._show() + + def _pick(self, rating): + self._rating = rating + self.yes_btn.setEnabled(True) + for b, r in ((self.up_btn, "up"), (self.down_btn, "down")): + b.setStyleSheet( + f"QPushButton {{ border: 1.5px solid {theme.ACCENT}; color: {theme.ACCENT};" + f"background: {theme.SURFACE}; }}" if r == rating else "") + + def _finish(self, yes: bool): + mode = self._mode + self.hide() + self.yes_btn.setEnabled(True) + if mode == "feedback" and yes and self._rating: + self.feedback_given.emit(self._game_id, self._rating, self.comment.text().strip()) diff --git a/app/ui/settings.py b/app/ui/settings.py index e517e21..5b95217 100644 --- a/app/ui/settings.py +++ b/app/ui/settings.py @@ -16,6 +16,7 @@ class SettingsPage(QWidget): saved = Signal(dict, dict) # global fields, world fields (active world) cancelled = Signal() remove_world_requested = Signal(str) # world_id + forget_reports_requested = Signal() about_requested = Signal() preflight_requested = Signal() publish_update_requested = Signal() @@ -160,6 +161,25 @@ def __init__(self, parent=None): form.addWidget(self.statuspage_check) self.chime_check = QCheckBox("A soft chime when the wilds open") form.addWidget(self.chime_check) + self.reports_check = QCheckBox("Send anonymous reports to help fix games (on by default)") + self.reports_check.setToolTip( + "Which game, whether a share or pull worked, the app version. Never names, " + "world names, folders or saves.") + form.addWidget(self.reports_check) + reports_row = QHBoxLayout() + self.forget_btn_reports = widgets.make_button("Delete my reports", "subtle", "trash", + height=28) + self.forget_btn_reports.setToolTip("Erase everything this PC has sent and start over " + "with a new anonymous id.") + self.forget_btn_reports.clicked.connect(self.forget_reports_requested.emit) + reports_row.addWidget(self.forget_btn_reports) + privacy = QLabel('What is sent?') + privacy.setOpenExternalLinks(True) + privacy.setProperty("role", "hint") + reports_row.addWidget(privacy) + reports_row.addStretch(1) + form.addLayout(reports_row) self.discord_field = widgets.FormField( "Discord application ID (optional)", @@ -261,6 +281,10 @@ def load(self, cfg: dict, world: dict | None): self.startup_check.setChecked(autostart.is_enabled()) self.statuspage_check.setChecked(bool(cfg.get("publish_status_page", True))) self.chime_check.setChecked(bool(cfg.get("play_chime", True))) + from ..core import telemetry + self.reports_check.setVisible(telemetry.available()) + self.forget_btn_reports.setVisible(telemetry.available()) + self.reports_check.setChecked(cfg.get("telemetry") is not False) self.discord_field.edit.setText(cfg.get("discord_app_id") or "") self._world_id = world["id"] if world else None @@ -343,6 +367,7 @@ def _submit(self): "launch_on_startup": self.startup_check.isChecked(), "publish_status_page": self.statuspage_check.isChecked(), "play_chime": self.chime_check.isChecked(), + "_reports": self.reports_check.isChecked(), "discord_app_id": self.discord_field.value(), } world_fields = {} diff --git a/app/ui/tray.py b/app/ui/tray.py index 95640f0..6826064 100644 --- a/app/ui/tray.py +++ b/app/ui/tray.py @@ -12,11 +12,13 @@ class TrayManager(QObject): open_requested = Signal() quit_requested = Signal() + tip_clicked = Signal() def __init__(self, icon: QIcon, parent=None): super().__init__(parent) self.available = QSystemTrayIcon.isSystemTrayAvailable() self._tip_shown = False + self._last_message = None if not self.available: log.info("No system tray available") return @@ -33,39 +35,47 @@ def __init__(self, icon: QIcon, parent=None): self._menu = menu self.tray.setContextMenu(menu) self.tray.activated.connect(self._on_activated) - self.tray.messageClicked.connect(self.open_requested.emit) + self.tray.messageClicked.connect(self._on_message_clicked) self.tray.show() + def _on_message_clicked(self): + if self._last_message == "tip": + self.tip_clicked.emit() + else: + self.open_requested.emit() + + def _show(self, kind, title, body, ms): + self._last_message = kind + self.tray.showMessage(title, body, QSystemTrayIcon.Information, ms) + + def notify_tip(self, shares: int): + if self.available: + self._show("tip", "Enjoying WorldSync? ☕", + f"Your group has shared {shares} sessions without renting a server. " + f"WorldSync is free; click here if you'd like to buy me a coffee.", 10000) + def _on_activated(self, reason): if reason in (QSystemTrayIcon.Trigger, QSystemTrayIcon.DoubleClick): self.open_requested.emit() def notify_friend_push(self, world_name: str, editor: str, version: int): if self.available: - self.tray.showMessage( - f"Your turn in {world_name}?", - f"{editor} shared v{version} - the wilds await.", - QSystemTrayIcon.Information, 8000) + self._show("open", f"Your turn in {world_name}?", + f"{editor} shared v{version} - the newest save is waiting for you.", 8000) def notify_nudge(self, from_player: str, world_name: str): if self.available: - self.tray.showMessage( - f"It's your turn in {world_name}", - f"{from_player} passed you the world - jump in when you're ready.", - QSystemTrayIcon.Information, 8000) + self._show("open", f"It's your turn in {world_name}", + f"{from_player} passed you the world - jump in when you're ready.", 8000) def notify_update(self, version: str): if self.available: - self.tray.showMessage( - "Update available", - f"Version {version} is ready. Open WorldSync to update.", - QSystemTrayIcon.Information, 7000) + self._show("open", "Update available", + f"Version {version} is ready. Open WorldSync to update.", 7000) def show_minimized_tip(self): if self.available and not self._tip_shown: self._tip_shown = True - self.tray.showMessage( - "Still keeping watch", - "WorldSync lives in the tray now - you'll get a ping " - "when a friend shares a save. Right-click the icon to quit.", - QSystemTrayIcon.Information, 6000) + self._show("open", "Still keeping watch", + "WorldSync lives in the tray now - you'll get a ping " + "when a friend shares a save. Right-click the icon to quit.", 6000) diff --git a/app/ui/window.py b/app/ui/window.py index 47fa2c0..b9e6dde 100644 --- a/app/ui/window.py +++ b/app/ui/window.py @@ -25,6 +25,7 @@ from .onboarding import OnboardingPage from .overlay import ConfirmOverlay from .preflight_page import PreflightPage +from .report_overlay import ReportOverlay from .saga_page import SagaPage from .settings import SettingsPage from . import icons, theme @@ -102,6 +103,7 @@ def __init__(self, controller: Controller, app_icon: QIcon, parent=None): self.toasts = ToastHost(self.chrome) self.confirm = ConfirmOverlay(self.chrome) self.note_overlay = NoteOverlay(self.chrome) + self.report_overlay = ReportOverlay(self.chrome) self.tray = TrayManager(app_icon, self) @@ -150,6 +152,20 @@ def _wire(self): self.addgame_page.game_chosen.connect(self._on_game_chosen) self.addgame_page.back_requested.connect(self._addgame_back) c.library_summary.connect(self.library_page.set_summary) + + # opt-in reports, "did it work?", the tip jar + self.report_overlay.feedback_given.connect(c.send_feedback) + c.feedback_prompt.connect(lambda gid: self._after_note( + lambda: self.report_overlay.ask_feedback(gid, games.get(gid).short))) + c.tip_prompt.connect(self._notify_tip) + self.tray.tip_clicked.connect(lambda: self._open_tip("notification")) + for page in (self.main_page, self.library_page, self.about_page): + page.tip_clicked.connect(self._open_tip) + self.addgame_page.request_clicked.connect(lambda: c.report("game_requested")) + self.invite_page.code_generated.connect(lambda gid: c.report("invite_created", gid)) + self.onboarding_page.step_reached.connect( + lambda step, gid: c.report("onboarding", gid or None, step=step)) + self.settings_page.forget_reports_requested.connect(self._forget_reports) self.saga_page.back_requested.connect(lambda: self._show_page(self.main_page)) self.saga_page.export_requested.connect(c.export_saga) # (the grimoire deliberately has no menu entry - see _awaken_secret) @@ -269,7 +285,46 @@ def _open_game(self, game_id: str): self._show_page(self.main_page) c.refresh_status() + # -- reports & tips ------------------------------------------------------------- + def _after_note(self, fn, tries=60): + """Wait for the session-note card to close before showing another card.""" + from PySide6.QtCore import QTimer + if self.note_overlay.isVisible() or self.confirm.isVisible(): + if tries: + QTimer.singleShot(1000, lambda: self._after_note(fn, tries - 1)) + return + if self.isVisible() and not self.isMinimized(): + fn() + + def _notify_tip(self, shares): + """One Windows notification after the fifth share; nothing ever pops up in the app.""" + from .library_page import TIP_URL + if not TIP_URL: + return + if self.tray.available: + self.tray.notify_tip(shares) + else: + self.toasts.show_toast("info", f"{shares} sessions shared without a server. " + f"Enjoying WorldSync? There's a coffee link " + f"at the bottom of the screen.") + + def _open_tip(self, source="link"): + import webbrowser + from .library_page import TIP_URL + if TIP_URL: + self.controller.report("tip_clicked", source=source) + webbrowser.open(TIP_URL) + + def _forget_reports(self): + if self.confirm.ask( + "Delete your reports?", + "Everything this PC has sent is erased from the report server, and it " + "gets a fresh anonymous id. Reports stay on unless you untick the box.", + danger_label="Delete reports", safe_label="Keep them"): + self.controller.forget_reports() + def _open_guide(self, game_id: str): + self.controller.report("guide_opened", game_id) self._guide_return = self.pages.currentWidget() save_dir = config.game_save_dir(self.controller.cfg or {}, game_id) self.guide_page.load(games.get(game_id), save_dir) @@ -291,6 +346,7 @@ def _addgame_back(self): def _on_name_chosen(self, name): self._pending_name = name + self.controller.report("onboarding", step="name_done") self._open_add_game(first_run=True) def _player_for_flow(self) -> str: @@ -305,6 +361,7 @@ def _start_game_flow(self, game_id, back_to=None): self._show_page(self.onboarding_page) def _on_game_chosen(self, game_id): + self.controller.report("onboarding", game_id, step="game_picked") self._start_game_flow(game_id, back_to=self.addgame_page) def _join_from_library(self): @@ -406,6 +463,9 @@ def worker(): except Exception: log.exception("Preflight failed") checks = [] + if checks: + self.controller.report("preflight", config.world_game(world).id, + worst=preflight.worst(checks)) # widgets are rebuilt on the GUI thread, never from this worker self.controller.run_on_ui.emit( lambda: self.preflight_page.show_results(checks)) @@ -470,6 +530,12 @@ def _finish_onboarding(self, payload): self._first_run = False self.titlebar.settings_btn.show() self._open_game(game_id) + profile = games.get(game_id) + found = games.default_save_dir(profile) + c.report("world_created" if payload["kind"] == "create" else "world_joined", game_id, + found_folder=bool(found), + worlds_found=len(games.discover_worlds(profile, found)) if found else 0) + if payload["kind"] == "join": self.toasts.show_toast("success", f"Welcome to {shown}. Hit Play - " @@ -525,6 +591,9 @@ def _settings_back(self): def _save_settings(self, global_fields, world_fields): c = self.controller game_fields = global_fields.pop("_game", None) + reports = global_fields.pop("_reports", None) + if reports is not None and bool(reports) != (c.cfg.get("telemetry") is not False): + c.set_telemetry(bool(reports)) if game_fields: c.update_game(game_fields["id"], game_fields["save_dir"], game_fields["exe_path"]) c.update_globals(global_fields) @@ -778,6 +847,8 @@ def worker(): info.path, ctx["save_dir"], ctx["match"], ctx["root"], patterns=ctx.get("patterns"), mirror=ctx.get("mirror", False)) if count: + self.controller.report("restore", self.controller.active_game().id, + kind=ctx["kind"]) self.controller.toast.emit( "success", "Backup restored to this PC." + (" Share it with “Save my progress now” if the group " diff --git a/docs/screenshots/01_welcome.png b/docs/screenshots/01_welcome.png index 20bd73c..bf512d5 100644 Binary files a/docs/screenshots/01_welcome.png and b/docs/screenshots/01_welcome.png differ diff --git a/docs/screenshots/04_library.png b/docs/screenshots/04_library.png index 8930190..8d49062 100644 Binary files a/docs/screenshots/04_library.png and b/docs/screenshots/04_library.png differ diff --git a/docs/screenshots/05_every_game_its_own_look.png b/docs/screenshots/05_every_game_its_own_look.png index 5955a0c..f3470e1 100644 Binary files a/docs/screenshots/05_every_game_its_own_look.png and b/docs/screenshots/05_every_game_its_own_look.png differ diff --git a/docs/screenshots/06_friend_playing.png b/docs/screenshots/06_friend_playing.png index dc80884..f9631f0 100644 Binary files a/docs/screenshots/06_friend_playing.png and b/docs/screenshots/06_friend_playing.png differ diff --git a/docs/screenshots/07_in_game.png b/docs/screenshots/07_in_game.png index 1456c1b..3b0f3cc 100644 Binary files a/docs/screenshots/07_in_game.png and b/docs/screenshots/07_in_game.png differ diff --git a/docs/screenshots/09_conflict.png b/docs/screenshots/09_conflict.png index 3a9ebc1..34bbfa3 100644 Binary files a/docs/screenshots/09_conflict.png and b/docs/screenshots/09_conflict.png differ diff --git a/docs/screenshots/10_backups.png b/docs/screenshots/10_backups.png index a1a4b04..fffaa3d 100644 Binary files a/docs/screenshots/10_backups.png and b/docs/screenshots/10_backups.png differ diff --git a/docs/screenshots/11_about.png b/docs/screenshots/11_about.png index e08efc9..fd48953 100644 Binary files a/docs/screenshots/11_about.png and b/docs/screenshots/11_about.png differ diff --git a/installer.iss b/installer.iss index d3a5a2e..121d5ca 100644 --- a/installer.iss +++ b/installer.iss @@ -7,7 +7,7 @@ [Setup] AppName=WorldSync -AppVersion=2.0.0 +AppVersion=2.1.0 AppPublisher=AndorManu DefaultDirName={autopf}\WorldSync DefaultGroupName=WorldSync diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e7ca794 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +"""Shared test setup. + +Tests build the real app, and the real app knows the live reporting address. +This keeps every test (and CI) from sending reports: telemetry's endpoint is +blanked unless a test swaps in its own fake backend. +""" + +import pytest + +from app.core import telemetry + + +@pytest.fixture(autouse=True) +def no_live_reports(monkeypatch): + monkeypatch.setattr(telemetry, "ENDPOINT", "") + monkeypatch.setattr(telemetry, "API_KEY", "") diff --git a/tests/test_every_game.py b/tests/test_every_game.py index 5f47f70..7731f5e 100644 --- a/tests/test_every_game.py +++ b/tests/test_every_game.py @@ -188,7 +188,7 @@ def test_every_game_has_a_setup_guide(): for g in games.ALL: steps = games.setup_steps(g) assert len(steps) >= 3, f"{g.id} needs a real setup guide" - assert all("—" not in s for s in steps) + assert all(chr(0x2014) not in s for s in steps) # no em dashes assert len(games.COMMON_STEPS) >= 3 diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..56b5576 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,128 @@ +"""Anonymous reports: on by default, off with one setting, never anything personal.""" + +import json + +import pytest + +from app.core import telemetry + + +@pytest.fixture +def backend(monkeypatch): + sent = [] + monkeypatch.setattr(telemetry, "ENDPOINT", "https://example.invalid/rest/v1/events") + monkeypatch.setattr(telemetry, "API_KEY", "public-insert-only") + monkeypatch.setattr(telemetry, "_post", sent.append) + + class Sync: # run the "background" send inline so the test can see it + def __init__(self, target, args, **kw): + self.target, self.args = target, args + + def start(self): + self.target(*self.args) + monkeypatch.setattr(telemetry.threading, "Thread", Sync) + return sent + + +def test_on_by_default_once_the_pc_has_an_id(backend): + cfg = {} + assert telemetry.send(cfg, "push", "valheim", "2.1.0", result="pushed") is None # no id yet + assert telemetry.ensure_id(cfg) and not telemetry.ensure_id(cfg) + assert telemetry.send(cfg, "push", "valheim", "2.1.0", result="pushed") is not None + + +def test_nothing_is_sent_when_switched_off(backend): + assert telemetry.send({"telemetry": False, "install_id": "a" * 32}, "push") is None + assert backend == [] + + +def test_nothing_is_sent_without_a_backend(monkeypatch): + monkeypatch.setattr(telemetry, "ENDPOINT", "") + cfg = {} + telemetry.opt_in(cfg) + assert telemetry.send(cfg, "push") is None + + +def test_opt_in_makes_a_random_id_and_sends(backend): + cfg = {"player_name": "Andor"} + telemetry.opt_in(cfg) + assert len(cfg["install_id"]) == 32 + payload = telemetry.send(cfg, "push", "valheim", "2.0.0", result="pushed") + assert backend == [payload] + assert payload["game"] == "valheim" and payload["props"] == {"result": "pushed"} + + +def test_personal_data_never_gets_through(backend): + cfg = {"player_name": "Andor"} + telemetry.opt_in(cfg) + payload = telemetry.send(cfg, "push", "valheim", "2.0.0", result="pushed", + world_name="Midgard", sync_dir="C:/Users/andor/Drive", + player="Andor", path="C:/secret") + text = repr(payload) + for leak in ("Midgard", "andor", "Andor", "secret"): + assert leak not in text + assert set(payload) == {"install_id", "app_version", "os", "game", "event", "props"} + + +def test_unknown_events_are_dropped(backend): + cfg = {} + telemetry.opt_in(cfg) + assert telemetry.send(cfg, "keylogger") is None + + +def test_comment_is_trimmed(backend): + cfg = {} + telemetry.opt_in(cfg) + payload = telemetry.send(cfg, "feedback", "raft", "2.0.0", rating="down", + comment=" " + "x" * 900) + assert len(payload["props"]["comment"]) == telemetry.COMMENT_MAX + + +def test_opt_out_stops_sending(backend): + cfg = {} + telemetry.opt_in(cfg) + telemetry.opt_out(cfg) + assert telemetry.send(cfg, "push") is None + + +def test_crash_report_has_type_and_function_but_no_message(backend): + cfg = {} + telemetry.opt_in(cfg) + telemetry.set_context(cfg, "2.1.0") + + def load_world(): + raise KeyError("C:/Users/andor/secret/World.sav") + try: + load_world() + except KeyError as e: + payload = telemetry.send_crash(type(e), e.__traceback__) + assert payload["event"] == "crash" + assert payload["props"]["kind"] == "KeyError" + assert "secret" not in repr(payload) and "andor" not in repr(payload) + telemetry.set_context(None, "") + + +def test_forget_erases_and_gives_a_new_id(monkeypatch, backend): + calls = [] + + class Resp: + def close(self): + pass + monkeypatch.setattr(telemetry.urllib.request, "urlopen", + lambda req, timeout: calls.append(json.loads(req.data)) or Resp()) + cfg = {} + telemetry.opt_in(cfg) + old = cfg["install_id"] + assert telemetry.forget(cfg) + assert calls == [{"p_install_id": old}] + assert cfg["install_id"] != old and len(cfg["install_id"]) == 32 + + +def test_new_numbers_and_labels_pass_the_whitelist(backend): + cfg = {} + telemetry.opt_in(cfg) + p = telemetry.send(cfg, "push", "valheim", "2.1.0", result="pushed", sync_ms=812, + size_mb=12.345, files=3, group_size=4, duration_min=95, + cloud="google drive", world_name="Midgard") + assert p["props"] == {"result": "pushed", "sync_ms": 812, "size_mb": 12.3, "files": 3, + "group_size": 4, "duration_min": 95, "cloud": "google drive"} diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index 5cae476..60dd832 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -137,3 +137,57 @@ def test_every_theme_applies_and_every_scene_paints(qapp): pm = scenes.render(gamethemes.get(tid).scene, 160, 60, theme.ACCENT, theme.EMBER) assert not pm.isNull() theme.apply_game("dragonwilds") + + +def test_coffee_notification_fires_once_at_the_fifth_share(qapp, sandbox): + _, data = sandbox + data.mkdir(parents=True) + (data / "config.json").write_text(json.dumps({ + "schema": 3, "player_name": "Andor", "library": ["raft"], "last_view": "raft", + "telemetry": False, + "worlds": [{"id": "w_1", "game": "raft", "world_name": "Sea", "sync_dir": "C:/none"}], + "active_world": "w_1", + })) + c, win = make_window() + tips, asks = [], [] + c.tip_prompt.connect(tips.append) + c.feedback_prompt.connect(asks.append) + for _ in range(8): + c._count_share(c.active_world()) + assert tips == [5] + assert asks == [] # reports off: no "did it work?" either + assert win.main_page.coffee_btn.isVisibleTo(win.main_page) + win.close() + + +def test_reports_on_by_default_with_a_random_id(qapp, sandbox): + _, data = sandbox + data.mkdir(parents=True) + (data / "config.json").write_text(json.dumps({ + "schema": 2, "player_name": "Andor", + "worlds": [{"id": "w_1", "world_name": "Ash", "sync_dir": "C:/none"}], + "active_world": "w_1", + })) + c, win = make_window() + assert c.cfg["telemetry"] is True and len(c.cfg["install_id"]) == 32 + assert not win.report_overlay.isVisible() # nothing pops up + win.close() + + +def test_first_run_reports_keep_one_id_after_setup(qapp, sandbox): + home, _ = sandbox + vh = home / "AppData" / "LocalLow" / "IronGate" / "Valheim" / "worlds_local" + vh.mkdir(parents=True) + (vh / "Midgard.fwl").write_bytes(b"x" * 100) + (vh / "Midgard.db").write_bytes(b"x" * 5000) + c, win = make_window() + pre_id = c._pre_cfg["install_id"] + win.onboarding_page.name_field.edit.setText("Andor") + win.onboarding_page._submit_name() + win.addgame_page.game_chosen.emit("valheim") + ob = win.onboarding_page + ob._submit_world() + ob.shared_field.edit.setText(str(home / "Drive" / "Valheim - Midgard")) + ob._submit_create() + assert c.cfg["install_id"] == pre_id + win.close() diff --git a/tools/screenshots.py b/tools/screenshots.py index 5c669a4..0ae1839 100644 --- a/tools/screenshots.py +++ b/tools/screenshots.py @@ -39,7 +39,9 @@ from PySide6.QtTest import QTest # noqa: E402 from PySide6.QtWidgets import QApplication, QWidget # noqa: E402 -from app.core import config, games, presence, steam, storage # noqa: E402 +from app.core import config, games, presence, steam, storage, telemetry # noqa: E402 + +telemetry.ENDPOINT = "" # screenshots must never send reports from app.core.logs import setup_logging # noqa: E402 from app.core.sync import _backup_files # noqa: E402 from app.controller import Controller # noqa: E402