diff --git a/app/main.js b/app/main.js index e06812d..3f9dcbc 100644 --- a/app/main.js +++ b/app/main.js @@ -1,7 +1,7 @@ // Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on // a free loopback port and shows it in a native window. The server does all the // work over the `frame` SSH alias; this file only hosts it. -const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron"); +const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron"); const { execFile, spawn } = require("child_process"); const { promisify } = require("util"); const fs = require("fs"); @@ -275,6 +275,14 @@ function fromUi(e) { // The error page's Try Again button (there is no server page while it shows). ipcMain.handle("server:restart", (e) => { if (win && e.sender === win.webContents && !url) restartServer(); }); ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); +// A PNG or JPEG (a screenshot) onto the clipboard as an image. +ipcMain.handle("clipboard:writeImage", (e, bytes) => { + if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false; + const img = nativeImage.createFromBuffer(Buffer.from(bytes)); + if (img.isEmpty()) throw new Error("not an image"); + clipboard.writeImage(img); + return true; +}); ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); }); ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); }); ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null); diff --git a/app/preload.js b/app/preload.js index 05d3a6d..6dcec17 100644 --- a/app/preload.js +++ b/app/preload.js @@ -2,8 +2,8 @@ // to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells // the page where a dropped file or folder lives, so a folder can be sideloaded // as a title without zipping it (the local server reads it from there). -// It can open Set Up Connection when the headset can't be reached, and keeps the -// Frame menu's list of headsets up to date. +// It can put a screenshot on the clipboard as an image, open Set Up Connection when +// the headset can't be reached, and keeps the Frame menu's list of headsets up to date. // It also receives frame-control://install links (docs/web-install.md): only // what the link asked for, never an install; the page asks the user first. // And it passes update state both ways: see app/updater.js. @@ -12,6 +12,7 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron"); contextBridge.exposeInMainWorld("frameApp", { notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request), readClipboard: () => ipcRenderer.invoke("clipboard:read"), + writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes), setUpConnection: () => ipcRenderer.invoke("connection:setup"), restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again // The Frame menu's headset switcher: the page tells it the headsets, and hears picks. diff --git a/docs/privacy.md b/docs/privacy.md index db7004f..1ec1755 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -103,8 +103,10 @@ privately to Frame Control's PostHog project as a `problem_report` event, the same way as the analytics above, so only the maintainer can read it and nothing is published. It works whatever the analytics settings are, because the person sends it deliberately. The report has the kind, title and text you -wrote, how to reach you if you gave it, a short reference shown after sending, -and the diagnostics below. It has its own random id, so it isn't linked to +wrote, a short reference shown after sending, and the diagnostics below. Your +email address goes with it only if you tick **The maintainer may contact me +with follow-up questions** (the report then carries `contact_followup: true`); +it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to your analytics events. With **Include diagnostics** ticked (the default), the report adds: @@ -128,11 +130,60 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or with `python3 ui/frame_report.py inbox [days]`, which uses the same personal API key as `frame_compat_db.py sync`. +## Contact email (optional) + +Frame Control never needs an email address. If you'd like to leave one, there +are two separate choices, both off until you tick them: + +| Choice | What it's for | +|---|---| +| **Email me about Frame Control updates** | Occasional notices about new releases and updates | +| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly | + +You're asked once, in a bar at the top of the page, after the Frame has +connected for the first time, and never while or straight after the +first-run privacy notice is showing. **No thanks** hides it for good, and it isn't +shown again even if you ignore it. **Contact email** in **Privacy & updates** +is where you add, change or remove the address and either choice at any time. + +**What's sent, and where.** The address and the two choices go privately to +Frame Control's PostHog project, the same place as problem reports, as a +`contact_consent` event with `email`, `updates`, `followup`, `action` (`set` +or `withdraw`) and the common properties above. Only the maintainer can read +that project, and nothing in it is published or shared. It's sent only when +you save, whatever the analytics settings are, because you chose to. It +carries its own random contact id, not the analytics id, so it isn't linked +to your usage events, and a `rev` number that goes up with each change, so +the newest choice always wins. Like everything else sent, it's listed under +**Show what's been sent**. On this computer the address and choices are kept in +`contact/contact.json` in Frame Control's data folder. An address is only +kept with at least one choice ticked. + +**Removing it.** **Remove my email** (or clearing the address and saving) +deletes it from this computer, including from the **Show what's been sent** +log (in earlier contact events and problem reports), and sends a `withdraw` +event with no address in it. The maintainer's list only uses the newest event from each copy, so from +then on the address isn't listed for either choice. Unticking one choice +works the same way for that choice. If you're offline, the change waits on +this computer and is sent when PostHog can be reached. The earlier event +stays in PostHog until its data retention removes it; to have it deleted +sooner, ask the maintainer (for example in a problem report). + +Nothing sends email yet: this only records who agreed to what. The +maintainer lists the addresses with +`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same +personal API key as `inbox`. + ## Turning it all off Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in the environment that starts Frame Control. A copy run from a source checkout -never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set. +never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set. + +These switches cover the analytics above. A problem report or a contact email +is sent only because you pressed its Send or Save button, so those still go +when you choose to send them (a contact change saved while offline is sent +by itself once PostHog can be reached); if you don't, nothing is sent. ## Update checks diff --git a/tests/test_contact.py b/tests/test_contact.py new file mode 100644 index 0000000..8c2234a --- /dev/null +++ b/tests/test_contact.py @@ -0,0 +1,280 @@ +"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately, +withdrawn when removed, never lost offline, and the one-time prompt stays dismissed. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import sys +import threading +import time +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import frame_compat_db as db # noqa: E402 +import frame_contact as fc # noqa: E402 +import frame_report as fr # noqa: E402 +import frame_telemetry as tm # noqa: E402 +from test_telemetry import Base, ReportProblem # noqa: E402 + +REPORT = {"title": "RDP not working", "message": "It never connects on Windows."} + + +class Contact(Base): + """Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file.""" + serve = ReportProblem.serve + + def setUp(self): + super().setUp() + self.addCleanup(fc._removed.clear) + for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")): + p = mock.patch.object(fc, name, value) + p.start() + self.addCleanup(p.stop) + self.got = self.serve() + + def events(self): + return [body["batch"][0] for _, body in self.got] + + def offline(self): + return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog")) + + # ---- storage and consent flags + + def test_nothing_is_kept_or_sent_until_chosen(self): + s = fc.state() + self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False)) + self.assertFalse(fc.FILE.exists()) + self.assertEqual(self.got, []) + + def test_an_address_needs_a_choice_and_a_real_address(self): + with self.assertRaisesRegex(ValueError, "tick"): + fc.save({"email": "me@example.com"}) + with self.assertRaisesRegex(ValueError, "email address"): + fc.save({"email": "not an address", "updates": True}) + self.assertEqual(fc.load()["email"], "") + self.assertEqual(self.got, []) + + def test_each_choice_is_sent_privately_on_its_own(self): + fc.save({"email": " me@example.com ", "updates": True}) + fc.save({"email": "me@example.com", "updates": False, "followup": True}) + first, second = self.events() + self.assertEqual(first["event"], "contact_consent") + self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")}, + {"email": "me@example.com", "updates": True, "followup": False, "action": "set"}) + self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True)) + self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins + self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id + self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]), + (False, True)) + self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2) + + def test_sent_whatever_the_analytics_settings(self): + tm.update_settings({"usage": False}) + fc.save({"email": "me@example.com", "followup": True}) + self.assertEqual(len(self.got), 1) + + def test_saving_the_same_choice_again_sends_nothing(self): + fc.save({"email": "me@example.com", "updates": True}) + fc.save({"email": "me@example.com", "updates": True}) + self.assertEqual(len(self.got), 1) + + # ---- withdrawal + + def test_removing_the_address_sends_a_withdrawal_without_it(self): + fc.save({"email": "me@example.com", "updates": True, "followup": True}) + s = fc.save({"email": "", "updates": True, "followup": True}) + self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False)) + withdrawal = self.events()[-1]["properties"] + self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]), + ("withdraw", "", False, False)) + self.assertNotIn("me@example.com", fc.FILE.read_text()) + + def test_an_address_still_waiting_is_withdrawn_too(self): + with self.offline(): + fc.save({"email": "me@example.com", "updates": True}) # may already be on its way + with mock.patch.object(tm, "post") as post: + fc.save({"email": ""}) + self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"]) + self.assertFalse(fc.state()["waiting"]) + + def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self): + fc.save({"email": "me@example.com", "updates": True}) + with self.offline(): + s = fc.save({"email": ""}) + self.assertTrue(s["waiting"]) + self.assertFalse(fc._send_pending()) + self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw") + self.assertTrue(fc._send_pending()) + self.assertFalse(fc.state()["waiting"]) + self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"]) + + def test_removing_the_address_wipes_it_from_the_sent_log_too(self): + fc.save({"email": "me@example.com", "followup": True}) + fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True}) + self.assertIn("me@example.com", tm.SENT.read_text()) + fc.save({"email": ""}) + self.assertNotIn("me@example.com", tm.SENT.read_text()) + self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT) + if e["event"] == "contact_consent"], ["set", "withdraw"]) + + def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self): + fc.save({"email": "me@example.com", "updates": True}) + fc.save({"email": "new@example.com", "updates": True}) + fc.save({"email": ""}) + self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3]) + + def test_a_withdrawal_during_a_send_goes_after_it(self): + started, release, order = threading.Event(), threading.Event(), [] + real = tm.post + + def slow(batch, timeout=20): + order.append(batch[0]["properties"]["action"]) + if len(order) == 1: + started.set() + release.wait(5) + real(batch, timeout) + + with mock.patch.object(tm, "post", side_effect=slow): + t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},)) + t.start() + self.assertTrue(started.wait(5)) + w = threading.Thread(target=fc.save, args=({"email": ""},)) + w.start() + for _ in range(500): # the withdrawal is saved while the first send is still out + if fc.load()["rev"] == 2: + break + time.sleep(0.01) + self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw") + release.set() + t.join(5) + w.join(5) + self.assertEqual(order, ["set", "withdraw"]) + self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"]) + self.assertFalse(fc.state()["waiting"]) + self.assertNotIn("me@example.com", tm.SENT.read_text()) + + def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self): + fc.save({"email": "me@example.com", "followup": True}) + real = tm.post + + def remove_meanwhile(batch, timeout=20): + real(batch, timeout) + fc.save({"email": ""}) # removed while the report is on its way, before it's logged + + with mock.patch.object(tm, "post", side_effect=remove_meanwhile): + fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True}) + self.assertNotIn("me@example.com", tm.SENT.read_text()) + fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True}) + self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent + + def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self): + fc._removed["me@example.com"] = 1790000000.3 + event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}} + before, after = event(), event() # the same whole second as the removal + fc.redact_removed(before, 1790000000.1) + fc.redact_removed(after, 1790000000.6) + self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]), + ("", "me@example.com")) + + def test_saving_during_a_slow_send_returns_at_once(self): + busy = fc._send_lock + busy.acquire() + try: + s = fc.save({"email": "me@example.com", "updates": True}) + finally: + busy.release() + self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take + self.assertEqual(self.got, []) + self.assertTrue(fc._send_pending()) + self.assertEqual(len(self.got), 1) + + def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self): + real = fc._send_lock + + class Lock: # a Save lands after the sender found nothing waiting, before it lets go + saved = False + + def acquire(self, blocking=True): + return real.acquire(blocking) + + def release(self): + if not Lock.saved: + Lock.saved = True + s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},)) + s.start() + s.join(5) + assert not s.is_alive() # the change is saved while the sender still holds the lock + real.release() + + with mock.patch.object(fc, "_send_lock", Lock()): + self.assertTrue(fc._send_pending()) + self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"]) + self.assertFalse(fc.state()["waiting"]) + + # ---- the one-time prompt + + def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self): + self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet + tm.frame_seen("20260901.1", "3.8") + self.assertTrue(fc.state()["showPrompt"]) + fc.prompt({"prompt": "dismissed"}) + fc.prompt({"prompt": "shown"}) # a later session can't bring it back + self.assertEqual(fc.load()["prompt"], "dismissed") + self.assertFalse(fc.state()["showPrompt"]) + self.assertEqual(self.got, []) # No thanks sends nothing + with self.assertRaises(ValueError): + fc.prompt({"prompt": "reset"}) + + def test_the_prompt_is_shown_once_and_saving_answers_it(self): + tm.frame_seen("20260901.1", "3.8") + fc.prompt({"prompt": "shown"}) + self.assertFalse(fc.state()["showPrompt"]) + fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True}) + self.assertEqual(fc.load()["prompt"], "answered") + + # ---- reports and the maintainer's list + + def test_a_report_carries_the_address_only_with_follow_up_consent(self): + fr.send({**REPORT, "contact": "me@example.com"}) + fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True}) + without, with_ = (e["properties"] for e in self.events()) + self.assertEqual((without["contact"], without["contact_followup"]), ("", False)) + self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True)) + with self.assertRaisesRegex(ValueError, "email address"): + fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True}) + + def test_contacts_lists_the_newest_choice_per_copy_by_consent(self): + rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"], + ["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"], + ["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn + ["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]] + with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q: + found = fr.contacts() + self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))", + q.call_args.args[0]) + self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")], + "followup": [("both@example.com", "2026-09-01")]}) + with mock.patch.object(fr, "contacts", return_value=found), \ + mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \ + mock.patch("builtins.print") as out: + fr.main() + printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args) + self.assertIn("both@example.com", printed) + self.assertNotIn("news@example.com", printed) + + def test_the_page_can_reach_it(self): + import server + self.assertIs(server.POST["/api/contact"], fc.save) + self.assertIs(server.POST["/api/contact/prompt"], fc.prompt) + + +# Run these once, in test_telemetry, not again through the import above. +del Base, ReportProblem + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py index 93087b4..b82034c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -111,6 +111,8 @@ class ServerGuards(unittest.TestCase): ("/api/volume", {"level": 1.5}), ("/api/clipboard", {"text": ""}), ("/api/open", {"what": "anything-else"}), + ("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}), + ("/api/open", {"what": "shot"}), ("/api/shots/save", {"ids": []}), ("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}), ("/api/shots/save", {"ids": [1]}), @@ -121,6 +123,10 @@ class ServerGuards(unittest.TestCase): status, payload = self.post(path, body) self.assertEqual(status, 400, f"{path} {body} -> {payload}") + def test_showing_a_shot_needs_it_saved_here(self): + status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"}) + self.assertEqual(status, 404, payload) + def test_screenshot_ids_checked_before_ssh(self): for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"): status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"}) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 4b400c6..71c9f7e 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -392,13 +392,14 @@ class ReportProblem(Base): got = self.serve() tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.", - "contact": "me@example.com"}) + "contact": "me@example.com", "contactFollowup": True}) path, body = got[0] event = body["batch"][0] self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report")) props = event["properties"] self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]), ("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"])) + self.assertIs(props["contact_followup"], True) self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True)) self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics self.assertIn(res["id"], res["message"]) @@ -421,8 +422,9 @@ class ReportProblem(Base): def test_the_inbox_skips_malformed_reports(self): good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None, - "0.4.0", "macOS", "", ""] - rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good] + "0.4.0", "macOS", "", "", None] + rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None], + ["short"], good] with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \ mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \ mock.patch("builtins.print") as out: diff --git a/ui/frame_contact.py b/ui/frame_contact.py new file mode 100644 index 0000000..c79e2c1 --- /dev/null +++ b/ui/frame_contact.py @@ -0,0 +1,218 @@ +"""An email address the person chooses to leave, and what it may be used for. Python stdlib only. + +Two separate opt-in choices, both off until ticked: + +- updates: occasional notices about Frame Control releases and updates +- followup: the maintainer may ask follow-up questions, mainly about problem reports + +The address and the choices are kept on this computer (frame_host.data_dir('contact')) and +sent privately to Frame Control's PostHog project as a `contact_consent` event, the same way +as problem reports (frame_report.py), so only the maintainer can read them. Every change +sends a new event under this copy's own random contact id (not the analytics id), numbered +by `rev`, and the highest rev for an id is the one that counts, whatever the clocks say: +removing the address sends a withdrawal with no address in it, and wipes the address from +the local log of what was sent. The maintainer lists who agreed to what with +`python3 ui/frame_report.py contacts`. Nothing here sends email. + +A change that can't be sent (offline) waits in the state file and is retried in the +background, so a withdrawal is never lost. The page's one-time prompt is remembered here +too: once it has been shown or dismissed it never comes back. +""" +import json +import os +import re +import threading +import time +import uuid + +import frame_host +import frame_telemetry + +STATE = frame_host.data_dir('contact') +FILE = STATE / 'contact.json' +EMAIL_MAX = 254 +EMAIL_RE = re.compile(r'[^@\s]+@[^@\s]+\.[^@\s.]+') +PROMPTS = ('new', 'shown', 'dismissed', 'answered') +RETRY_EVERY = 600 + +_lock = threading.RLock() +_send_lock = threading.Lock() # one send at a time, so events reach PostHog in rev order +_removed = {} # address (lower case) -> when it was removed, for reports still being sent then +_wake = threading.Event() +_retrier = None + + +def _defaults(): + return {'id': str(uuid.uuid4()), 'email': '', 'updates': False, 'followup': False, + 'prompt': 'new', 'pending': None, 'rev': 0} + + +def load(): + with _lock: + s = _defaults() + try: + with open(FILE) as f: + saved = json.load(f) + if isinstance(saved, dict): + s.update({k: v for k, v in saved.items() if k in s}) + except (OSError, ValueError): + pass + return s + + +def _save(s): + STATE.mkdir(parents=True, exist_ok=True) + tmp = FILE.with_suffix('.tmp') + tmp.write_text(json.dumps(s, indent=1)) + os.replace(tmp, FILE) + + +def valid_email(email): + return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email)) + + +def state(): + """What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet, + and the Frame has connected at least once (setup worked), so it never greets a new install.""" + s = load() + set_up = bool(frame_telemetry.settings().get('frames_seen')) + return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'], + 'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up} + + +def _event(s): + email = s['email'] if s['updates'] or s['followup'] else '' + return {'event': 'contact_consent', 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()), + 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + 'properties': {**frame_telemetry.common(), 'email': email, 'updates': bool(email and s['updates']), + 'followup': bool(email and s['followup']), + 'action': 'set' if email else 'withdraw', 'rev': s['rev'], 'level': 'contact'}} + + +def _send_pending(block=True): + """Send what's waiting, including changes made while sending. True if nothing is left + waiting. Without block, a send already under way is left to pick up the newest change.""" + if not _send_lock.acquire(blocking=block): + return False + try: + while True: + with _lock: + event = load()['pending'] + if event is None: + break + try: + frame_telemetry.post([event], timeout=30) + except frame_telemetry.SendError: + return False + _sent(event) + finally: + _send_lock.release() + # A change saved just as this finished found the lock still held and left it to us. + with _lock: + left = load()['pending'] is not None + return _send_pending(block=False) if left else True + + +def _sent(event): + with _lock: + s = load() + if s['pending'] and s['pending'].get('uuid') == event['uuid']: # not replaced meanwhile + s['pending'] = None + _save(s) + # A withdrawal, or the address still in use: not an old one removed while this was on its way. + if event['properties']['email'] in ('', s['email']): + try: + frame_telemetry.record_sent([event]) + except OSError: + pass + + +def _forget_locally(email): + """Take a removed address out of the log of what was sent (contact events and reports).""" + with frame_telemetry._lock: + _removed[email.lower()] = time.time() + rows = frame_telemetry._read_lines(frame_telemetry.SENT) + hit = False + for e in rows: + p = e.get('properties') or {} + for k in ('email', 'contact'): + if p.get(k) and str(p[k]).strip().lower() == email.lower(): + p[k], hit = '', True + if hit: + frame_telemetry._write_lines(frame_telemetry.SENT, rows) + + +def redact_removed(event, started): + """Before logging a report (started at time.time() `started`) whose address was removed + while it was being sent: take the address out. Call with frame_telemetry._lock held, so a + removal can't slip between this and the log.""" + p = event.get('properties') or {} + removed_at = _removed.get(str(p.get('contact') or '').strip().lower()) + if removed_at is not None and started <= removed_at: + p['contact'] = '' + + +def save(body): + """Set, change or remove the address and the two choices. An address needs at least one + choice ticked; an empty address (or neither ticked) removes it and withdraws both.""" + email = str(body.get('email') or '').strip() + updates, followup = bool(body.get('updates')), bool(body.get('followup')) + if email and not valid_email(email): + raise ValueError("that doesn't look like an email address") + if email and not (updates or followup): + raise ValueError('tick what the address may be used for, or remove it') + if not email: + updates = followup = False + with _lock: + s = load() + old = s['email'] + changed = (email, updates, followup) != (s['email'], s['updates'], s['followup']) + s.update(email=email, updates=updates, followup=followup) + if body.get('fromPrompt') or email: + s['prompt'] = 'answered' + if changed: + # Only the newest choice matters, so it replaces anything still waiting. A withdrawal + # is sent even for an address still waiting here: its send may already be under way. + s['rev'] += 1 + s['pending'] = _event(s) + _save(s) + if old and old.lower() != email.lower(): + try: + _forget_locally(old) + except OSError: + pass + if changed and not _send_pending(block=False): + _wake.set() # offline, or a send under way that will take this change with it + return state() + + +def prompt(body): + """The one-time prompt was shown, or dismissed with No thanks. Either way it stays gone.""" + action = body.get('prompt') + if action not in ('shown', 'dismissed'): + raise ValueError('unknown prompt action') + with _lock: + s = load() + if s['prompt'] in ('new', 'shown'): + s['prompt'] = action + _save(s) + return state() + + +def start(): + """Retry a change that couldn't be sent, from now on in the background.""" + global _retrier + if _retrier: + return + + def loop(): + while True: + try: + _send_pending() + except Exception: + pass + _wake.wait(RETRY_EVERY) + _wake.clear() + + _retrier = threading.Thread(target=loop, name='contact', daemon=True) + _retrier.start() diff --git a/ui/frame_host.py b/ui/frame_host.py index e5c1914..7562d42 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -153,6 +153,19 @@ def open_path(path): stderr=subprocess.DEVNULL, **DETACHED) +def reveal_path(path): + """Show a file selected in its folder (Linux file managers vary, so there the folder opens).""" + path = Path(path) + if MAC: + cmd = ["open", "-R", str(path)] + elif WINDOWS: + cmd = f'explorer /select,"{path}"' # as one string: Explorer wants the quotes after the comma + else: + return open_path(path.parent) + subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, **DETACHED) + + open_url = open_path # the same openers hand URLs to the default browser diff --git a/ui/frame_report.py b/ui/frame_report.py index ffcfa9d..25b887c 100644 --- a/ui/frame_report.py +++ b/ui/frame_report.py @@ -6,6 +6,10 @@ project as a `problem_report` event: only the maintainer can read it, and nothing is published. It is sent whatever the analytics settings are, because the person sends it deliberately. Diagnostics are scrubbed first (frame_telemetry.scrub); the person's own words are sent as written. + +An email address goes with a report only when the person ticks "may contact me with +follow-up questions" (contact_followup). Standing choices made in Settings are +frame_contact.py's `contact_consent` events; `contacts` lists them. """ import os import platform @@ -13,6 +17,7 @@ import sys import time import uuid +import frame_contact import frame_host import frame_telemetry @@ -107,19 +112,26 @@ def send(body): """Send the report to PostHog. Returns {"id", "message"}; raises ReportError.""" kind = body.get('kind') if body.get('kind') in KINDS else 'bug' title, text, diag = compose(body) + followup = bool(body.get('contactFollowup')) + contact = str(body.get('contact') or '').strip() if followup else '' + if followup and not frame_contact.valid_email(contact): + raise ValueError('add your email address for follow-up questions, or untick that box') ref = uuid.uuid4().hex[:8].upper() props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text, - 'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag, + 'contact': contact, 'contact_followup': followup, 'diagnostics': diag, 'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'} # Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics. event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()), 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props} + started = time.time() try: frame_telemetry.post([event], timeout=30) except frame_telemetry.SendError as e: raise ReportError(str(e)) try: - frame_telemetry.record_sent([event]) + with frame_telemetry._lock: # the lock a removal holds while wiping its address + frame_contact.redact_removed(event, started) + frame_telemetry.record_sent([event]) except OSError: pass # it was sent; failing to log it here mustn't make the person send it again return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'} @@ -135,22 +147,71 @@ def inbox(days=30): import frame_compat_db res = frame_compat_db._posthog_query( "SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, " - "properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics " + "properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, " + "properties.contact_followup " f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY " "ORDER BY timestamp DESC LIMIT 200") return res.get('results') or [] +def _yes(v): + return v is True or str(v).lower() in ('true', '1') + + +def contacts(): + """{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest + contact_consent event agrees to each, oldest first. A withdrawal, or a change to another + address, replaces what came before, so withdrawn addresses are never listed. "Newest" is + the highest rev from that copy (then time), so every field comes from the same event + whatever order they arrived in or what the clocks said.""" + import frame_compat_db + newest = "tuple(ifNull(toInt(properties.rev), 0), timestamp)" + res = frame_compat_db._posthog_query( + f"SELECT distinct_id, argMax(properties.email, {newest}), argMax(properties.updates, {newest}), " + f"argMax(properties.followup, {newest}), argMax(timestamp, {newest}) FROM events " + "WHERE event = 'contact_consent' GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000") + out = {'updates': [], 'followup': []} + for row in res.get('results') or []: + if not isinstance(row, list) or len(row) != 5: + continue + _, email, updates, followup, ts = row + email = str(email or '').strip() + if not frame_contact.valid_email(email): + continue + for kind, agreed in (('updates', updates), ('followup', followup)): + if _yes(agreed): + out[kind].append((email, str(ts or '')[:10])) + return out + + +USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]' + + def main(): cmd, *args = sys.argv[1:] or ['inbox'] + if cmd == 'contacts': + kinds = args[:1] or ['updates', 'followup'] + if not set(kinds) <= {'updates', 'followup'}: + sys.exit(USAGE) + found = contacts() + for kind in kinds: + print(f"== {'Release and update notices' if kind == 'updates' else 'Follow-up questions'}" + f" ({len(found[kind])})") + for email, since in found[kind]: + print(f" {email} (since {since})") + print() + return if cmd != 'inbox': - sys.exit('usage: frame_report.py inbox [days]') + sys.exit(USAGE) for row in inbox(*(args[:1] or [30])): - if not isinstance(row, list) or len(row) != 10: + if not isinstance(row, list) or len(row) != 11: continue - ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row) + ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10]) + # Reports from before contact_followup existed only carried an address given for a reply. + reply = contact and (row[10] is None or _yes(row[10])) print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}") - print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}") + print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}" + f"{', may follow up at ' + contact if reply else ''}") print(' ' + text.replace('\n', '\n ')) if diag: print(' --- diagnostics\n ' + diag.replace('\n', '\n ')) diff --git a/ui/index.html b/ui/index.html index 0d7a686..e82ff2e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -175,7 +175,7 @@ .seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; } .seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; } .seg button.on { background: var(--btn-hi); color: var(--bright); } - input[type=text], input[type=search], input[type=url], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; + input[type=text], input[type=search], input[type=url], input[type=password], input[type=email], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 9px 11px; font: inherit; } textarea { resize: vertical; min-height: 76px; } input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); } @@ -258,7 +258,12 @@ .shot-card .row { flex-wrap: nowrap; } .shot-card .grow { flex: 1; min-width: 0; } .shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .shot-card .s { color: var(--muted); font-size: 12px; } + .shot-card .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ctx-menu { position: fixed; z-index: 1000; min-width: 200px; padding: 4px; border-radius: 4px; background: #232c38; + box-shadow: 0 10px 28px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.08); } + .ctx-menu button { display: block; width: 100%; height: 30px; padding: 0 10px; text-align: left; background: none; } + .ctx-menu button:hover, .ctx-menu button:focus-visible { background: var(--blue); color: #fff; outline: none; } + .ctx-menu hr { border: 0; border-top: 1px solid rgba(255,255,255,.1); margin: 4px 2px; } /* ---- library shelf (portrait capsules, like Steam's library home) ---- */ .shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; } @@ -296,6 +301,11 @@ background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; } .notice .grow { flex: 1; min-width: 260px; } .notice .progress { width: 160px; margin-top: 0; display: block; } + .contact-opts { display: flex; gap: 6px 18px; flex-wrap: wrap; margin-top: 8px; } + .contact-opts label { display: inline-flex; gap: 7px; align-items: center; cursor: pointer; } + .contact-opts input { width: 15px; height: 15px; margin: 0; accent-color: var(--blue); } + #contactNotice input[type=email] { width: min(320px, 100%); margin-top: 8px; padding: 7px 10px; } + #cEmail { max-width: 420px; } .popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; } .popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); } .popt b { font-weight: 600; color: var(--text); } @@ -674,6 +684,17 @@ +
Loading…
-
Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
+
Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to ~/Pictures/SteamFrame. Right-click for more.
@@ -1127,6 +1149,17 @@ Error messages and where in Frame Control they happened, with your home folder, user name, addresses and keys removed.
+

Contact email (optional)

+
+ +
+ +
+
+
+
+
Sent privately to the Frame Control maintainer, never shared or published. + Updates are occasional release notices; follow-up questions are mainly about problem reports you send.
Show what's been sent
@@ -1234,7 +1267,9 @@ placeholder="e.g. Installing an APK stops at 'copying to the Frame'"> - + +