mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2c8466ba9 | ||
|
|
6cf01729e2 | ||
|
|
47a29afb4c | ||
|
|
83d74548cd |
No files matched your search
+9
-1
@@ -1,7 +1,7 @@
|
|||||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
// 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
|
// 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.
|
// 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 { execFile, spawn } = require("child_process");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -264,6 +264,14 @@ function fromUi(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
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.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
||||||
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||||
|
|||||||
+3
-2
@@ -2,8 +2,8 @@
|
|||||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
// 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
|
// 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).
|
// 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
|
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
|
||||||
// Frame menu's list of headsets up to date.
|
// 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
|
// 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.
|
// 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.
|
// And it passes update state both ways: see app/updater.js.
|
||||||
@@ -12,6 +12,7 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
|||||||
contextBridge.exposeInMainWorld("frameApp", {
|
contextBridge.exposeInMainWorld("frameApp", {
|
||||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||||
|
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||||
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
||||||
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
||||||
|
|||||||
+4
-69
@@ -103,18 +103,9 @@ 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, a short reference shown after sending, and the diagnostics below. Your
|
wrote, how to reach you if you gave it, a short reference shown after sending,
|
||||||
email address goes with it only if you tick **The maintainer may contact me
|
and the diagnostics below. It has its own random id, so it isn't linked to
|
||||||
with follow-up questions** (the report then carries `contact_followup: true`);
|
your analytics events.
|
||||||
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:
|
||||||
|
|
||||||
@@ -137,67 +128,11 @@ 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 analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
never sends anything 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,412 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -111,6 +111,8 @@ class ServerGuards(unittest.TestCase):
|
|||||||
("/api/volume", {"level": 1.5}),
|
("/api/volume", {"level": 1.5}),
|
||||||
("/api/clipboard", {"text": ""}),
|
("/api/clipboard", {"text": ""}),
|
||||||
("/api/open", {"what": "anything-else"}),
|
("/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": []}),
|
||||||
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
||||||
("/api/shots/save", {"ids": [1]}),
|
("/api/shots/save", {"ids": [1]}),
|
||||||
@@ -121,6 +123,10 @@ class ServerGuards(unittest.TestCase):
|
|||||||
status, payload = self.post(path, body)
|
status, payload = self.post(path, body)
|
||||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
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):
|
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"):
|
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"})
|
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
||||||
|
|||||||
@@ -391,16 +391,14 @@ 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"])
|
||||||
@@ -423,9 +421,8 @@ 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", "", "", None, None, None]
|
"0.4.0", "macOS", "", ""]
|
||||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
|
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
|
||||||
["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:
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -153,6 +153,19 @@ def open_path(path):
|
|||||||
stderr=subprocess.DEVNULL, **DETACHED)
|
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
|
open_url = open_path # the same openers hand URLs to the default browser
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-110
@@ -6,10 +6,6 @@ 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
|
||||||
@@ -17,7 +13,6 @@ 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
|
||||||
|
|
||||||
@@ -112,18 +107,9 @@ 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': contact, 'contact_followup': followup, 'diagnostics': diag,
|
'contact': str(body.get('contact') or '').strip()[:120], '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()),
|
||||||
@@ -133,9 +119,7 @@ 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_telemetry.record_sent([event])
|
||||||
frame_contact.redact_removed(event, started)
|
|
||||||
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
|
||||||
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
|
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
|
||||||
@@ -147,109 +131,26 @@ 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). Column 10 is whether the person may be asked follow-up
|
frame_compat_db.sync uses)."""
|
||||||
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 "
|
||||||
"properties.contact_followup, properties.contact_id, properties.contact_rev "
|
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
|
||||||
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")
|
||||||
rows = [r for r in res.get('results') or [] if isinstance(r, list) and len(r) == 13]
|
return res.get('results') or []
|
||||||
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)
|
sys.exit('usage: frame_report.py inbox [days]')
|
||||||
for row in inbox(*(args[:1] or [30])):
|
for row in inbox(*(args[:1] or [30])):
|
||||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
|
if not isinstance(row, list) or len(row) != 10:
|
||||||
# Reports from before contact_followup existed only carried an address given for a reply.
|
continue
|
||||||
reply = contact and (row[10] is None or _yes(row[10]))
|
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
|
||||||
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'}"
|
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}")
|
||||||
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 '))
|
||||||
|
|||||||
+203
-150
@@ -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], input[type=email], 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], 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); }
|
||||||
@@ -258,7 +258,12 @@
|
|||||||
.shot-card .row { flex-wrap: nowrap; }
|
.shot-card .row { flex-wrap: nowrap; }
|
||||||
.shot-card .grow { flex: 1; min-width: 0; }
|
.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 .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) ---- */
|
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
|
||||||
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
||||||
@@ -296,11 +301,6 @@
|
|||||||
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); }
|
||||||
@@ -679,17 +679,6 @@
|
|||||||
<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>
|
||||||
@@ -717,6 +706,7 @@
|
|||||||
<button class="action" id="shotBtn">Capture</button>
|
<button class="action" id="shotBtn">Capture</button>
|
||||||
<button id="liveBtn" title="Keep updating, as video">Live</button>
|
<button id="liveBtn" title="Keep updating, as video">Live</button>
|
||||||
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
|
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
|
||||||
|
<button id="copyBtn" disabled title="Copy the image to the clipboard (or right-click it)">Copy</button>
|
||||||
<button id="saveBtn" disabled>Save</button>
|
<button id="saveBtn" disabled>Save</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -883,7 +873,7 @@
|
|||||||
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||||||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
|
<div class="hint">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 <code>~/Pictures/SteamFrame</code>. Right-click for more.</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1143,17 +1133,6 @@
|
|||||||
<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>
|
||||||
@@ -1261,10 +1240,7 @@
|
|||||||
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="popt"><input type="checkbox" id="bugFollowup"><b>The maintainer may contact me with follow-up questions</b>
|
<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>
|
||||||
<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>
|
||||||
@@ -1899,7 +1875,7 @@ async function capture() {
|
|||||||
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
|
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
|
||||||
ctrlShow();
|
ctrlShow();
|
||||||
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
||||||
$("saveBtn").disabled = false;
|
$("saveBtn").disabled = $("copyBtn").disabled = false;
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log("Capture failed: " + e.message, "e");
|
log("Capture failed: " + e.message, "e");
|
||||||
@@ -1981,7 +1957,7 @@ async function startVideo() {
|
|||||||
$("viewer").classList.remove("busy");
|
$("viewer").classList.remove("busy");
|
||||||
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
||||||
$("srcBadge").hidden = false; $("srcBadge").textContent = `${video.label} · video`;
|
$("srcBadge").hidden = false; $("srcBadge").textContent = `${video.label} · video`;
|
||||||
$("stamp").hidden = false; $("saveBtn").disabled = false;
|
$("stamp").hidden = false; $("saveBtn").disabled = $("copyBtn").disabled = false;
|
||||||
}
|
}
|
||||||
frames++;
|
frames++;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
@@ -2050,6 +2026,35 @@ $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) :
|
|||||||
if (!b) return toast("Couldn't encode the image", true);
|
if (!b) return toast("Couldn't encode the image", true);
|
||||||
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
|
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
|
||||||
}, "image/png");
|
}, "image/png");
|
||||||
|
const copyViewer = () => act("Copy image", () => copyImage(lastShot ? lastShot.blob
|
||||||
|
: new Promise((ok, bad) => $("canvas").toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"))));
|
||||||
|
$("copyBtn").onclick = copyViewer;
|
||||||
|
// Right-click the viewer to copy or save what it shows (in Control, a right-click goes to the Frame).
|
||||||
|
$("canvas").oncontextmenu = e => {
|
||||||
|
if (ctrl.on || $("saveBtn").disabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
showMenu(e, [["Copy image", copyViewer], ["Save image", () => $("saveBtn").click()]]);
|
||||||
|
};
|
||||||
|
// An image onto the clipboard. The app does it natively (JPEG too); a browser
|
||||||
|
// takes PNG only, and the blob is handed over as a promise so the click still counts.
|
||||||
|
async function copyImage(blob) {
|
||||||
|
if (window.frameApp?.writeImage) {
|
||||||
|
if (!await window.frameApp.writeImage(new Uint8Array(await (await blob).arrayBuffer()))) throw new Error("the app refused the image");
|
||||||
|
return { message: "Copied the image" };
|
||||||
|
}
|
||||||
|
if (!navigator.clipboard?.write || !window.ClipboardItem) throw new Error("This browser can't copy images here");
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ "image/png": Promise.resolve(blob).then(pngBlob) })]);
|
||||||
|
return { message: "Copied the image" };
|
||||||
|
}
|
||||||
|
async function pngBlob(blob) {
|
||||||
|
if (blob.type === "image/png") return blob;
|
||||||
|
const bmp = await createImageBitmap(blob);
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = bmp.width; c.height = bmp.height;
|
||||||
|
c.getContext("2d").drawImage(bmp, 0, 0);
|
||||||
|
bmp.close();
|
||||||
|
return new Promise((ok, bad) => c.toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"));
|
||||||
|
}
|
||||||
function download(blob, name) {
|
function download(blob, name) {
|
||||||
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
|
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -3527,8 +3532,11 @@ loadReports();
|
|||||||
api("/api/host").then(applyHostWording).catch(() => {});
|
api("/api/host").then(applyHostWording).catch(() => {});
|
||||||
|
|
||||||
// ---- Steam screenshots from the headset ----
|
// ---- Steam screenshots from the headset ----
|
||||||
const shots = { list: [], urls: [] };
|
// thumbs: id -> promise of an object URL, kept across reloads so a refresh
|
||||||
|
// that finds a new shot fetches only that one's thumbnail.
|
||||||
|
const shots = { list: [], sig: null, thumbs: new Map(), loading: null, gen: 0, fill: 0 };
|
||||||
const STEAMVR_APPID = "250820";
|
const STEAMVR_APPID = "250820";
|
||||||
|
const SHOTS_POLL_MS = 8000;
|
||||||
function shotApp(appid) {
|
function shotApp(appid) {
|
||||||
if (appid === STEAMVR_APPID) return "SteamVR";
|
if (appid === STEAMVR_APPID) return "SteamVR";
|
||||||
const g = state?.games?.find(x => x.appid === appid);
|
const g = state?.games?.find(x => x.appid === appid);
|
||||||
@@ -3540,35 +3548,85 @@ async function shotBlob(id, thumb) {
|
|||||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
return r.blob();
|
return r.blob();
|
||||||
}
|
}
|
||||||
async function loadShots() {
|
function shotThumb(id) {
|
||||||
$("shotsRefresh").disabled = true;
|
const thumbs = shots.thumbs; // this headset's: a late failure must not touch the next one's
|
||||||
|
if (!thumbs.has(id)) {
|
||||||
|
const p = shotBlob(id, true).then(b => URL.createObjectURL(b));
|
||||||
|
p.catch(() => { if (thumbs.get(id) === p) thumbs.delete(id); }); // try again next time
|
||||||
|
thumbs.set(id, p);
|
||||||
|
}
|
||||||
|
return thumbs.get(id);
|
||||||
|
}
|
||||||
|
// quiet: a background check. It keeps what's shown if the Frame can't be read,
|
||||||
|
// and redraws only when the shots (or whether they're saved here) changed.
|
||||||
|
function loadShots(quiet) {
|
||||||
|
if (shots.loading) {
|
||||||
|
// A check already on its way will do for another check; Refresh, or a save
|
||||||
|
// that just finished, reads again after it so the answer is a fresh one.
|
||||||
|
const again = () => loadShots();
|
||||||
|
return quiet === true ? shots.loading : shots.loading.then(again, again);
|
||||||
|
}
|
||||||
|
const p = readShots(quiet === true).finally(() => { if (shots.loading === p) shots.loading = null; });
|
||||||
|
return shots.loading = p;
|
||||||
|
}
|
||||||
|
// Forget the shots of a headset we've switched away from, and ignore its answers still on their way.
|
||||||
|
function resetShots() {
|
||||||
|
closeMenu(); // its items were about the other headset's shot
|
||||||
|
shots.gen++; shots.fill++; shots.loading = null; shots.list = []; shots.sig = null;
|
||||||
|
for (const p of shots.thumbs.values()) p.then(URL.revokeObjectURL, () => {});
|
||||||
|
shots.thumbs = new Map();
|
||||||
|
}
|
||||||
|
async function readShots(quiet) {
|
||||||
|
const gen = shots.gen;
|
||||||
|
if (!quiet) $("shotsRefresh").disabled = true;
|
||||||
|
let got;
|
||||||
try {
|
try {
|
||||||
shots.list = (await api("/api/shots")).shots;
|
got = await api("/api/shots");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return failed($("shotGrid"), e);
|
if (!quiet && gen === shots.gen) { shots.sig = null; failed($("shotGrid"), e); }
|
||||||
|
return;
|
||||||
} finally { $("shotsRefresh").disabled = false; }
|
} finally { $("shotsRefresh").disabled = false; }
|
||||||
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
if (gen !== shots.gen) return;
|
||||||
|
({ shots: shots.list, folder: shots.folder } = got);
|
||||||
|
const sig = JSON.stringify(shots.list.map(s => [s.id, s.saved, shotApp(s.appid)]));
|
||||||
|
if (sig !== shots.sig) { shots.sig = sig; drawShots(); }
|
||||||
|
fillThumbs(quiet); // not waited for: the next check, or a save, needn't sit behind the thumbnails
|
||||||
|
}
|
||||||
|
// Thumbnails one at a time over the shared SSH connection. One that failed is
|
||||||
|
// tried again on Refresh, not by every background check.
|
||||||
|
async function fillThumbs(quiet) {
|
||||||
|
const run = ++shots.fill;
|
||||||
|
// Refresh puts every failed one back in line first, so a background pass that takes over carries them all on.
|
||||||
|
if (!quiet) for (const img of document.querySelectorAll("#shotGrid img[data-failed]")) delete img.dataset.failed;
|
||||||
|
for (const img of document.querySelectorAll("#shotGrid img[data-shot]:not([src])" + (quiet ? ":not([data-failed])" : ""))) {
|
||||||
|
const s = shots.list[+img.dataset.shot];
|
||||||
|
let url;
|
||||||
|
try { url = await shotThumb(s.id); } catch (e) { img.alt = "Preview failed"; img.dataset.failed = 1; }
|
||||||
|
if (run !== shots.fill) return; // a newer pass (after a check, a redraw or another headset) has taken over
|
||||||
|
if (url) img.src = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "Sep 28, 10:37 PM": short enough to sit beside the card's buttons (the full date is its tooltip).
|
||||||
|
function shotTime(t) {
|
||||||
|
return new Date(t * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
function drawShots() {
|
||||||
|
const ids = new Set(shots.list.map(s => s.id));
|
||||||
|
for (const [id, p] of shots.thumbs) {
|
||||||
|
if (!ids.has(id)) { shots.thumbs.delete(id); p.then(URL.revokeObjectURL, () => {}); }
|
||||||
|
}
|
||||||
const unsaved = shots.list.filter(s => !s.saved).length;
|
const unsaved = shots.list.filter(s => !s.saved).length;
|
||||||
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved && !HOST.mobile ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved && !HOST.mobile ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
||||||
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
|
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
|
||||||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card" data-card="${i}">
|
||||||
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
|
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer (right-click for more)">
|
||||||
<div class="row"><div class="grow">
|
<div class="row"><div class="grow">
|
||||||
<div class="t">${esc(shotApp(s.appid))}</div>
|
<div class="t">${esc(shotApp(s.appid))}</div>
|
||||||
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
|
<div class="s" title="${esc(new Date(s.time * 1000).toLocaleString())}">${esc(shotTime(s.time))}</div></div>
|
||||||
|
<button class="small" data-shot-copy="${i}" title="Copy the image to the clipboard">Copy</button>
|
||||||
${s.saved && !HOST.mobile ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
${s.saved && !HOST.mobile ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||||||
</div></div>`).join("")
|
</div></div>`).join("")
|
||||||
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||||||
// Thumbnails one at a time over the shared SSH connection.
|
|
||||||
for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) {
|
|
||||||
const s = shots.list[+img.dataset.shot];
|
|
||||||
try {
|
|
||||||
const url = URL.createObjectURL(await shotBlob(s.id, true));
|
|
||||||
shots.urls.push(url);
|
|
||||||
img.src = url;
|
|
||||||
} catch (e) { img.alt = "Preview failed"; }
|
|
||||||
if (!img.isConnected) return; // the list was reloaded meanwhile
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
async function openShot(s) {
|
async function openShot(s) {
|
||||||
if (live) toggleLive(false);
|
if (live) toggleLive(false);
|
||||||
@@ -3586,7 +3644,7 @@ async function openShot(s) {
|
|||||||
draw();
|
draw();
|
||||||
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
|
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
|
||||||
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
|
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
|
||||||
$("saveBtn").disabled = false;
|
$("saveBtn").disabled = $("copyBtn").disabled = false;
|
||||||
$("view").scrollIntoView({ behavior: "smooth" });
|
$("view").scrollIntoView({ behavior: "smooth" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast("Couldn't open the screenshot: " + e.message, true);
|
toast("Couldn't open the screenshot: " + e.message, true);
|
||||||
@@ -3605,15 +3663,95 @@ async function saveShots(list, btn) {
|
|||||||
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
|
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
|
||||||
if (res) loadShots();
|
if (res) loadShots();
|
||||||
}
|
}
|
||||||
|
const copyShot = (s, btn) => act("Copy screenshot", () => copyImage(shotBlob(s.id, false)), btn);
|
||||||
$("shotGrid").onclick = e => {
|
$("shotGrid").onclick = e => {
|
||||||
const img = e.target.closest("img[data-shot]");
|
const img = e.target.closest("img[data-shot]");
|
||||||
if (img) return openShot(shots.list[+img.dataset.shot]);
|
if (img) return openShot(shots.list[+img.dataset.shot]);
|
||||||
|
const c = e.target.closest("[data-shot-copy]");
|
||||||
|
if (c) return copyShot(shots.list[+c.dataset.shotCopy], c);
|
||||||
const b = e.target.closest("[data-shot-save]");
|
const b = e.target.closest("[data-shot-save]");
|
||||||
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
|
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
|
||||||
};
|
};
|
||||||
$("shotsRefresh").onclick = loadShots;
|
// A small right-click menu. items: [label, fn] pairs, null for a divider.
|
||||||
|
// back: the keyboard closed it, so focus returns to where it was.
|
||||||
|
function closeMenu(back) {
|
||||||
|
const menu = document.querySelector(".ctx-menu");
|
||||||
|
if (!menu) return;
|
||||||
|
menu.remove();
|
||||||
|
if (back === true && menu.opener?.isConnected) menu.opener.focus();
|
||||||
|
}
|
||||||
|
function showMenu(e, items) {
|
||||||
|
closeMenu();
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.className = "ctx-menu";
|
||||||
|
menu.setAttribute("role", "menu");
|
||||||
|
menu.opener = document.activeElement;
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it) { menu.append(document.createElement("hr")); continue; }
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.textContent = it[0];
|
||||||
|
b.setAttribute("role", "menuitem");
|
||||||
|
b.onclick = () => { closeMenu(); it[1](); };
|
||||||
|
menu.append(b);
|
||||||
|
}
|
||||||
|
(document.fullscreenElement || document.body).append(menu); // in front of a fullscreen viewer
|
||||||
|
const r = menu.getBoundingClientRect();
|
||||||
|
menu.style.left = Math.max(4, Math.min(e.clientX, innerWidth - r.width - 4)) + "px";
|
||||||
|
menu.style.top = Math.max(4, Math.min(e.clientY, innerHeight - r.height - 4)) + "px";
|
||||||
|
menu.querySelector("button").focus();
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", e => { if (!e.target.closest(".ctx-menu")) closeMenu(); }, true);
|
||||||
|
document.addEventListener("keydown", e => {
|
||||||
|
const menu = document.querySelector(".ctx-menu");
|
||||||
|
if (!menu) return;
|
||||||
|
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); return closeMenu(true); } // Tab doesn't wander off behind it
|
||||||
|
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||||
|
e.preventDefault(); // arrows move through the menu, not the page
|
||||||
|
const items = [...menu.querySelectorAll("button")];
|
||||||
|
const at = items.indexOf(document.activeElement), step = e.key === "ArrowDown" ? 1 : -1;
|
||||||
|
items[at < 0 ? (step > 0 ? 0 : items.length - 1) : (at + step + items.length) % items.length].focus();
|
||||||
|
});
|
||||||
|
addEventListener("blur", closeMenu);
|
||||||
|
document.addEventListener("fullscreenchange", closeMenu);
|
||||||
|
addEventListener("scroll", closeMenu, true);
|
||||||
|
// Right-click a screenshot for everything it can do.
|
||||||
|
$("shotGrid").oncontextmenu = e => {
|
||||||
|
const card = e.target.closest("[data-card]");
|
||||||
|
if (!card) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const s = shots.list[+card.dataset.card];
|
||||||
|
const items = [
|
||||||
|
["Open in viewer", () => openShot(s)],
|
||||||
|
["Copy image", () => copyShot(s)],
|
||||||
|
];
|
||||||
|
if (!s.saved || HOST.mobile) items.push(null, [`Save to ${HOST.computer}`, () => saveShots([s])]);
|
||||||
|
else {
|
||||||
|
const sep = shots.folder.includes("\\") ? "\\" : "/";
|
||||||
|
items.push(null);
|
||||||
|
items.push([`Show in ${!HOST.fileManager || HOST.fileManager === "your file manager" ? "folder" : HOST.fileManager}`,
|
||||||
|
() => act("Show screenshot", () => api("/api/open", { what: "shot", id: s.id }))]);
|
||||||
|
items.push(["Copy file path", () => act("Copy file path", async () => {
|
||||||
|
await copyText(shots.folder + sep + s.file);
|
||||||
|
return { message: "Copied the file path" };
|
||||||
|
})]);
|
||||||
|
}
|
||||||
|
items.push(null, ["Copy file name", () => act("Copy file name", async () => {
|
||||||
|
await copyText(s.file);
|
||||||
|
return { message: "Copied " + s.file };
|
||||||
|
})]);
|
||||||
|
showMenu(e, items);
|
||||||
|
};
|
||||||
|
function copyText(text) {
|
||||||
|
if (!navigator.clipboard?.writeText) throw new Error("This browser can't copy here");
|
||||||
|
return navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
$("shotsRefresh").onclick = () => loadShots();
|
||||||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
||||||
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||||||
|
// Watch for new shots: a cheap listing over the shared SSH connection while the
|
||||||
|
// window is visible and the Frame is reachable, and again on coming back to it.
|
||||||
|
setInterval(() => { if (!document.hidden && online) loadShots(true); }, SHOTS_POLL_MS);
|
||||||
|
document.addEventListener("visibilitychange", () => { if (!document.hidden && online) loadShots(true); });
|
||||||
|
|
||||||
// ---- Panel switcher: our UI over SteamVR's panel API ----
|
// ---- Panel switcher: our UI over SteamVR's panel API ----
|
||||||
let panelSeq = 0;
|
let panelSeq = 0;
|
||||||
@@ -3964,7 +4102,6 @@ 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();
|
||||||
@@ -3975,7 +4112,6 @@ 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() {
|
||||||
@@ -3996,69 +4132,7 @@ $("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"; };
|
||||||
const telemetryLoaded = loadTelemetry();
|
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(() => {});
|
||||||
}
|
}
|
||||||
@@ -4073,8 +4147,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 email = $("bugFollowup").checked ? $("bugContact").value.trim() : "";
|
const contact = $("bugContact").value.trim();
|
||||||
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```" : ""}`;
|
const body = `Kind: ${$("bugKind").value}${contact ? `\nContact: ${contact}` : ""}\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.
|
||||||
@@ -4090,31 +4164,12 @@ 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();
|
||||||
@@ -4128,8 +4183,7 @@ $("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,
|
||||||
contactFollowup: $("bugFollowup").checked, contact: $("bugFollowup").checked ? $("bugContact").value : "",
|
contact: $("bugContact").value, diagnostics: $("bugDiag").checked ? bug.preview : "" });
|
||||||
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");
|
||||||
@@ -4137,7 +4191,6 @@ $("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);
|
||||||
|
|
||||||
@@ -4292,7 +4345,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setView("headset");
|
setView("headset");
|
||||||
refresh().then(loadShots); // after status, so app names resolve
|
refresh().then(() => loadShots()); // after status, so app names resolve
|
||||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
@@ -4413,7 +4466,7 @@ function onConnection(s) {
|
|||||||
lastImg = null; lastShot = null;
|
lastImg = null; lastShot = null;
|
||||||
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
|
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
|
||||||
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
|
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
|
||||||
$("saveBtn").disabled = true;
|
$("saveBtn").disabled = $("copyBtn").disabled = true;
|
||||||
// Lists and their buttons (Remove, Launch…) were the other headset's: clear them
|
// Lists and their buttons (Remove, Launch…) were the other headset's: clear them
|
||||||
// before anyone clicks one, until the new headset's arrive.
|
// before anyone clicks one, until the new headset's arrive.
|
||||||
["games", "titleList", "andApps", "flatpaks", "shotGrid", "gmGrid"].forEach(id => {
|
["games", "titleList", "andApps", "flatpaks", "shotGrid", "gmGrid"].forEach(id => {
|
||||||
@@ -4422,7 +4475,7 @@ function onConnection(s) {
|
|||||||
disp.list = []; disp.port = null;
|
disp.list = []; disp.port = null;
|
||||||
// The lists behind those panels too, so filters can't bring the old ones back.
|
// The lists behind those panels too, so filters can't bring the old ones back.
|
||||||
gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.seq++;
|
gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.seq++;
|
||||||
androidApps = []; shots.list = [];
|
androidApps = []; resetShots();
|
||||||
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
|
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
|
||||||
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
|
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
|
||||||
// Confirmations still open were checked against the other headset.
|
// Confirmations still open were checked against the other headset.
|
||||||
@@ -4438,7 +4491,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(); checkContactPrompt(); });
|
refresh().then(() => { if (reload) reloadAll(); });
|
||||||
} 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") {
|
||||||
|
|||||||
+7
-8
@@ -50,7 +50,6 @@ 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
|
||||||
@@ -134,7 +133,6 @@ 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
|
||||||
@@ -1194,6 +1192,12 @@ def open_thing(body):
|
|||||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
frame_host.open_path(SHOTS_DIR)
|
frame_host.open_path(SHOTS_DIR)
|
||||||
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||||
|
if what == "shot":
|
||||||
|
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
|
||||||
|
if not saved.exists():
|
||||||
|
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
||||||
|
frame_host.reveal_path(saved)
|
||||||
|
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
|
||||||
except frame_host.HostError as e:
|
except frame_host.HostError as e:
|
||||||
raise Failure(str(e), 500)
|
raise Failure(str(e), 500)
|
||||||
raise Failure("unknown target", 400)
|
raise Failure("unknown target", 400)
|
||||||
@@ -2153,7 +2157,6 @@ 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)}
|
||||||
|
|
||||||
@@ -2383,8 +2386,6 @@ 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":
|
||||||
@@ -2441,8 +2442,7 @@ 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)
|
||||||
# Not headset work: switching headsets mustn't wait for (or refuse) these.
|
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
||||||
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:
|
||||||
@@ -2650,7 +2650,6 @@ 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