mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a95d3b638 | ||
|
|
989962aecc | ||
|
|
08d75e3ffb | ||
|
|
01d5c612c0 | ||
|
|
2ca0e6924a | ||
|
|
cf2db32721 | ||
|
|
3f0f09b138 | ||
|
|
b8ed53f2ff | ||
|
|
19a0d0af18 | ||
|
|
fa6d4fd81b | ||
|
|
7cc4abaffa | ||
|
|
dc4a64a9c3 |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
Locked real-Frame repeat, 2026-09-29
|
||||||
|
SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
|
||||||
|
mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
|
||||||
|
Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
|
||||||
|
Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
|
||||||
|
Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
|
||||||
|
Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
|
||||||
|
No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
|
||||||
|
Headset unworn. No Linux gaming host. No pairing or streaming session reached.
|
||||||
|
|
||||||
|
WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
|
||||||
|
Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
|
||||||
|
The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
|
||||||
|
Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
|
||||||
@@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected.
|
|||||||
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
||||||
SteamVR's original process remained running. No global settings changed.
|
SteamVR's original process remained running. No global settings changed.
|
||||||
|
|
||||||
|
### Locked repeat, 2026-09-29 (verified)
|
||||||
|
|
||||||
|
Acquired `/tmp/frame-test.lock` before installing or launching anything and
|
||||||
|
released it after cleanup. Battery was 44% and charging at preflight, 46–47%
|
||||||
|
during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
|
||||||
|
|
||||||
|
Reinstalled and launched both original APKs in immersive Lepton instances.
|
||||||
|
WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
|
||||||
|
ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
|
||||||
|
unmodified-client test, not the newer installer's automatic compatibility-layer
|
||||||
|
path. Neither reached a session that could be paired or exercised further.
|
||||||
|
Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
|
||||||
|
record the failures.
|
||||||
|
|
||||||
|
SteamVR's screenshot API returned a 1920×1080 headset capture after each
|
||||||
|
launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
|
||||||
|
and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
|
||||||
|
rendering or a working client. The headset was unworn; visibility, controllers,
|
||||||
|
frame rate and motion-to-photon latency could not be judged. The explicit
|
||||||
|
OpenXR errors, rather than the dark captures, establish the client blocker.
|
||||||
|
|
||||||
|
Both test installs, app data, shader caches, shortcuts, containers and temporary
|
||||||
|
capture files were removed. The original Steam and SteamVR process IDs were
|
||||||
|
unchanged. No reboot, power action or global setting change was used. Native
|
||||||
|
clients remain untested, and no Linux gaming host was available for Valve's
|
||||||
|
streaming path. The recommendation below is unchanged.
|
||||||
|
|
||||||
### Relation to the VR APK branch
|
### Relation to the VR APK branch
|
||||||
|
|
||||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||||
|
|||||||
+69
-4
@@ -103,9 +103,18 @@ 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
|
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
|
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
|
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,
|
wrote, a short reference shown after sending, and the diagnostics below. Your
|
||||||
and the diagnostics below. It has its own random id, so it isn't linked to
|
email address goes with it only if you tick **The maintainer may contact me
|
||||||
your analytics events.
|
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 that box ticked, the address also becomes your
|
||||||
|
**Contact email** below with follow-up questions ticked, so you remove it there
|
||||||
|
like any other. If it's a different address from the one saved there, it
|
||||||
|
replaces it, and update notices stop until you turn them on again (they were
|
||||||
|
agreed for the old address); the form says so before you send. The report then also
|
||||||
|
carries this copy's contact id and change number (`contact_id`, `contact_rev`,
|
||||||
|
see below), so removing or changing the address later takes back the
|
||||||
|
follow-up permission given with the report too.
|
||||||
|
|
||||||
With **Include diagnostics** ticked (the default), the report adds:
|
With **Include diagnostics** ticked (the default), the report adds:
|
||||||
|
|
||||||
@@ -128,11 +137,67 @@ 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
|
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||||
API key as `frame_compat_db.py sync`.
|
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 in the same visit as the first-run
|
||||||
|
privacy notice. **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, or when you send a problem report with follow-up questions ticked,
|
||||||
|
whatever the analytics settings are, because you chose to. With a report, the
|
||||||
|
address and choices are saved before the report is sent and stay saved if it
|
||||||
|
fails; like any change, they're sent as soon as PostHog can be reached. 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. This also covers problem reports you sent
|
||||||
|
from this copy with follow-up questions ticked: if your newest choice since the
|
||||||
|
report (by change number, not the clock) no longer agrees to follow-up
|
||||||
|
questions at that address, the maintainer's inbox shows the permission as
|
||||||
|
withdrawn and leaves the address out. 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
|
## Turning it all off
|
||||||
|
|
||||||
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
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
|
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
|
## Update checks
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""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_only_a_real_true_counts_as_consent(self):
|
||||||
|
for wrong in ("false", "true", 1, 0, [], {}):
|
||||||
|
with self.assertRaisesRegex(ValueError, "true or false"):
|
||||||
|
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
|
||||||
|
with self.assertRaisesRegex(ValueError, "true or false"):
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
|
||||||
|
self.assertEqual((fc.load()["email"], self.got), ("", []))
|
||||||
|
fc.save({"email": "me@example.com", "updates": True}) # left out is no
|
||||||
|
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
|
||||||
|
|
||||||
|
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"]),
|
||||||
|
("<removed>", "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 reports(self):
|
||||||
|
return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
|
||||||
|
|
||||||
|
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com"})
|
||||||
|
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||||
|
without, with_ = self.reports()
|
||||||
|
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
|
||||||
|
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
|
||||||
|
self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
|
||||||
|
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
|
||||||
|
with self.assertRaisesRegex(ValueError, "email address"):
|
||||||
|
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
|
||||||
|
|
||||||
|
def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||||
|
s = fc.state()
|
||||||
|
self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
|
||||||
|
consent = [e for e in self.events() if e["event"] == "contact_consent"]
|
||||||
|
self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
|
||||||
|
self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
|
||||||
|
fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
|
||||||
|
self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
|
||||||
|
self.assertEqual(self.reports()[1]["contact_rev"], 1)
|
||||||
|
fc.save({"email": ""}) # Remove my email
|
||||||
|
last = self.events()[-1]
|
||||||
|
self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
|
||||||
|
("withdraw", "", 2))
|
||||||
|
logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
||||||
|
self.assertEqual(logged, ["<removed>", "<removed>"])
|
||||||
|
|
||||||
|
def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
|
||||||
|
"""Update notices were agreed for the old address, not the new one (the form says so)."""
|
||||||
|
fc.save({"email": "old@example.com", "updates": True})
|
||||||
|
fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
|
||||||
|
s = fc.state()
|
||||||
|
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
|
||||||
|
self.assertEqual(self.reports()[0]["contact_rev"], 2)
|
||||||
|
fc.save({"email": "new@example.com", "updates": True, "followup": False})
|
||||||
|
fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
|
||||||
|
s = fc.state()
|
||||||
|
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
|
||||||
|
|
||||||
|
def test_a_removal_while_the_report_saves_its_address_still_counts(self):
|
||||||
|
"""Removed while the report's own consent is on its way: the report keeps that consent's
|
||||||
|
rev (so the removal is newer) and is logged without the address."""
|
||||||
|
post, removed = tm.post, []
|
||||||
|
|
||||||
|
def slow_post(events, **kw):
|
||||||
|
post(events, **kw)
|
||||||
|
if not removed and events[0]["event"] == "contact_consent":
|
||||||
|
removed.append(fc.save({"email": ""})) # Remove my email, mid-send
|
||||||
|
with mock.patch.object(tm, "post", side_effect=slow_post):
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||||
|
report = self.reports()[0]
|
||||||
|
self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
|
||||||
|
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
||||||
|
for e in self.events() if e["event"] == "contact_consent"]
|
||||||
|
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
||||||
|
fr.mark_withdrawn([row], consents)
|
||||||
|
self.assertEqual(row[10], "withdrawn")
|
||||||
|
logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
||||||
|
self.assertEqual(logged, ["<removed>"])
|
||||||
|
|
||||||
|
def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
|
||||||
|
return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
|
||||||
|
"0.4.0", "Windows", "", "", followup, cid, rev]
|
||||||
|
|
||||||
|
def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
|
||||||
|
reports = [self.report_row(), # removed later
|
||||||
|
self.report_row(cid="other"), # another copy, still agrees
|
||||||
|
self.report_row(rev=3), # sent after the removal
|
||||||
|
self.report_row(cid="moved"), # address changed later
|
||||||
|
self.report_row(cid="news-only"), # follow-up unticked later
|
||||||
|
self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
|
||||||
|
self.report_row(cid="", followup=True), # no contact id: left alone
|
||||||
|
self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
|
||||||
|
consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
|
||||||
|
["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
|
||||||
|
["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
|
||||||
|
["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
|
||||||
|
fr.mark_withdrawn(reports, consents)
|
||||||
|
self.assertEqual([r[10] for r in reports],
|
||||||
|
["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
|
||||||
|
|
||||||
|
def test_the_change_number_decides_not_the_clock(self):
|
||||||
|
"""The clock went back between the report and the removal: the removal still counts."""
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||||
|
with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
|
||||||
|
fc.save({"email": ""})
|
||||||
|
report = self.reports()[0]
|
||||||
|
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
||||||
|
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
||||||
|
for e in self.events() if e["event"] == "contact_consent"]
|
||||||
|
self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
|
||||||
|
fr.mark_withdrawn([row], consents)
|
||||||
|
self.assertEqual(row[10], "withdrawn")
|
||||||
|
|
||||||
|
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
|
||||||
|
reports = [self.report_row(), ["short"]]
|
||||||
|
consents = [["copy", "", False, 2]]
|
||||||
|
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
|
||||||
|
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
|
||||||
|
mock.patch("builtins.print") as out:
|
||||||
|
fr.main()
|
||||||
|
self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
|
||||||
|
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
|
||||||
|
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
|
||||||
|
self.assertIn("follow-up permission since withdrawn", printed)
|
||||||
|
self.assertNotIn("me@example.com", printed)
|
||||||
|
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
|
||||||
|
fr.inbox()
|
||||||
|
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_saving_is_not_headset_work(self):
|
||||||
|
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
|
||||||
|
import io
|
||||||
|
import server
|
||||||
|
seen = []
|
||||||
|
for path in ("/api/contact", "/api/contact/prompt"):
|
||||||
|
h = server.Handler.__new__(server.Handler)
|
||||||
|
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
|
||||||
|
h.path, h.rfile = path, io.BytesIO(body)
|
||||||
|
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
|
||||||
|
h.local_request = lambda: True
|
||||||
|
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
|
||||||
|
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
|
||||||
|
h.do_POST()
|
||||||
|
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
|
||||||
|
|
||||||
|
|
||||||
|
# Run these once, in test_telemetry, not again through the import above.
|
||||||
|
del Base, ReportProblem
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -391,14 +391,16 @@ class ReportProblem(Base):
|
|||||||
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
|
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
|
||||||
got = self.serve()
|
got = self.serve()
|
||||||
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
|
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
|
||||||
|
with mock.patch.object(fr.frame_contact, "from_report", return_value=("contact-id", 1)): # test_contact
|
||||||
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
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]
|
path, body = got[0]
|
||||||
event = body["batch"][0]
|
event = body["batch"][0]
|
||||||
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
|
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
|
||||||
props = event["properties"]
|
props = event["properties"]
|
||||||
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
|
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"]))
|
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
|
||||||
|
self.assertEqual((props["contact_followup"], props["contact_id"], props["contact_rev"]), (True, "contact-id", 1))
|
||||||
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, 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.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
|
||||||
self.assertIn(res["id"], res["message"])
|
self.assertIn(res["id"], res["message"])
|
||||||
@@ -421,8 +423,9 @@ class ReportProblem(Base):
|
|||||||
|
|
||||||
def test_the_inbox_skips_malformed_reports(self):
|
def test_the_inbox_skips_malformed_reports(self):
|
||||||
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
|
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
|
||||||
"0.4.0", "macOS", "", ""]
|
"0.4.0", "macOS", "", "", None, None, None]
|
||||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
|
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
|
||||||
|
["short"], good]
|
||||||
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
|
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
|
||||||
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
|
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
|
||||||
mock.patch("builtins.print") as out:
|
mock.patch("builtins.print") as out:
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""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 flag(body, key):
|
||||||
|
"""A consent choice: true only when it really is true (not "false" or 1), left out is no."""
|
||||||
|
v = body.get(key)
|
||||||
|
if v is not None and not isinstance(v, bool):
|
||||||
|
raise ValueError(f'{key} must be true or false')
|
||||||
|
return v is True
|
||||||
|
|
||||||
|
|
||||||
|
def from_report(email):
|
||||||
|
"""Follow-up questions agreed to with a problem report: the address becomes the contact
|
||||||
|
email with that choice ticked, so it shows in Settings and is removed the same way. Update
|
||||||
|
notices stay on only for the same address: a different one replaces the old address with
|
||||||
|
follow-up questions only (the report form says so before sending). Returns (contact id,
|
||||||
|
rev) for the report to carry, read together with the change itself: a later change from
|
||||||
|
this copy has a higher rev, and the newest such change decides whether the report's
|
||||||
|
follow-up permission still stands, whatever the clocks say."""
|
||||||
|
with _lock:
|
||||||
|
s = load()
|
||||||
|
same = s['email'].lower() == email.lower()
|
||||||
|
changed, cid, rev = _apply({'email': s['email'] if same else email,
|
||||||
|
'updates': s['updates'] and same, 'followup': True})
|
||||||
|
_deliver(changed)
|
||||||
|
return cid, rev
|
||||||
|
|
||||||
|
|
||||||
|
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 = '<removed>', 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'] = '<removed>'
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
_deliver(_apply(body)[0])
|
||||||
|
return state()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(body):
|
||||||
|
"""save()'s change, kept here and waiting to send. Returns (changed, contact id, rev)."""
|
||||||
|
email = str(body.get('email') or '').strip()
|
||||||
|
updates, followup = flag(body, 'updates'), flag(body, '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
|
||||||
|
return changed, s['id'], s['rev']
|
||||||
|
|
||||||
|
|
||||||
|
def _deliver(changed):
|
||||||
|
if changed and not _send_pending(block=False):
|
||||||
|
_wake.set() # offline, or a send under way that will take this change with it
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
+109
-10
@@ -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
|
nothing is published. It is sent whatever the analytics settings are, because
|
||||||
the person sends it deliberately. Diagnostics are scrubbed first
|
the person sends it deliberately. Diagnostics are scrubbed first
|
||||||
(frame_telemetry.scrub); the person's own words are sent as written.
|
(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 os
|
||||||
import platform
|
import platform
|
||||||
@@ -13,6 +17,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import frame_contact
|
||||||
import frame_host
|
import frame_host
|
||||||
import frame_telemetry
|
import frame_telemetry
|
||||||
|
|
||||||
@@ -107,9 +112,18 @@ def send(body):
|
|||||||
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
|
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
|
||||||
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
||||||
title, text, diag = compose(body)
|
title, text, diag = compose(body)
|
||||||
|
followup = frame_contact.flag(body, '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')
|
||||||
|
started = time.time() # a removal from now on (even while saving the address) is redacted from the log
|
||||||
|
# It becomes the contact email in Settings, where it's changed or removed like any other.
|
||||||
|
contact_id, contact_rev = frame_contact.from_report(contact) if followup else ('', 0)
|
||||||
ref = uuid.uuid4().hex[:8].upper()
|
ref = uuid.uuid4().hex[:8].upper()
|
||||||
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
|
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,
|
||||||
|
# Only with an address: a later change from this copy (higher rev) can take it back.
|
||||||
|
'contact_id': contact_id, 'contact_rev': contact_rev,
|
||||||
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
|
'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.
|
# 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()),
|
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
|
||||||
@@ -119,6 +133,8 @@ def send(body):
|
|||||||
except frame_telemetry.SendError as e:
|
except frame_telemetry.SendError as e:
|
||||||
raise ReportError(str(e))
|
raise ReportError(str(e))
|
||||||
try:
|
try:
|
||||||
|
with frame_telemetry._lock: # the lock a removal holds while wiping its address
|
||||||
|
frame_contact.redact_removed(event, started)
|
||||||
frame_telemetry.record_sent([event])
|
frame_telemetry.record_sent([event])
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # it was sent; failing to log it here mustn't make the person send it again
|
pass # it was sent; failing to log it here mustn't make the person send it again
|
||||||
@@ -131,26 +147,109 @@ class ReportError(RuntimeError):
|
|||||||
|
|
||||||
def inbox(days=30):
|
def inbox(days=30):
|
||||||
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key
|
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key
|
||||||
frame_compat_db.sync uses)."""
|
frame_compat_db.sync uses). Column 10 is whether the person may be asked follow-up
|
||||||
|
questions now: 'withdrawn' when a later choice from the same copy took it back."""
|
||||||
import frame_compat_db
|
import frame_compat_db
|
||||||
|
days = int(days)
|
||||||
res = frame_compat_db._posthog_query(
|
res = frame_compat_db._posthog_query(
|
||||||
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
|
"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, "
|
||||||
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
|
"properties.contact_followup, properties.contact_id, properties.contact_rev "
|
||||||
|
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {days} DAY "
|
||||||
"ORDER BY timestamp DESC LIMIT 200")
|
"ORDER BY timestamp DESC LIMIT 200")
|
||||||
return res.get('results') or []
|
rows = [r for r in res.get('results') or [] if isinstance(r, list) and len(r) == 13]
|
||||||
|
if any(r[11] and _yes(r[10]) for r in rows):
|
||||||
|
later = frame_compat_db._posthog_query(
|
||||||
|
"SELECT distinct_id, properties.email, properties.followup, ifNull(toInt(properties.rev), 0) "
|
||||||
|
"FROM events WHERE event = 'contact_consent' LIMIT 100000")
|
||||||
|
mark_withdrawn(rows, later.get('results') or [])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def mark_withdrawn(reports, consents):
|
||||||
|
"""Mark reports whose follow-up permission was taken back: the newest contact choice from
|
||||||
|
the same copy made after the report (a higher rev than it carries, not a later clock) no
|
||||||
|
longer agrees to follow-up questions at that address."""
|
||||||
|
newest = {}
|
||||||
|
for c in consents:
|
||||||
|
if not isinstance(c, list) or len(c) != 4:
|
||||||
|
continue
|
||||||
|
cid, email, followup, rev = c
|
||||||
|
try:
|
||||||
|
rev = int(rev or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if rev > newest.get(str(cid), (-1,))[0]:
|
||||||
|
newest[str(cid)] = (rev, str(email or ''), followup)
|
||||||
|
for r in reports:
|
||||||
|
if not (r[11] and _yes(r[10])):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sent_at = int(r[12] or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sent_at = 0
|
||||||
|
rev, email, followup = newest.get(str(r[11]), (-1, '', None))
|
||||||
|
if rev > sent_at and not (_yes(followup) and email.strip().lower() == str(r[5] or '').strip().lower()):
|
||||||
|
r[10] = 'withdrawn'
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
def main():
|
||||||
cmd, *args = sys.argv[1:] or ['inbox']
|
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':
|
if cmd != 'inbox':
|
||||||
sys.exit('usage: frame_report.py inbox [days]')
|
sys.exit(USAGE)
|
||||||
for row in inbox(*(args[:1] or [30])):
|
for row in inbox(*(args[:1] or [30])):
|
||||||
if not isinstance(row, list) or len(row) != 10:
|
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
|
||||||
continue
|
# Reports from before contact_followup existed only carried an address given for a reply.
|
||||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
|
reply = contact and (row[10] is None or _yes(row[10]))
|
||||||
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
|
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 ''}"
|
||||||
|
f"{', follow-up permission since withdrawn' if row[10] == 'withdrawn' else ''}")
|
||||||
print(' ' + text.replace('\n', '\n '))
|
print(' ' + text.replace('\n', '\n '))
|
||||||
if diag:
|
if diag:
|
||||||
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
||||||
|
|||||||
+122
-7
@@ -175,7 +175,7 @@
|
|||||||
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
|
.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 { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
|
||||||
.seg button.on { background: var(--btn-hi); color: var(--bright); }
|
.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; }
|
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
||||||
textarea { resize: vertical; min-height: 76px; }
|
textarea { resize: vertical; min-height: 76px; }
|
||||||
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
||||||
@@ -296,6 +296,11 @@
|
|||||||
background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; }
|
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 .grow { flex: 1; min-width: 260px; }
|
||||||
.notice .progress { width: 160px; margin-top: 0; display: block; }
|
.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 { 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 input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
|
||||||
.popt b { font-weight: 600; color: var(--text); }
|
.popt b { font-weight: 600; color: var(--text); }
|
||||||
@@ -674,6 +679,17 @@
|
|||||||
<button class="small" id="noticeMore" title="Also share compatibility results and error details (you can turn either off later)">Share more to help fix problems</button>
|
<button class="small" id="noticeMore" title="Also share compatibility results and error details (you can turn either off later)">Share more to help fix problems</button>
|
||||||
<button class="action small" id="noticeOk">OK</button>
|
<button class="action small" id="noticeOk">OK</button>
|
||||||
</div>
|
</div>
|
||||||
|
<form class="notice" id="contactNotice" hidden>
|
||||||
|
<div class="grow"><b>Leave an email address?</b> Optional: Frame Control works the same either way, and
|
||||||
|
you can change or remove it any time in Privacy & updates.
|
||||||
|
<div><input type="email" id="cpEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address" required></div>
|
||||||
|
<div class="contact-opts">
|
||||||
|
<label><input type="checkbox" id="cpUpdates"> Email me about Frame Control updates</label>
|
||||||
|
<label><input type="checkbox" id="cpFollowup"> The maintainer may contact me with follow-up questions</label></div></div>
|
||||||
|
<span class="sub" id="cpMsg" role="status"></span>
|
||||||
|
<button type="button" class="small" id="cpNo">No thanks</button>
|
||||||
|
<button type="submit" class="action small" id="cpSave">Save</button>
|
||||||
|
</form>
|
||||||
<div class="banner" id="offline" role="alert" hidden>
|
<div class="banner" id="offline" role="alert" hidden>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
|
||||||
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
|
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
|
||||||
@@ -1127,6 +1143,17 @@
|
|||||||
<span class="sub">Error messages and where in Frame Control they happened, with your home
|
<span class="sub">Error messages and where in Frame Control they happened, with your home
|
||||||
folder, user name, addresses and keys removed.</span></label>
|
folder, user name, addresses and keys removed.</span></label>
|
||||||
<div class="hint" id="tStatus"></div>
|
<div class="hint" id="tStatus"></div>
|
||||||
|
<h3 style="margin-top:22px">Contact email (optional)</h3>
|
||||||
|
<form id="contactForm">
|
||||||
|
<input type="email" id="cEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address">
|
||||||
|
<div class="contact-opts">
|
||||||
|
<label><input type="checkbox" id="cUpdates"> Email me about Frame Control updates</label>
|
||||||
|
<label><input type="checkbox" id="cFollowup"> The maintainer may contact me with follow-up questions</label></div>
|
||||||
|
<div class="row" style="margin-top:10px"><button type="submit" class="small action" id="cSave">Save</button>
|
||||||
|
<button type="button" class="small" id="cRemove">Remove my email</button></div>
|
||||||
|
</form>
|
||||||
|
<div class="hint" id="cStatus">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.</div>
|
||||||
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
|
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
|
||||||
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
|
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
|
||||||
<button class="small" id="updateCheck" hidden>Check for updates</button>
|
<button class="small" id="updateCheck" hidden>Check for updates</button>
|
||||||
@@ -1234,7 +1261,10 @@
|
|||||||
placeholder="e.g. Installing an APK stops at 'copying to the Frame'"></label>
|
placeholder="e.g. Installing an APK stops at 'copying to the Frame'"></label>
|
||||||
<label class="field">What happened?<textarea id="bugText" maxlength="5000" required minlength="10"
|
<label class="field">What happened?<textarea id="bugText" maxlength="5000" required minlength="10"
|
||||||
placeholder="What you did, what happened, and what you expected."></textarea></label>
|
placeholder="What you did, what happened, and what you expected."></textarea></label>
|
||||||
<label class="field">How can we reach you? (optional, for a reply)<input type="text" id="bugContact" maxlength="120" placeholder="Email, GitHub or Discord name"></label>
|
<label class="popt"><input type="checkbox" id="bugFollowup"><b>The maintainer may contact me with follow-up questions</b>
|
||||||
|
<span class="sub">Optional. Your email address goes with this report only when this is ticked, and is kept as your contact email in Privacy & updates, where you can remove it.</span></label>
|
||||||
|
<label class="field">Your email address<input type="email" id="bugContact" maxlength="254" placeholder="you@example.com" disabled></label>
|
||||||
|
<p class="hint" id="bugReplaces" role="status" hidden></p>
|
||||||
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
|
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
|
||||||
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
|
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
|
||||||
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
|
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
|
||||||
@@ -3934,6 +3964,7 @@ async function offerTest(m) {
|
|||||||
|
|
||||||
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
|
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
|
||||||
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
|
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
|
||||||
|
let privacyNoticeShown = false; // this visit: then the contact prompt waits for another one
|
||||||
function renderTelemetry(s) {
|
function renderTelemetry(s) {
|
||||||
Object.assign(telemetry, s);
|
Object.assign(telemetry, s);
|
||||||
setRepHint();
|
setRepHint();
|
||||||
@@ -3944,6 +3975,7 @@ function renderTelemetry(s) {
|
|||||||
: "Nothing sent yet.";
|
: "Nothing sent yet.";
|
||||||
const showNotice = !s.blocked && !s.noticeShown && s.usage;
|
const showNotice = !s.blocked && !s.noticeShown && s.usage;
|
||||||
$("privacyNotice").hidden = !showNotice;
|
$("privacyNotice").hidden = !showNotice;
|
||||||
|
if (showNotice) privacyNoticeShown = true;
|
||||||
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
|
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
async function loadTelemetry() {
|
async function loadTelemetry() {
|
||||||
@@ -3964,7 +3996,69 @@ $("noticeMore").onclick = async () => {
|
|||||||
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
|
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
|
||||||
};
|
};
|
||||||
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
|
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
|
||||||
loadTelemetry();
|
const telemetryLoaded = loadTelemetry();
|
||||||
|
|
||||||
|
// ---- contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
|
||||||
|
const contact = { email: "", updates: false, followup: false };
|
||||||
|
function renderContact(s) {
|
||||||
|
Object.assign(contact, s);
|
||||||
|
$("cEmail").value = s.email; $("cUpdates").checked = s.updates; $("cFollowup").checked = s.followup;
|
||||||
|
$("cRemove").hidden = !s.email;
|
||||||
|
$("cStatus").textContent = s.waiting ? "Saved here; it's sent to the maintainer when Frame Control can reach PostHog."
|
||||||
|
: s.email ? `Saved. ${s.email} may get ${[s.updates && "update notices", s.followup && "follow-up questions"].filter(Boolean).join(" and ")}. Remove it any time.`
|
||||||
|
: "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.";
|
||||||
|
}
|
||||||
|
async function saveContact(change) {
|
||||||
|
const s = await api("/api/contact", change);
|
||||||
|
renderContact(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
async function loadContact() {
|
||||||
|
await telemetryLoaded;
|
||||||
|
try { renderContact(await api("/api/contact")); } catch { return; }
|
||||||
|
checkContactPrompt();
|
||||||
|
}
|
||||||
|
// One time only, only once the Frame has connected, and never in a visit that showed the privacy
|
||||||
|
// notice (two asks in a row is nagging): checked at load and whenever the Frame connects.
|
||||||
|
async function checkContactPrompt() {
|
||||||
|
await telemetryLoaded;
|
||||||
|
if (privacyNoticeShown || !$("contactNotice").hidden) return;
|
||||||
|
let s;
|
||||||
|
try { s = await api("/api/contact"); } catch { return; }
|
||||||
|
if (!s.showPrompt || privacyNoticeShown || !$("contactNotice").hidden) return;
|
||||||
|
$("contactNotice").hidden = false;
|
||||||
|
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
|
||||||
|
}
|
||||||
|
$("contactNotice").onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!$("cpUpdates").checked && !$("cpFollowup").checked) { $("cpMsg").textContent = "Tick at least one, or choose No thanks."; return; }
|
||||||
|
$("cpSave").disabled = true;
|
||||||
|
try {
|
||||||
|
await saveContact({ email: $("cpEmail").value, updates: $("cpUpdates").checked, followup: $("cpFollowup").checked, fromPrompt: true });
|
||||||
|
$("contactNotice").hidden = true;
|
||||||
|
toast("Thanks! Change or remove it any time in Privacy & updates.");
|
||||||
|
} catch (err) { $("cpMsg").textContent = `Couldn't save: ${err.message}`; }
|
||||||
|
finally { $("cpSave").disabled = false; }
|
||||||
|
};
|
||||||
|
$("cpNo").onclick = async () => {
|
||||||
|
try { await api("/api/contact/prompt", { prompt: "dismissed" }); $("contactNotice").hidden = true; }
|
||||||
|
catch (err) { $("cpMsg").textContent = `Couldn't save that: ${err.message}. Try again.`; }
|
||||||
|
};
|
||||||
|
$("contactForm").onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = $("cEmail").value.trim();
|
||||||
|
if (email && !$("cUpdates").checked && !$("cFollowup").checked) {
|
||||||
|
$("cStatus").textContent = "Tick what your email may be used for, or use Remove my email."; return;
|
||||||
|
}
|
||||||
|
try { await saveContact({ email, updates: $("cUpdates").checked, followup: $("cFollowup").checked });
|
||||||
|
toast(email ? "Contact email saved." : "Contact email removed."); }
|
||||||
|
catch (err) { toast(`Couldn't save: ${err.message}`, true); }
|
||||||
|
};
|
||||||
|
$("cRemove").onclick = async () => {
|
||||||
|
try { await saveContact({ email: "", updates: false, followup: false }); toast("Contact email removed. Neither choice applies any more."); }
|
||||||
|
catch (err) { toast(`Couldn't remove it: ${err.message}`, true); }
|
||||||
|
};
|
||||||
|
loadContact();
|
||||||
function pageEvent(event, properties) {
|
function pageEvent(event, properties) {
|
||||||
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
|
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -3979,8 +4073,8 @@ document.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () =
|
|||||||
const bug = { preview: "" };
|
const bug = { preview: "" };
|
||||||
const activityLines = () => [...$("log").children].slice(0, 25).map(el => el.textContent.trim());
|
const activityLines = () => [...$("log").children].slice(0, 25).map(el => el.textContent.trim());
|
||||||
function bugReportText() {
|
function bugReportText() {
|
||||||
const contact = $("bugContact").value.trim();
|
const email = $("bugFollowup").checked ? $("bugContact").value.trim() : "";
|
||||||
const body = `Kind: ${$("bugKind").value}${contact ? `\nContact: ${contact}` : ""}\n\n${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
|
const body = `Kind: ${$("bugKind").value}${email ? `\nFollow-up questions welcome: ${email}` : ""}\n\n${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
|
||||||
return { title: $("bugTitleIn").value.trim(), body };
|
return { title: $("bugTitleIn").value.trim(), body };
|
||||||
}
|
}
|
||||||
// The preview is a snapshot: exactly this text is sent, even if more activity happens meanwhile.
|
// The preview is a snapshot: exactly this text is sent, even if more activity happens meanwhile.
|
||||||
@@ -3996,12 +4090,31 @@ function openBugReport() {
|
|||||||
$("bugMsg").textContent = ""; $("bugSend").disabled = false;
|
$("bugMsg").textContent = ""; $("bugSend").disabled = false;
|
||||||
$("bugCancel").textContent = "Cancel";
|
$("bugCancel").textContent = "Cancel";
|
||||||
$("bugDiagBox").open = false; $("bugLogs").disabled = false;
|
$("bugDiagBox").open = false; $("bugLogs").disabled = false;
|
||||||
|
// A standing yes to follow-up questions (Privacy & updates) fills this in; it can be unticked.
|
||||||
|
$("bugFollowup").checked = contact.followup; $("bugContact").value = contact.followup ? contact.email : "";
|
||||||
|
$("bugContact").disabled = !contact.followup; $("bugContact").required = contact.followup;
|
||||||
|
bugReplaces();
|
||||||
$("bugDlg").showModal();
|
$("bugDlg").showModal();
|
||||||
loadBugPreview();
|
loadBugPreview();
|
||||||
}
|
}
|
||||||
document.body.addEventListener("click", e => { if (e.target.closest("[data-report]")) openBugReport(); });
|
document.body.addEventListener("click", e => { if (e.target.closest("[data-report]")) openBugReport(); });
|
||||||
$("bugDiag").onchange = () => { $("bugLogs").disabled = !$("bugDiag").checked; loadBugPreview(); };
|
$("bugDiag").onchange = () => { $("bugLogs").disabled = !$("bugDiag").checked; loadBugPreview(); };
|
||||||
$("bugLogs").onchange = loadBugPreview;
|
$("bugLogs").onchange = loadBugPreview;
|
||||||
|
$("bugFollowup").onchange = () => {
|
||||||
|
const on = $("bugFollowup").checked;
|
||||||
|
$("bugContact").disabled = !on; $("bugContact").required = on;
|
||||||
|
if (on && !$("bugContact").value) { $("bugContact").value = contact.email; $("bugContact").focus(); }
|
||||||
|
bugReplaces();
|
||||||
|
};
|
||||||
|
// Sending with another address replaces the saved one (ui/frame_contact.py from_report): say so first.
|
||||||
|
function bugReplaces() {
|
||||||
|
const email = $("bugContact").value.trim(), old = contact.email;
|
||||||
|
const replaces = $("bugFollowup").checked && email && old && email.toLowerCase() !== old.toLowerCase();
|
||||||
|
$("bugReplaces").hidden = !replaces;
|
||||||
|
$("bugReplaces").textContent = !replaces ? "" : `Sending replaces ${old} as your contact email${contact.updates
|
||||||
|
? ", and update notices stop until you turn them on again in Privacy & updates" : ""}.`;
|
||||||
|
}
|
||||||
|
$("bugContact").oninput = bugReplaces;
|
||||||
$("bugCancel").onclick = () => $("bugDlg").close();
|
$("bugCancel").onclick = () => $("bugDlg").close();
|
||||||
$("bugCopy").onclick = async () => {
|
$("bugCopy").onclick = async () => {
|
||||||
const { title, body } = bugReportText();
|
const { title, body } = bugReportText();
|
||||||
@@ -4015,7 +4128,8 @@ $("bugForm").onsubmit = async e => {
|
|||||||
try {
|
try {
|
||||||
const res = await api("/api/report", {
|
const res = await api("/api/report", {
|
||||||
kind: $("bugKind").value, title: $("bugTitleIn").value, message: $("bugText").value,
|
kind: $("bugKind").value, title: $("bugTitleIn").value, message: $("bugText").value,
|
||||||
contact: $("bugContact").value, diagnostics: $("bugDiag").checked ? bug.preview : "" });
|
contactFollowup: $("bugFollowup").checked, contact: $("bugFollowup").checked ? $("bugContact").value : "",
|
||||||
|
diagnostics: $("bugDiag").checked ? bug.preview : "" });
|
||||||
$("bugMsg").textContent = `Sent, thank you. Your reference is ${res.id}.`;
|
$("bugMsg").textContent = `Sent, thank you. Your reference is ${res.id}.`;
|
||||||
$("bugCancel").textContent = "Close";
|
$("bugCancel").textContent = "Close";
|
||||||
log(res.message, "ok");
|
log(res.message, "ok");
|
||||||
@@ -4023,6 +4137,7 @@ $("bugForm").onsubmit = async e => {
|
|||||||
$("bugMsg").textContent = `Couldn't send it: ${err.message}. Try again later, or use Copy report.`;
|
$("bugMsg").textContent = `Couldn't send it: ${err.message}. Try again later, or use Copy report.`;
|
||||||
$("bugSend").disabled = false;
|
$("bugSend").disabled = false;
|
||||||
}
|
}
|
||||||
|
api("/api/contact").then(renderContact).catch(() => {}); // the report may have saved the address
|
||||||
};
|
};
|
||||||
if (window.frameApp && window.frameApp.onReportProblem) window.frameApp.onReportProblem(openBugReport);
|
if (window.frameApp && window.frameApp.onReportProblem) window.frameApp.onReportProblem(openBugReport);
|
||||||
|
|
||||||
@@ -4323,7 +4438,7 @@ function onConnection(s) {
|
|||||||
$("offline").hidden = true;
|
$("offline").hidden = true;
|
||||||
const reload = link.reload;
|
const reload = link.reload;
|
||||||
link.reload = false;
|
link.reload = false;
|
||||||
refresh().then(() => { if (reload) reloadAll(); });
|
refresh().then(() => { if (reload) reloadAll(); checkContactPrompt(); });
|
||||||
} else if (s.phase === "failed" && s.error) {
|
} else if (s.phase === "failed" && s.error) {
|
||||||
setOnline(false, s.error.message);
|
setOnline(false, s.error.message);
|
||||||
} else if (s.phase === "connecting" && prev && prev.phase === "connected") {
|
} else if (s.phase === "connecting" && prev && prev.phase === "connected") {
|
||||||
|
|||||||
+8
-1
@@ -50,6 +50,7 @@ import frame_catalog # noqa: E402
|
|||||||
import frame_devices # noqa: E402
|
import frame_devices # noqa: E402
|
||||||
import frame_steamgriddb
|
import frame_steamgriddb
|
||||||
import frame_comfort # noqa: E402
|
import frame_comfort # noqa: E402
|
||||||
|
import frame_contact # noqa: E402
|
||||||
import frame_host # noqa: E402
|
import frame_host # noqa: E402
|
||||||
import frame_link # noqa: E402
|
import frame_link # noqa: E402
|
||||||
import frame_macview # noqa: E402
|
import frame_macview # noqa: E402
|
||||||
@@ -133,6 +134,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
|
|||||||
# install's clean-up) to the other headset.
|
# install's clean-up) to the other headset.
|
||||||
_work_lock = threading.Lock()
|
_work_lock = threading.Lock()
|
||||||
_work = [0]
|
_work = [0]
|
||||||
|
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
@@ -2151,6 +2153,7 @@ POST = {
|
|||||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||||
"/api/webinstall/cancel": webinstall_cancel,
|
"/api/webinstall/cancel": webinstall_cancel,
|
||||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||||
|
"/api/contact": frame_contact.save, "/api/contact/prompt": frame_contact.prompt,
|
||||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
|
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
|
||||||
"/api/devices": lambda body: devices_post(body)}
|
"/api/devices": lambda body: devices_post(body)}
|
||||||
|
|
||||||
@@ -2380,6 +2383,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(macview_state(parse_qs(url.query)))
|
self.send_json(macview_state(parse_qs(url.query)))
|
||||||
elif path == "/api/telemetry":
|
elif path == "/api/telemetry":
|
||||||
self.send_json(frame_telemetry.state())
|
self.send_json(frame_telemetry.state())
|
||||||
|
elif path == "/api/contact":
|
||||||
|
self.send_json(frame_contact.state())
|
||||||
elif path == "/api/computer/state":
|
elif path == "/api/computer/state":
|
||||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
||||||
elif path == "/api/status":
|
elif path == "/api/status":
|
||||||
@@ -2436,7 +2441,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
body = json.loads(self.rfile.read(length) or b"{}")
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
raise Failure("request body must be a JSON object", 400)
|
raise Failure("request body must be a JSON object", 400)
|
||||||
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
# Not headset work: switching headsets mustn't wait for (or refuse) these.
|
||||||
|
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
|
||||||
result = handler(body)
|
result = handler(body)
|
||||||
self.send_json(result)
|
self.send_json(result)
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
@@ -2644,6 +2650,7 @@ def main():
|
|||||||
sweep_tmp()
|
sweep_tmp()
|
||||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||||
frame_telemetry.start()
|
frame_telemetry.start()
|
||||||
|
frame_contact.start()
|
||||||
global LINK, _ONE_SERVER
|
global LINK, _ONE_SERVER
|
||||||
if not LOCAL:
|
if not LOCAL:
|
||||||
if not PRIVATE: # a private server only uses the headsets (see one_server)
|
if not PRIVATE: # a private server only uses the headsets (see one_server)
|
||||||
|
|||||||
Reference in new issue
Block a user