Compare commits

..
14 Commits
Author SHA1 Message Date
saphidandClaude Sonnet 5.5 024ec593f9 Merge origin/main into pad-gamescope
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 21:06:06 +10:00
saphidandClaude Sonnet 5.5 0373555519 Pad: count gamescope's real typing time, with the settle margin added once
Review found the estimate used 12 ms a character where the agent takes 16 ms
(32 shifted), and added the margin on every request so it piled up.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 21:01:53 +10:00
saphidandClaude Sonnet 5.5 3539215517 Pad: drain the KDE Connect queue without losing, stranding or reordering input
Second review found the queue could be trimmed under an in-flight send (deleting
unsent events), the worker could retire as a request enqueued, and ASCII could
overtake an accent still being typed (or the reverse). The worker now takes its
batch out while sending and puts it back on failure, retires under the lock, and
each way waits out the other's estimated typing time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 20:57:08 +10:00
saphidandClaude Sonnet 5.5 9f3fb89e30 Pad: keep typing in order and acknowledge it once, with accents through one KDE Connect queue
Review found accent-only input repeated forever (sent was false), text split
across two transports out of order, accents stuck until the next keypress,
duplicated on retry, and racy. A batch with an accent now sends its whole
keyboard through KDE Connect; later keys follow while any are queued; the
server owns one ordered, bounded queue drained by a single thread; sent means
the whole batch is taken.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 20:52:41 +10:00
saphidandClaude Sonnet 5.5 dc7854d10b Touch: a bare release no longer clears the stale-focus notice before the page has seen it
The page leaves the panel off releases, so a release says nothing about focus.
Found in review of the stale-latch fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 20:52:41 +10:00
saphidandClaude Sonnet 5.5 5a6b925448 Trackpad and key row go through gamescope's input; KDE Connect only for accents and emoji
The pad reaches whichever panel has focus (Steam's menus too), needs nothing installed, and no longer clamps to 1280x720. Characters that aren't on a US keyboard start KDE Connect the first time they're typed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:03:37 +10:00
saphidandClaude Sonnet 5.5 60ade8c2e8 Merge origin/main into live-touch
Kept both route tables: main's media, agent, assistant and Mac view routes
plus /api/touch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:04:47 +10:00
saphidandClaude Opus 5.5 aa1ac7368c frame_touch: any event that goes through clears the stale flag
A trackpad move names no panel, so waiting for an aimed event could leave
the page re-reading panels for the rest of the session; a release still
aimed at the old panel doesn't count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:13:09 +10:00
saphidandClaude Opus 5.5 46b6966cf7 frame_touch: build the socket path on the Frame, so Windows can import it for tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:41:42 +10:00
saphidandClaude Opus 5.5 be2e770e66 Control: stale clears, pauses reconverge, pastes split per request
- The Frame says it has caught up as soon as an aimed event lands after a
  stale one, so the page stops re-reading the panels.
- After a device pause it lets go of everything it holds (releases that
  arrived while paused were dropped), and waits for the device once per
  batch, not once per event.
- The quick focus check no longer freshens the panel geometry's age.
- Each request carries at most about 100 characters of text.
- Turning Control off while it connects doesn't report an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:40:00 +10:00
saphidandClaude Opus 5.5 597f98af6d Control: fixes from the SWE-2 Max review
- The Frame side tracks keys as well as buttons and lets go of both when the
  session ends.
- A stale tap tells the page, which re-reads the panels at once.
- A paused input device waits instead of ending the session; only a
  disconnect does. An OS error on one event skips it.
- Presses check focus with two property reads and do the full lookup only
  when it changed.
- Writes to an agent's stdin are serialized, so two devices sending at once
  can't tear a line (the keyboard agent too).
- Connecting gives up with a message after 15 s instead of hanging on
  "Connecting…"; text goes in 100-character pieces so releases don't wait
  behind a long paste; a cancelled mouse gesture releases what's held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:28:28 +10:00
saphidandClaude Opus 5.5 ac07efa8f1 Control: close the targeting gaps from the second review
- The focused panel's display comes from GAMESCOPE_FOCUS_DISPLAY (gamescope
  packs ":1" into the first value), so a window id repeated across :0 and :1
  can't be mistaken; the pid is only the fallback.
- Presses, keys, text and scrolls read focus afresh on the Frame; only moves
  use a reading up to a second old.
- A gesture remembers the panel it started on and does nothing more if that
  stops being the one in use; a press with no panel to aim at isn't sent.
- Opening a screenshot clears the panel Control would act on; switching to
  another app releases held keys and buttons.
- Trimming keeps a click with its position; the error backoff holds for new
  input too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:52:00 +10:00
saphidandClaude Opus 5.5 0f4f0b863f Control: fixes from review
- Keys held on the Frame are released with buttons when Control stops or
  the view loses focus; keys for the Frame no longer trigger Frame Control's
  own shortcuts.
- Taps only act when the picture on screen is the panel in use; positions,
  presses, keys, text and scrolls name their panel (display and window: ids
  repeat across :0 and :1, told apart by pid), and the Frame drops them if
  focus has moved on. Releases always go.
- While connecting, a tap keeps its position; on an error only releases wait
  and retries back off; trimming a long queue never drops a release.
- Lifting one of two scrolling fingers ends the scroll; a cancelled touch
  isn't a tap; clicks and holds on the bars around the picture do nothing.
- A capture loop from before a Live restart can't stop the new video.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:40:46 +10:00
saphidandClaude Opus 5.5 e7c81733b1 Live view: a Desktop view that stays still, and Control to tap on the Frame
The live view gets a second source and a way to use the Frame from it:

- Desktop: the app panel in use in the headset, streamed from its own window
  (x11grab of gamescope's redirected window), so it doesn't move as the
  wearer looks around. A picker shows any other panel, view only.
- Control: on the Desktop view a tap or click lands exactly where you put it;
  drag is a mouse drag, press and hold right-clicks, two fingers scroll, and
  on a computer the mouse, wheel and keyboard work directly. On the headset
  view the view is a trackpad. A text field and key row type from a phone.

Input goes through gamescope's own EIS socket (the way Steam feeds Remote
Play input) with the libei already on the image: ui/frame_touch.py, over
the same long-lived ssh machinery as the keyboard agent, nothing to
install. It reaches the panel that has focus on either X display, which
the KDE Connect route can't. Verified on the Frame and from the iPhone app
in the Simulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:23:55 +10:00
26 changed files with 543 additions and 949 deletions

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
Browse the shots you take in the headset and save them to your Pictures folder. Browse the shots you take in the headset and save them to your Pictures folder.
**⌨️ Keyboard and trackpad**<br> **⌨️ Keyboard and trackpad**<br>
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere. Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one.
</td> </td>
</tr> </tr>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "frame-control", "name": "frame-control",
"version": "0.4.1", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frame-control", "name": "frame-control",
"version": "0.4.1", "version": "0.4.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"electron": "^44.4.5", "electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frame-control", "name": "frame-control",
"productName": "Frame Control", "productName": "Frame Control",
"version": "0.4.1", "version": "0.4.0",
"description": "Desktop app for managing a Valve Steam Frame over SSH", "description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true, "private": true,
"main": "main.js", "main": "main.js",
+2 -7
View File
@@ -88,13 +88,8 @@ counts them while they run.
name your networks, and switch headsets. See [devices.md](devices.md). name your networks, and switch headsets. See [devices.md](devices.md).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote - **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
Linux). Remote desktop first checks that the Frame's xrdp answers on port Linux). Sleep, restart and shut down open a terminal window because SteamOS
3389 (Developer Mode turns it on). On Windows it opens a connection file for asks for the sudo password over SSH.
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
xrdp turns away. Accept the warning about the Frame's own certificate, then
sign in with the Developer Mode password. Sleep, restart and
shut down open a terminal window because SteamOS asks for the sudo password
over SSH.
## How it works ## How it works
+5 -19
View File
@@ -107,14 +107,7 @@ wrote, a short reference shown after sending, and the diagnostics below. Your
email address goes with it only if you tick **The maintainer may contact me email address goes with it only if you tick **The maintainer may contact me
with follow-up questions** (the report then carries `contact_followup: true`); with follow-up questions** (the report then carries `contact_followup: true`);
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to 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 your analytics events.
**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:
@@ -148,8 +141,8 @@ are two separate choices, both off until you tick them:
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly | | **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 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 connected for the first time, and never while or straight after the
privacy notice. **No thanks** hides it for good, and it isn't first-run privacy notice is showing. **No thanks** hides it for good, and it isn't
shown again even if you ignore it. **Contact email** in **Privacy & updates** 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. is where you add, change or remove the address and either choice at any time.
@@ -158,10 +151,7 @@ Frame Control's PostHog project, the same place as problem reports, as a
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set` `contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
or `withdraw`) and the common properties above. Only the maintainer can read 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 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, you save, whatever the analytics settings are, because you chose to. It
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 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 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 the newest choice always wins. Like everything else sent, it's listed under
@@ -174,11 +164,7 @@ 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` 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 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 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 works the same way for that choice. If you're offline, the change waits on
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 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 stays in PostHog until its data retention removes it; to have it deleted
sooner, ask the maintainer (for example in a problem report). sooner, ask the maintainer (for example in a problem report).
+7 -18
View File
@@ -25,20 +25,6 @@ The confidence labels are the same as in [ssh.md](ssh.md).
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
Mac with keyboard, mouse, and clipboard. Mac with keyboard, mouse, and clipboard.
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
Mode password opens a Plasma (X11) desktop within about 6 seconds.
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
fills in `steamos` there on Windows, Remmina and FreeRDP.
- The desktop is a separate login session (Xorg on display `:10`), not the
headset's view. It uses about 1.3 GB of the Frame's memory.
- Closing the client leaves the session running, and the next login
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
gamescope or SteamVR session.
## B. Show the Mac's desktop inside the Frame ## B. Show the Mac's desktop inside the Frame
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
@@ -137,10 +123,13 @@ two fingers to scroll, two-finger tap to right-click) plus a text field that
types on the Frame. On a computer, clicking the pad passes your mouse and types on the Frame. On a computer, clicking the pad passes your mouse and
keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac). keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac).
It goes through **KDE Connect**, the first-party route (KDE makes the Frame's Since Control (below), the pad goes through **gamescope's own input socket**
desktop): Frame Control's server runs [`ui/frame_input_agent.py`](../ui/frame_input_agent.py) ([`ui/frame_touch.py`](../ui/frame_touch.py)), so it reaches whichever panel has
on the Frame, which talks KDE Connect's own LAN protocol to the Frame's focus, Steam's own menus included, and needs nothing installed on the Frame.
`kdeconnectd` as if it were a phone. KDE Connect does the typing and clicking. Only characters that aren't on a US keyboard (accents, emoji) go through
**KDE Connect** as below; it starts the first time you type one. (Before
Control, everything went through KDE Connect, which is why the notes below
describe it in detail.)
**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901): **Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
+2 -134
View File
@@ -59,16 +59,6 @@ class Contact(Base):
self.assertEqual(fc.load()["email"], "") self.assertEqual(fc.load()["email"], "")
self.assertEqual(self.got, []) 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): 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": True})
fc.save({"email": "me@example.com", "updates": False, "followup": True}) fc.save({"email": "me@example.com", "updates": False, "followup": True})
@@ -249,121 +239,15 @@ class Contact(Base):
# ---- reports and the maintainer's list # ---- 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): def test_a_report_carries_the_address_only_with_follow_up_consent(self):
fr.send({**REPORT, "contact": "me@example.com"}) fr.send({**REPORT, "contact": "me@example.com"})
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True}) fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
without, with_ = self.reports() without, with_ = (e["properties"] for e in self.events())
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, "")) self.assertEqual((without["contact"], without["contact_followup"]), ("", False))
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True)) 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"): with self.assertRaisesRegex(ValueError, "email address"):
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True}) 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): def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"], rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"], ["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
@@ -388,22 +272,6 @@ class Contact(Base):
self.assertIs(server.POST["/api/contact"], fc.save) self.assertIs(server.POST["/api/contact"], fc.save)
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt) 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. # Run these once, in test_telemetry, not again through the import above.
del Base, ReportProblem del Base, ReportProblem
+2 -5
View File
@@ -17,7 +17,6 @@ ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui")) sys.path.insert(0, str(ROOT / "ui"))
import frame_devices as fd # noqa: E402 import frame_devices as fd # noqa: E402
import frame_host # noqa: E402
CONFIG = """Host lxso1 CONFIG = """Host lxso1
HostName 192.168.1.109 HostName 192.168.1.109
@@ -275,8 +274,7 @@ class Pins(Base):
def test_hashed_and_non_default_port_entries(self): def test_hashed_and_non_default_port_entries(self):
kh = self.ssh / "known_hosts" kh = self.ssh / "known_hosts"
kh.write_text(f"[frame.local]:2222 {KEY}\n") kh.write_text(f"[frame.local]:2222 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222)) self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text()) self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
@@ -285,8 +283,7 @@ class Pins(Base):
target = fd.known_hosts("d4") target = fd.known_hosts("d4")
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"frame-control-d4 {KEY}\n") target.write_text(f"frame-control-d4 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertNotIn("frame-control-d4", target.read_text()) self.assertNotIn("frame-control-d4", target.read_text())
self.assertTrue(fd.pinned("d4")) self.assertTrue(fd.pinned("d4"))
self.assertTrue(fd.forget_pin("d4")) self.assertTrue(fd.forget_pin("d4"))
+1 -1
View File
@@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase):
with self.assertRaisesRegex(android.FrameError, 'start this app'): with self.assertRaisesRegex(android.FrameError, 'start this app'):
data.install_obb(PKG, [path]) data.install_obb(PKG, [path])
stream.assert_not_called() stream.assert_not_called()
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')): with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with self.assertRaisesRegex(android.FrameError, 'bad hash'): with self.assertRaisesRegex(android.FrameError, 'bad hash'):
data._stream('command') data._stream('command')
-87
View File
@@ -1,87 +0,0 @@
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
import sandbox # noqa: F401
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
import frame_host
class CapturedSSH(unittest.TestCase):
def run_command(self, source, **kwargs):
with mock.patch.object(frame_host, "WINDOWS", True):
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
def test_binary_output_and_input(self):
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
"sys.stderr.buffer.write(b'error\\r\\n')",
capture_output=True, input=b"data\x00\xff")
self.assertEqual(result.stdout, b"data\x00\xff")
self.assertEqual(result.stderr, b"error\r\n")
def test_text_output_normalizes_newlines(self):
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
capture_output=True, input="hello\n", text=True)
self.assertEqual(result.stdout, "hello\n")
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
def test_explicit_encoding_and_errors(self):
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
capture_output=True, encoding="ascii", errors="replace")
self.assertEqual(result.stderr, "\ufffd\ufffd")
def test_check_preserves_error_output(self):
with self.assertRaises(subprocess.CalledProcessError) as caught:
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
capture_output=True, text=True, check=True)
self.assertEqual(caught.exception.returncode, 7)
self.assertEqual(caught.exception.stdout, "out\n")
self.assertEqual(caught.exception.stderr, "err\n")
def test_timeout_preserves_partial_stderr(self):
with self.assertRaises(subprocess.TimeoutExpired) as caught:
with mock.patch.object(frame_host, "WINDOWS", True):
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
capture_output=True, text=True, timeout=1)
self.assertEqual(caught.exception.stderr, b"waiting")
def test_streamed_stdout_is_kept_separate(self):
with tempfile.TemporaryFile() as output:
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
"sys.stderr.buffer.write(b'error')",
stdout=output, stderr=subprocess.PIPE)
output.seek(0)
self.assertEqual(output.read(), b"file")
self.assertIsNone(result.stdout)
self.assertEqual(result.stderr, b"error")
def test_uncaptured_windows_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
def test_posix_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
def test_capture_rejects_explicit_streams(self):
for stream in ("stdout", "stderr"):
with self.subTest(stream=stream), self.assertRaises(ValueError):
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
def test_real_ssh_failure_returns_stderr_without_hanging(self):
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
self.assertEqual(result.returncode, 255)
self.assertIn("Could not resolve hostname", result.stderr)
+191
View File
@@ -630,3 +630,194 @@ class PageQueue(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class PadEvents(unittest.TestCase):
"""The trackpad and key row go through gamescope; accents and emoji go the KDE Connect way."""
@classmethod
def setUpClass(cls):
import server
cls.pad = staticmethod(server.pad_events)
def test_moves_clicks_and_scroll(self):
out, extra = self.pad([{"dx": 3, "dy": -2}, {"singleclick": True}, {"singlehold": True},
{"singlerelease": True}, {"scroll": True, "dy": 1}, {"rightclick": True}])
self.assertEqual(out[0], {"dx": 3, "dy": -2})
self.assertEqual(out[1:3], [{"button": "left", "down": True}, {"button": "left", "down": False}])
self.assertEqual(out[3:5], [{"button": "left", "down": True}, {"button": "left", "down": False}])
self.assertEqual(out[5], {"scroll": [0, -15]}) # up = content follows the finger
self.assertEqual(out[6:], [{"button": "right", "down": True}, {"button": "right", "down": False}])
self.assertEqual(extra, [])
def test_keys_and_modifiers(self):
out, _ = self.pad([{"specialKey": 12}, {"specialKey": 1, "ctrl": True}, {"key": "c", "ctrl": True}])
self.assertEqual(out[:2], [{"key": 28, "down": True}, {"key": 28, "down": False}])
self.assertEqual([e["key"] for e in out[2:6]], [29, 14, 14, 29]) # Ctrl+Backspace
self.assertEqual([(e["key"], e["down"]) for e in out[6:]],
[(29, True), (46, True), (46, False), (29, False)]) # Ctrl+C
def test_text_stays_ascii_through_gamescope(self):
out, slow = self.pad([{"key": "Hi "}, {"key": "there"}])
self.assertEqual(out, [{"text": "Hi there"}])
self.assertEqual(slow, [])
def test_keyboard_goes_to_kde_whole_and_in_order(self):
events = [{"key": "a"}, {"key": "é"}, {"specialKey": 12}, {"key": "c", "ctrl": True}, {"dx": 2, "dy": 1}]
out, slow = self.pad(events, kde=True)
self.assertEqual(out, [{"dx": 2, "dy": 1}]) # the pointer still goes through gamescope
self.assertEqual(slow, events[:4]) # the keyboard as sent, in order
def test_shifted_key_with_modifier(self):
out, _ = self.pad([{"key": "A", "ctrl": True}])
self.assertEqual([e["key"] for e in out], [29, 42, 30, 30, 42, 29])
class PadDelivery(unittest.TestCase):
"""remote_input's promises: "sent" means the whole batch is taken; accents go once, in order."""
class Agent:
def __init__(self, sent=True, state="ready"):
self.sent, self.state, self.got = sent, state, []
def send(self, events):
if events and self.sent:
self.got.append(events)
return {"state": self.state, "sent": self.sent and bool(events)}
def setUp(self):
import server
self.s = server
self.saved = (server._touch, server._input, server.KDE_WAIT)
server._touch, server._input = self.Agent(), self.Agent()
self.reset()
def tearDown(self):
self.s._touch, self.s._input, self.s.KDE_WAIT = self.saved
self.settle()
self.reset()
def reset(self):
self.s._kde_queue.clear()
self.s._kde_until = self.s._typed_until = 0.0
self.s._kde_worker[0] = None
def settle(self):
worker = self.s._kde_worker[0]
if worker:
worker.join(5)
def typed(self):
return [e for batch in self.s._input.got for e in batch]
def test_accent_only_batch_is_acknowledged_and_typed_once(self):
status = self.s.remote_input({"events": [{"key": "é"}]})
self.assertTrue(status["sent"]) # so the page doesn't send it again
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}])
def test_mixed_text_stays_in_one_ordered_stream(self):
self.s.remote_input({"events": [{"key": "aéb"}, {"specialKey": 12}]})
self.settle()
self.assertEqual(self.typed(), [{"key": "aéb"}, {"specialKey": 12}])
self.assertEqual(self.s._touch.got, []) # none of it split off through gamescope
def test_batch_gamescope_didnt_take_is_not_queued(self):
self.s._touch = self.Agent(sent=False, state="starting")
status = self.s.remote_input({"events": [{"dx": 1, "dy": 1}, {"key": "é"}]})
self.assertFalse(status["sent"]) # the page tries the whole batch again...
self.assertEqual(self.s._kde_queue, []) # ...so the accent isn't queued twice
def test_waits_for_kde_connect_then_sends_without_more_input(self):
self.s._input = self.Agent(sent=False, state="starting")
self.s.remote_input({"events": [{"key": "é"}]})
time.sleep(0.2)
self.assertEqual(self.s._kde_queue, [{"key": "é"}])
self.s._input.sent = True
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}])
self.assertEqual(self.s._kde_queue, [])
def test_later_ascii_waits_behind_a_pending_accent(self):
self.s._input = self.Agent(sent=False, state="starting")
self.s.remote_input({"events": [{"key": "é"}]})
self.s.remote_input({"events": [{"key": "x"}]})
self.assertEqual(self.s._touch.got, []) # "x" can't overtake the "é"
self.assertEqual(self.s._kde_queue, [{"key": "é"}, {"key": "x"}])
self.s._input.sent = True
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
def test_queue_is_bounded_and_dropped_on_error(self):
self.s._input = self.Agent(sent=False, state="error")
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.settle()
self.assertEqual(self.s._kde_queue, [])
self.s._input = self.Agent(sent=False, state="starting")
self.s.KDE_WAIT = 0.3
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.assertLessEqual(len(self.s._kde_queue), self.s.KDE_QUEUE_LIMIT)
self.settle()
self.assertEqual(self.s._kde_queue, [])
def test_ascii_just_after_an_accent_stays_behind_it(self):
self.s.remote_input({"events": [{"key": "é"}]})
self.settle()
self.s.remote_input({"events": [{"key": "x"}]}) # KDE Connect may still be typing the é
self.settle()
self.assertEqual(self.s._touch.got, [])
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
def test_accent_just_after_gamescope_text_waits_for_it(self):
self.s.remote_input({"events": [{"key": "x" * 50}]})
self.assertEqual(self.s._touch.got, [[{"text": "x" * 50}]])
start = time.time()
self.s.remote_input({"events": [{"key": "é"}]})
self.settle()
self.assertGreater(time.time() - start, 0.6) # 50 keys at 16 ms, plus the margin
self.assertEqual(self.typed(), [{"key": "é"}])
def test_typing_time_counts_every_key_transition(self):
t = self.s.typing_seconds
self.assertAlmostEqual(t([{"text": "ab"}]), 4 * 0.008)
self.assertAlmostEqual(t([{"text": "A"}]), 4 * 0.008) # shift down, key down, key up, shift up
self.assertAlmostEqual(t([{"text": "x" * 500}]), 8.0) # a long paste
self.assertAlmostEqual(t([{"key": 29, "down": True}, {"dx": 3, "dy": 0}]), 0.008)
def test_the_margin_is_added_once_not_per_request(self):
for _ in range(10):
self.s.remote_input({"events": [{"key": "ab"}]})
# ten requests of 4 transitions each: the work queues up, with no margin added per request
self.assertLess(self.s._typed_until - time.time(), 10 * 4 * 0.008 + 0.05)
def test_trimming_while_sending_doesnt_drop_unsent_events(self):
slow = self.Agent()
gate = threading.Event()
send = slow.send
def held(events):
gate.wait(5)
return send(events)
slow.send = held
self.s._input = slow
self.s.remote_input({"events": [{"key": "é"}] * 150})
time.sleep(0.2) # the worker has taken its 150 and is mid-send
self.s.remote_input({"events": [{"key": "ü"}] * 150})
gate.set()
self.settle()
sent = self.typed()
self.assertEqual(sent[:150], [{"key": "é"}] * 150)
self.assertEqual(sent[150:], [{"key": "ü"}] * 150) # none of the new ones went missing
def test_input_queued_as_the_worker_finishes_is_still_sent(self):
for _ in range(40):
self.reset()
self.s._input.got.clear()
self.s.remote_input({"events": [{"key": "é"}]})
time.sleep(0.002)
self.s.remote_input({"events": [{"key": "ü"}]})
self.settle()
self.assertEqual(self.s._kde_queue, [])
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "ü"}])
-161
View File
@@ -1,161 +0,0 @@
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
staying quiet when the page goes away mid-reply, which on Windows is
ConnectionAbortedError (WinError 10053).
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import email.message
import io
import socket
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import server # noqa: E402
def platform(name):
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
LINUX=name == "linux")
class OpenRdp(unittest.TestCase):
def setUp(self):
self.xrdp = socket.socket()
self.xrdp.bind(("127.0.0.1", 0))
self.xrdp.listen(4)
self.addCleanup(self.xrdp.close)
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
port.start()
self.addCleanup(port.stop)
self.spawned = []
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
spawn.start()
self.addCleanup(spawn.stop)
cache = tempfile.TemporaryDirectory()
self.addCleanup(cache.cleanup)
self.cache = Path(cache.name)
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
where.start()
self.addCleanup(where.stop)
def test_windows_signs_in_as_steamos(self):
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
with platform("windows"):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(len(self.spawned), 1)
argv = self.spawned[0]
self.assertEqual(argv[0], "mstsc.exe")
self.assertNotIn("/v:127.0.0.1", argv)
rdp = Path(argv[1])
self.assertEqual(rdp.suffix, ".rdp")
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
self.assertNotIn(b"\r\r", data)
lines = data.decode("utf-8").split("\r\n")
self.assertIn("full address:s:127.0.0.1", lines)
self.assertIn("username:s:steamos", lines)
self.assertIn("steamos", message)
self.assertIn("Developer Mode password", message)
self.assertIn("certificate", message)
self.assertIn("Connect", message)
def test_nothing_listening_says_why_and_opens_nothing(self):
self.xrdp.close()
for name in ("windows", "mac", "linux"):
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "127.0.0.1")
self.assertIn("Developer Mode", str(cm.exception))
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_says_which_way_it_failed(self):
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
(socket.timeout("timed out"), "didn't answer"),
(OSError(65, "No route to host"), "didn't answer")):
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "frame.local")
self.assertIn(says, str(cm.exception))
self.assertNotIn("refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_server_says_it_as_the_persons_to_fix(self):
# A 400 with the message, not a 500 filed as an error diagnostic.
self.xrdp.close()
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
self.assertRaises(server.Failure) as cm:
server.open_thing({"what": "rdp"})
self.assertEqual(cm.exception.status, 400)
self.assertIn("Developer Mode", str(cm.exception))
def test_one_file_per_address(self):
with platform("windows"):
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
self.assertEqual(len({a, b, c, d}), 4)
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
def test_address_cant_add_lines_to_the_file(self):
with platform("windows"), self.assertRaises(frame_host.HostError):
frame_host.rdp_file("frame\r\nusername:s:root")
self.assertEqual(list(self.cache.iterdir()), [])
def test_linux_clients_get_the_user(self):
with platform("linux"), mock.patch.object(frame_host, "which",
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
self.assertIn("steamos", message)
class PageGoneAway(unittest.TestCase):
"""The report's server log: the page closed while index.html was being sent, and the
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
def handler(self, path="/"):
h = server.Handler.__new__(server.Handler)
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
h.headers = email.message.Message()
h.headers["Host"] = "127.0.0.1:1"
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
h.close_connection = True
return h
def test_not_a_server_error(self):
h = self.handler()
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
h.do_GET()
diagnostic.assert_not_called()
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
def test_server_logs_nothing(self):
srv = server.LoopbackServer.__new__(server.LoopbackServer)
err = io.StringIO()
with mock.patch.object(sys, "stderr", err):
try:
raise server.ClientGone()
except server.ClientGone:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertEqual(err.getvalue(), "")
try:
raise RuntimeError("real")
except RuntimeError:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertIn("RuntimeError: real", err.getvalue())
if __name__ == "__main__":
unittest.main()
+3 -4
View File
@@ -391,7 +391,6 @@ 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", "contactFollowup": True}) "contact": "me@example.com", "contactFollowup": True})
path, body = got[0] path, body = got[0]
@@ -400,7 +399,7 @@ class ReportProblem(Base):
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.assertIs(props["contact_followup"], True)
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,8 +422,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", "", "", None]
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, 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"]), \
+8
View File
@@ -147,6 +147,14 @@ class Apply(unittest.TestCase):
self.assertFalse(self.t.STALE[0]) self.assertFalse(self.t.STALE[0])
self.assertEqual(self.said[-1][1].get("stale"), None) self.assertEqual(self.said[-1][1].get("stale"), None)
def test_stale_survives_a_bare_release(self):
gs = FakeGamescope()
panel = self.t.apply(gs, {"button": "left", "down": True, "window": 99, "display": ":1"}, None)
self.assertTrue(self.t.STALE[0])
self.t.apply(gs, {"button": "left", "down": False}, panel) # the page's release names no panel
self.assertTrue(self.t.STALE[0])
self.assertEqual(self.said[-1][1].get("stale"), True)
def test_same_window_id_on_the_other_display_is_another_panel(self): def test_same_window_id_on_the_other_display_is_another_panel(self):
gs = FakeGamescope() gs = FakeGamescope()
self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None) self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None)
-162
View File
@@ -1,162 +0,0 @@
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import frame_devices as fd # noqa: E402
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
def ran(*results):
"""subprocess.run stand-in answering whoami, then icacls."""
calls = []
def run(argv, **kw):
calls.append(argv)
return results[len(calls) - 1]
return run, calls
class MakePrivate(unittest.TestCase):
def test_windows_sets_owner_only_acl_by_sid(self):
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
subprocess.CompletedProcess([], 0))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
def test_windows_falls_back_to_username_and_reports_failure(self):
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
self.assertFalse(frame_host.make_private(Path("config")))
self.assertIn("bob:F", calls[1])
@unittest.skipIf(os.name == "nt", "POSIX modes")
def test_posix_chmods_600(self):
with tempfile.NamedTemporaryFile() as f:
os.chmod(f.name, 0o644)
self.assertTrue(frame_host.make_private(f.name))
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
class ConfigWrites(unittest.TestCase):
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
self.config = self.ssh / "config"
def test_devices_and_connect_writes_make_the_file_private(self):
import frame_connect as fc
self.config.write_text("Host other\n User me\n", encoding="utf-8")
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
fc.write_config("10.0.0.5")
self.assertTrue(fd.repair_permissions(self.config))
fd.rewrite_block("frame", path=self.config, user="deck")
self.assertEqual(private.call_count, 3)
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
def test_setup_runs_isolated_as_the_app_starts_it(self):
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
self.assertNotIn("ModuleNotFoundError", r.stderr)
self.assertIn("frame_connect.py", r.stdout + r.stderr)
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
self.assertFalse(fd.repair_permissions(self.config))
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
self.config.write_bytes(data)
with mock.patch.object(frame_host, "make_private", return_value=True):
self.assertTrue(fd.repair_permissions(self.config))
self.assertEqual(self.config.read_bytes(), data)
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
self.config.write_bytes(b"Host a\n")
before = self.config.stat().st_ino
with mock.patch.object(frame_host, "make_private", return_value=False):
self.assertFalse(fd.repair_permissions(self.config))
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
class ServerRepair(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server = server
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
mock.patch.object(frame_host, "WINDOWS", True),
mock.patch.object(self.server, "_config_repaired", False)]
for p in patches:
p.start()
self.addCleanup(p.stop)
def test_repairs_the_refused_config_once(self):
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
self.assertTrue(self.server.repair_ssh_config(REFUSED))
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_called_once()
def test_leaves_other_files_and_errors_alone(self):
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
with mock.patch.object(fd, "repair_permissions") as repair:
self.assertFalse(self.server.repair_ssh_config(key))
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
with mock.patch.object(frame_host, "WINDOWS", False):
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_not_called()
def test_ssh_retries_after_repairing(self):
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
mock.patch.object(fd, "repair_permissions", return_value=True), \
mock.patch.object(self.server, "LINK", None):
self.assertEqual(self.server.ssh("true"), "ok")
class LinkLocalZone(unittest.TestCase):
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
def probe(self, windows):
import frame_link as fl
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
sock = mock.MagicMock()
with mock.patch.object(frame_host, "WINDOWS", windows), \
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
mock.patch.object(fl.socket, "socket", return_value=sock), \
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
return fl.probe("frame.local", 22)["ip"]
def test_windows_uses_the_numeric_zone(self):
self.assertEqual(self.probe(True), "fe80::1%12")
def test_elsewhere_uses_the_interface_name(self):
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -47,7 +47,7 @@ def ssh(cmd, input=None, timeout=120):
try: try:
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it. # No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL} feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed, p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None) timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise FrameError(f'timed out talking to {FRAME}') raise FrameError(f'timed out talking to {FRAME}')
@@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
else: else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}'] cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try: try:
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout) subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out') raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
+1 -2
View File
@@ -11,14 +11,13 @@ import tempfile
import uuid import uuid
import frame_android as android import frame_android as android
import frame_host
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py' REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
def _stream(command, src=None, dst=None): def _stream(command, src=None, dst=None):
try: try:
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command], result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
stdin=src if src else subprocess.DEVNULL, stdin=src if src else subprocess.DEVNULL,
stdout=dst if dst else subprocess.PIPE, stdout=dst if dst else subprocess.PIPE,
stderr=subprocess.PIPE, timeout=1800) stderr=subprocess.PIPE, timeout=1800)
+3 -8
View File
@@ -24,10 +24,6 @@ import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
# The app runs this with python -I, which leaves the script's folder off sys.path.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_host # noqa: E402
FRAME_USER = os.environ.get("FRAME_USER", "steamos") FRAME_USER = os.environ.get("FRAME_USER", "steamos")
USER_FROM_ENV = "FRAME_USER" in os.environ USER_FROM_ENV = "FRAME_USER" in os.environ
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame") FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
@@ -334,9 +330,8 @@ def _write_config(host, port, user):
block = config_block(host, port, user) block = config_block(host, port, user)
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp") tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8") tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
if not frame_host.make_private(tmp): if os.name != "nt":
say(" couldn't make ~/.ssh/config private; if ssh says \"Bad owner or permissions\", " tmp.chmod(0o600)
"Frame Control repairs it when it next connects")
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open # On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
# and locked, so the swap can fail for a moment; keep trying for a while. # and locked, so the swap can fail for a moment; keep trying for a while.
for attempt in range(60): for attempt in range(60):
@@ -354,7 +349,7 @@ def _write_config(host, port, user):
def key_login_works(): def key_login_works():
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails. # accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"], "-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0 capture_output=True).returncode == 0
+2 -36
View File
@@ -71,31 +71,6 @@ def valid_email(email):
return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(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(): def state():
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet, """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.""" and the Frame has connected at least once (setup worked), so it never greets a new install."""
@@ -180,14 +155,8 @@ def redact_removed(event, started):
def save(body): def save(body):
"""Set, change or remove the address and the two choices. An address needs at least one """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.""" 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() email = str(body.get('email') or '').strip()
updates, followup = flag(body, 'updates'), flag(body, 'followup') updates, followup = bool(body.get('updates')), bool(body.get('followup'))
if email and not valid_email(email): if email and not valid_email(email):
raise ValueError("that doesn't look like an email address") raise ValueError("that doesn't look like an email address")
if email and not (updates or followup): if email and not (updates or followup):
@@ -212,12 +181,9 @@ def _apply(body):
_forget_locally(old) _forget_locally(old)
except OSError: except OSError:
pass pass
return changed, s['id'], s['rev']
def _deliver(changed):
if changed and not _send_pending(block=False): if changed and not _send_pending(block=False):
_wake.set() # offline, or a send under way that will take this change with it _wake.set() # offline, or a send under way that will take this change with it
return state()
def prompt(body): def prompt(body):
+4 -34
View File
@@ -241,7 +241,8 @@ def _write_config(path, text, expected):
try: try:
with os.fdopen(fd_, "w", encoding="utf-8") as fh: with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write(text) fh.write(text)
frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists) if not frame_host.WINDOWS:
tmp.chmod(0o600)
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
if read_config(path) != expected: if read_config(path) != expected:
return False return False
@@ -270,37 +271,6 @@ def _edit_config(path, change):
raise OSError(f"{path} kept changing while Frame Control tried to update it") raise OSError(f"{path} kept changing while Frame Control tried to update it")
def repair_permissions(path=None):
"""Give ~/.ssh/config make_private's ACL by swapping in a byte-for-byte copy: for a
file Windows' OpenSSH refuses ("Bad owner or permissions"). -> True only if the copy
got that ACL and replaced the file."""
path = Path(path or ssh_config())
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
try:
data = path.read_bytes()
except OSError:
return False
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
tmp = Path(tmp)
try:
with os.fdopen(fd_, "wb") as fh:
fh.write(data)
if not frame_host.make_private(tmp):
return False
for attempt in range(20): # a running ssh.exe can hold the file for a moment
if path.read_bytes() != data:
return False
try:
os.replace(tmp, path)
return True
except PermissionError:
time.sleep(0.25)
return False
finally:
if tmp.exists():
tmp.unlink()
def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None): def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the """Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
file alone. -> True if the file changed. Does nothing if there's no such block, or file alone. -> True if the file changed. Does nothing if there's no such block, or
@@ -363,7 +333,7 @@ def remove_block(alias, path=None):
def effective_port(alias, config): def effective_port(alias, config):
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22.""" """The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
try: try:
out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True, out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=10).stdout stdin=subprocess.DEVNULL, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return 22 return 22
@@ -376,7 +346,7 @@ def effective_port(alias, config):
def _keygen(*args): def _keygen(*args):
try: try:
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10) timeout=10)
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return None return None
+8 -128
View File
@@ -5,16 +5,12 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
CLI (used by the Electron app, so terminal handling lives in one place): CLI (used by the Electron app, so terminal handling lives in one place):
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
""" """
import hashlib
import io
import os import os
import shlex import shlex
import shutil import shutil
import socket
import ssl import ssl
import subprocess import subprocess
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
MAC = sys.platform == "darwin" MAC = sys.platform == "darwin"
@@ -36,45 +32,6 @@ class HostError(RuntimeError):
pass pass
class Unreachable(HostError):
"""The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
def run_ssh(argv, **kwargs):
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
A real temporary file avoids OpenSSH's blocked asynchronous stderr writes,
while keeping subprocess.run's captured output, text, check and timeout API.
"""
if not WINDOWS:
return subprocess.run(argv, **kwargs)
if kwargs.pop("capture_output", False):
if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
raise ValueError("stdout and stderr arguments may not be used with capture_output")
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if kwargs.get("stderr") != subprocess.PIPE:
return subprocess.run(argv, **kwargs)
check = kwargs.pop("check", False)
text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors"))
with tempfile.TemporaryFile() as stderr:
kwargs["stderr"] = stderr
try:
result = subprocess.run(argv, **kwargs)
except subprocess.TimeoutExpired as error:
stderr.seek(0)
error.stderr = stderr.read()
raise
stderr.seek(0)
if text:
with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader:
result.stderr = reader.read()
else:
result.stderr = stderr.read()
if check:
result.check_returncode()
return result
def data_dir(*parts): def data_dir(*parts):
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME """Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory).""" (or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
@@ -284,46 +241,10 @@ def clipboard_text():
raise HostError("Can't read the clipboard") raise HostError("Can't read the clipboard")
# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
BAD_PERMISSIONS = "Bad owner or permissions on "
def make_private(path):
"""Leave only this user able to open PATH, as ssh insists for ~/.ssh/config.
Windows: an ACL of just this user, SYSTEM and Administrators, inherited nothing.
A file written into ~/.ssh otherwise takes the folder's ACL, and Windows' OpenSSH
refuses it if that grants anyone else, even an account deleted long ago
("Bad owner or permissions"). Best effort: -> False if it couldn't."""
if not WINDOWS:
try:
os.chmod(path, 0o600)
return True
except OSError:
return False
me = os.environ.get("USERNAME", "")
try: # "desktop\me","S-1-5-21-..."
# Bytes: the account name is in the console's code page, the SID is ASCII.
out = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True,
stdin=subprocess.DEVNULL, timeout=10).stdout
sid = out.decode("ascii", "replace").strip().rsplit(",", 1)[-1].strip('"')
if sid.startswith("S-1-"):
me = "*" + sid
except (OSError, subprocess.TimeoutExpired):
pass
if not me:
return False
try:
return subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"{me}:F",
"*S-1-5-18:F", "*S-1-5-32-544:F"], capture_output=True,
stdin=subprocess.DEVNULL, timeout=10).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def ssh_hostname(alias): def ssh_hostname(alias):
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP.""" """The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
try: try:
out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return alias return alias
for line in out.splitlines(): for line in out.splitlines():
@@ -356,65 +277,24 @@ def open_steam_link():
return "Steam Link isn't installed; opened its download page" return "Steam Link isn't installed; opened its download page"
RDP_PORT = 3389
RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's
# xrdp's certificate is its own, so every client warns about it first.
RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} "
"with your Developer Mode password")
def check_rdp(host, timeout=3):
"""Raise Unreachable, saying why, unless the Frame's RDP port takes a connection."""
try:
with socket.create_connection((host, RDP_PORT), timeout=timeout):
return
except ConnectionRefusedError:
raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} "
"refused). Turn on Developer Mode in Steam Settings > System on the headset, "
"then restart it and try again.") from None
except socket.gaierror:
raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's "
"address on the Devices tab.") from None
except OSError as e:
raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, "
"switched off or on another network; if it's on, check Developer Mode is on "
"in Steam Settings > System.") from None
def rdp_file(host):
"""A Remote Desktop connection file for the Frame. mstsc /v: alone offers this
computer's Windows account, which xrdp turns away; the file names steamos instead."""
if any(c in host for c in "\r\n"):
raise HostError("That headset address can't be used for remote desktop")
# One file per address, so two launches close together can't swap headsets.
path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp")
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+
f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n")
return path
def open_rdp(alias, host=None): def open_rdp(alias, host=None):
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points.""" """Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
host = host or ssh_hostname(alias) host = host or ssh_hostname(alias)
# The client would open either way and then fail on its own, with nothing said here.
check_rdp(host)
if MAC: if MAC:
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0: if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
return f"Opened Windows App: connect to {host} and {RDP_LOGIN}" return "Opened Windows App"
open_url("https://apps.apple.com/app/windows-app/id1295203466") open_url("https://apps.apple.com/app/windows-app/id1295203466")
return "Windows App isn't installed; opened its App Store page" return "Windows App isn't installed; opened its App Store page"
if WINDOWS: if WINDOWS:
_spawn(["mstsc.exe", str(rdp_file(host))]) _spawn(["mstsc.exe", f"/v:{host}"])
# Windows asks about the unsigned connection file first. return f"Opened Remote Desktop to {host}"
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
if which("remmina"): if which("remmina"):
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"]) _spawn(["remmina", "-c", f"rdp://steamos@{host}"])
return f"Opened Remmina to {host}: {RDP_LOGIN}" return f"Opened Remmina to {host}"
for name in ("xfreerdp3", "xfreerdp"): for name in ("xfreerdp3", "xfreerdp"):
if which(name): if which(name):
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"]) _spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}: {RDP_LOGIN}" return f"Opened FreeRDP to {host}"
raise HostError("No RDP client found: install Remmina or FreeRDP") raise HostError("No RDP client found: install Remmina or FreeRDP")
+5 -6
View File
@@ -74,7 +74,7 @@ def ssh_g(alias):
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an """(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it.""" ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
try: try:
out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10).stdout timeout=10).stdout
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
out = "" out = ""
@@ -108,8 +108,7 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
ip = addr[0] ip = addr[0]
if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip: if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip:
try: # a link-local IPv6 address only works with its interface try: # a link-local IPv6 address only works with its interface
# Windows' ssh takes only the number: its names ("wireless_32768") don't resolve. ip = f"{ip}%{socket.if_indextoname(addr[3])}"
ip = f"{ip}%{addr[3] if frame_host.WINDOWS else socket.if_indextoname(addr[3])}"
except (OSError, AttributeError): except (OSError, AttributeError):
pass pass
left = deadline - now() left = deadline - now()
@@ -766,7 +765,7 @@ class Link:
if not self.control: if not self.control:
return False return False
try: try:
return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True, return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
stdin=subprocess.DEVNULL, timeout=5).returncode == 0 stdin=subprocess.DEVNULL, timeout=5).returncode == 0
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return False return False
@@ -778,7 +777,7 @@ class Link:
pending.kill() pending.kill()
if self.control and self.alias: if self.control and self.alias:
try: try:
frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True, subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
stdin=subprocess.DEVNULL, timeout=5) stdin=subprocess.DEVNULL, timeout=5)
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
pass pass
@@ -984,7 +983,7 @@ class Link:
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))), *self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"] "-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
try: try:
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True, r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=20) errors="replace", timeout=20)
err = r.stderr.strip() err = r.stderr.strip()
if r.returncode == 0: if r.returncode == 0:
+9 -47
View File
@@ -112,22 +112,18 @@ def send(body):
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError.""" """Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
kind = body.get('kind') if body.get('kind') in KINDS else 'bug' kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
title, text, diag = compose(body) title, text, diag = compose(body)
followup = frame_contact.flag(body, 'contactFollowup') followup = bool(body.get('contactFollowup'))
contact = str(body.get('contact') or '').strip() if followup else '' contact = str(body.get('contact') or '').strip() if followup else ''
if followup and not frame_contact.valid_email(contact): if followup and not frame_contact.valid_email(contact):
raise ValueError('add your email address for follow-up questions, or untick that box') 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': contact, 'contact_followup': followup, 'diagnostics': diag,
# Only with an address: a later change from this copy (higher rev) can take it back.
'contact_id': contact_id, 'contact_rev': contact_rev,
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'} 'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
# Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics. # Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics.
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()), event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props} 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props}
started = time.time()
try: try:
frame_telemetry.post([event], timeout=30) frame_telemetry.post([event], timeout=30)
except frame_telemetry.SendError as e: except frame_telemetry.SendError as e:
@@ -147,50 +143,15 @@ 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 " "properties.contact_followup "
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {days} DAY " f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(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): def _yes(v):
@@ -243,13 +204,14 @@ def main():
if cmd != 'inbox': if cmd != 'inbox':
sys.exit(USAGE) sys.exit(USAGE)
for row in inbox(*(args[:1] or [30])): for row in inbox(*(args[:1] or [30])):
if not isinstance(row, list) or len(row) != 11:
continue
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10]) ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
# Reports from before contact_followup existed only carried an address given for a reply. # Reports from before contact_followup existed only carried an address given for a reply.
reply = contact and (row[10] is None or _yes(row[10])) reply = contact and (row[10] is None or _yes(row[10]))
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}") print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}" print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}"
f"{', may follow up at ' + contact if reply 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 '))
+3 -1
View File
@@ -369,8 +369,10 @@ def apply(gs, event, panel):
say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs
STALE[0] = True STALE[0] = True
return panel return panel
if STALE[0] and not stale: release = event.get("down") is False and ("button" in event or "key" in event)
if STALE[0] and not stale and not (release and "window" not in event):
# Anything that goes through (a trackpad move names no panel) means caught up: stop re-syncing. # Anything that goes through (a trackpad move names no panel) means caught up: stop re-syncing.
# A bare release doesn't: the page leaves the panel off releases, so it says nothing about focus.
STALE[0] = False STALE[0] = False
say("ready", focus=(panel or {}).get("window"), display=(panel or {}).get("display")) say("ready", focus=(panel or {}).get("window"), display=(panel or {}).get("display"))
if "fx" in event and panel and panel.get("window"): if "fx" in event and panel and panel.get("window"):
+11 -28
View File
@@ -876,10 +876,9 @@
<button data-pad-key="12" title="Enter">Enter</button> <button data-pad-key="12" title="Enter">Enter</button>
</span> </span>
</div> </div>
<div class="hint">Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus <div class="hint">Types and points in whichever panel has focus in the headset, through Valve's own input path: nothing to
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build install and no internet needed. Accents and emoji go through KDE Connect, which comes with Frame Control and starts
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing the first time you type one (it copies Valve's build for the Frame, 3.6 MB, into your home folder there). <a href="#" data-about>Licences</a></div>
else to install. <a href="#" data-about>Licences</a></div>
</section> </section>
<section id="shots"> <section id="shots">
@@ -1268,9 +1267,8 @@
<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="popt"><input type="checkbox" id="bugFollowup"><b>The maintainer may contact me with follow-up questions</b>
<span class="sub">Optional. Your email address goes with this report only when this is ticked, and is kept as your contact email in Privacy &amp; updates, where you can remove it.</span></label> <span class="sub">Optional. Your email address goes with this report only when this is ticked.</span></label>
<label class="field">Your email address<input type="email" id="bugContact" maxlength="254" placeholder="you@example.com" disabled></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>
@@ -3902,10 +3900,10 @@ const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=c
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target))); document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
document.addEventListener("focusout", () => document.body.classList.remove("typing")); document.addEventListener("focusout", () => document.body.classList.remove("typing"));
// ---- keyboard and trackpad: events go to KDE Connect on the Frame (see ui/frame_input_agent.py) ---- // ---- keyboard and trackpad: events go to the Frame through gamescope (ui/frame_touch.py); accents and emoji through KDE Connect ----
const pad = { state: "off", queue: [], sending: false, poll: null, captured: false }; const pad = { state: "off", queue: [], sending: false, poll: null, captured: false };
const PAD_STATES = { const PAD_STATES = {
off: "", starting: "Connecting…", installing: "Setting up KDE Connect on the Frame (first time only)…", off: "", starting: "Connecting…", installing: "Setting up (first time only)…",
pairing: "Pairing with KDE Connect…", ready: "On", pairing: "Pairing with KDE Connect…", ready: "On",
}; };
// KDE Connect's specialKey numbers (plugins/mousepad in its source). // KDE Connect's specialKey numbers (plugins/mousepad in its source).
@@ -3923,7 +3921,7 @@ function padShow(status) {
$("padOn").hidden = ready || busy; $("padOn").hidden = ready || busy;
$("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on"; $("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on";
$("padArea").classList.toggle("off", !ready); $("padArea").classList.toggle("off", !ready);
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame." $("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach the Frame."
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.") : !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click" : TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
: pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop." : pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop."
@@ -4132,7 +4130,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();
@@ -4143,7 +4140,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() {
@@ -4186,14 +4182,13 @@ async function loadContact() {
try { renderContact(await api("/api/contact")); } catch { return; } try { renderContact(await api("/api/contact")); } catch { return; }
checkContactPrompt(); checkContactPrompt();
} }
// One time only, only once the Frame has connected, and never in a visit that showed the privacy // One time only, only once the Frame has connected, and never on top of the privacy notice or
// notice (two asks in a row is nagging): checked at load and whenever the Frame connects. // straight after it (two asks in a row is nagging): checked at load and whenever the Frame connects.
async function checkContactPrompt() { async function checkContactPrompt() {
await telemetryLoaded; if (!$("contactNotice").hidden || !$("privacyNotice").hidden) return;
if (privacyNoticeShown || !$("contactNotice").hidden) return;
let s; let s;
try { s = await api("/api/contact"); } catch { return; } try { s = await api("/api/contact"); } catch { return; }
if (!s.showPrompt || privacyNoticeShown || !$("contactNotice").hidden) return; if (!s.showPrompt || !$("contactNotice").hidden || !$("privacyNotice").hidden) return;
$("contactNotice").hidden = false; $("contactNotice").hidden = false;
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {}); api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
} }
@@ -4261,7 +4256,6 @@ function openBugReport() {
// A standing yes to follow-up questions (Privacy & updates) fills this in; it can be unticked. // 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 : ""; $("bugFollowup").checked = contact.followup; $("bugContact").value = contact.followup ? contact.email : "";
$("bugContact").disabled = !contact.followup; $("bugContact").required = contact.followup; $("bugContact").disabled = !contact.followup; $("bugContact").required = contact.followup;
bugReplaces();
$("bugDlg").showModal(); $("bugDlg").showModal();
loadBugPreview(); loadBugPreview();
} }
@@ -4272,17 +4266,7 @@ $("bugFollowup").onchange = () => {
const on = $("bugFollowup").checked; const on = $("bugFollowup").checked;
$("bugContact").disabled = !on; $("bugContact").required = on; $("bugContact").disabled = !on; $("bugContact").required = on;
if (on && !$("bugContact").value) { $("bugContact").value = contact.email; $("bugContact").focus(); } 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();
@@ -4305,7 +4289,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);
+270 -55
View File
@@ -60,6 +60,7 @@ import frame_report # noqa: E402
import frame_store # noqa: E402 import frame_store # noqa: E402
import frame_telemetry # noqa: E402 import frame_telemetry # noqa: E402
import frame_titles # noqa: E402 import frame_titles # noqa: E402
import frame_touch # noqa: E402
import frame_webinstall # noqa: E402 import frame_webinstall # noqa: E402
import frame_vr # noqa: E402 import frame_vr # noqa: E402
import frame_utilities # noqa: E402 import frame_utilities # noqa: E402
@@ -134,7 +135,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
@@ -334,14 +334,12 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
# Never let ssh inherit our stdin: under the app it's the pipe held open for # Never let ssh inherit our stdin: under the app it's the pipe held open for
# --exit-on-eof, and Windows' ssh.exe waits on it forever. # --exit-on-eof, and Windows' ssh.exe waits on it forever.
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL} feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed, r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout) text=text, errors="replace" if text else None, timeout=timeout)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise Failure(f"Timed out talking to {FRAME}") raise Failure(f"Timed out talking to {FRAME}")
if r.returncode != 0: if r.returncode != 0:
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace") err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
if r.returncode == 255 and repair_ssh_config(err):
return ssh(remote, stdin=stdin, timeout=timeout, text=text)
if r.returncode == 255 and LINK and unreachable(err): if r.returncode == 255 and LINK and unreachable(err):
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}") failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
@@ -350,30 +348,6 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
return r.stdout return r.stdout
_config_repaired = False
def repair_ssh_config(err):
"""Windows' OpenSSH refused ~/.ssh/config for its ACL: give the file a private one,
once per run. -> True if it did, so the command is worth retrying."""
global _config_repaired
if _config_repaired or not frame_host.WINDOWS or frame_host.BAD_PERMISSIONS not in err:
return False
# ssh doubles the backslashes: "C:\\Users\\me/.ssh/config"
named = re.sub(r"[\\/]+", "/", err.split(frame_host.BAD_PERMISSIONS, 1)[1].splitlines()[0].strip())
config = frame_devices.ssh_config()
if not named.lower().endswith("/" + config.name.lower()):
return False # a key or another file: not ours to rewrite
_config_repaired = True
try:
if frame_devices.repair_permissions(config):
print(f"Gave {config} a private ACL: ssh refused it ({named})", file=sys.stderr)
return True
except OSError as e:
print(f"Couldn't repair {config}'s permissions: {e}", file=sys.stderr)
return False
def strip_ansi(s): def strip_ansi(s):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s) return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
@@ -531,7 +505,7 @@ def save_shots(body):
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR)) incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
try: try:
try: try:
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)], r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300) capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out") raise Failure("Copying screenshots timed out")
@@ -1032,12 +1006,274 @@ def licenses():
return notices return notices
# KDE Connect's specialKey numbers -> Linux key codes (KEY_BACKSPACE, KEY_TAB, ...).
PAD_KEYS = {1: 14, 2: 15, 4: 105, 5: 103, 6: 106, 7: 108, 8: 104, 9: 109, 10: 102, 11: 107, 12: 28, 13: 111, 14: 1,
**{21 + i: code for i, code in enumerate([59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88])}}
PAD_MODS = (("ctrl", 29), ("alt", 56), ("shift", 42), ("super", 125))
PAD_CLICKS = {"singleclick": "left", "rightclick": "right", "middleclick": "middle"}
PAD_NOTCH = 15 # scroll units for one wheel notch
def typed_in_kde(events):
"""Whether these events must go the KDE Connect way: they hold an accent or emoji, or
earlier ones are queued, being sent or still being typed there (typing must stay in
order, so it can't be split between the two ways)."""
with _kde_lock:
waiting = bool(_kde_queue) or time.time() < _kde_until
return waiting or any(ch not in frame_touch.ASCII for e in events for ch in e.get("key", ""))
def pad_events(events, kde=False):
"""The trackpad's and key row's events as (gamescope events, KDE Connect events).
Gamescope's own input reaches every panel and needs nothing installed, but types only
US-keyboard characters. With kde, the keyboard goes to KDE Connect as it was sent, so
text stays in order; the pointer always goes through gamescope.
"""
ascii_keys = frame_touch.ASCII
out, slow = [], []
def tap(code, mods):
down = [{"key": c, "down": True} for c in mods]
out.extend(down + [{"key": code, "down": True}, {"key": code, "down": False}]
+ [{"key": c, "down": False} for c in reversed(mods)])
for e in events:
mods = [code for name, code in PAD_MODS if e.get(name)]
if "dx" in e or "dy" in e:
if not e.get("scroll"):
out.append({k: e[k] for k in ("dx", "dy") if k in e})
if e.get("scroll"):
out.append({"scroll": [0, -(e.get("dy") or 0) * PAD_NOTCH]})
for flag, button in PAD_CLICKS.items():
if e.get(flag):
out += [{"button": button, "down": True}, {"button": button, "down": False}]
if e.get("doubleclick"):
out += [{"button": "left", "down": d} for d in (True, False, True, False)]
if e.get("singlehold"):
out.append({"button": "left", "down": True})
if e.get("singlerelease"):
out.append({"button": "left", "down": False})
if kde and ("key" in e or "specialKey" in e):
slow.append({k: e[k] for k in ("key", "specialKey", "ctrl", "alt", "shift", "super") if k in e})
continue
if "specialKey" in e and e["specialKey"] in PAD_KEYS:
tap(PAD_KEYS[e["specialKey"]], mods)
if "key" in e:
for ch in e["key"]:
if ch not in ascii_keys:
continue # only reached when kde is wrong; the caller checks typed_in_kde first
elif mods:
code, shifted = ascii_keys[ch]
tap(code, mods + ([42] if shifted and 42 not in mods else []))
else:
if out and "text" in out[-1] and len(out[-1]["text"]) < 100:
out[-1]["text"] += ch
else:
out.append({"text": ch})
return out, slow
def remote_input(body): def remote_input(body):
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent.""" """{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent.
"sent" in the answer means the whole batch is taken (the page keeps it and tries again
if not), so the keyboard's share is queued only once that's so.
"""
events = body.get("events", []) events = body.get("events", [])
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT: if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400) raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
return _input.send([input_event(e) for e in events]) events = [input_event(e) for e in events]
touch, slow = pad_events(events, typed_in_kde(events))
status = _touch.send(touch)
if touch and not status.get("sent"):
return status
if touch:
_note_typed(touch)
if slow:
_queue_for_kde(slow)
return {**status, "sent": True}
# Typing KDE Connect is to do: it starts the first time it's needed, so this waits for it.
# Nothing on the Frame says when typed text has landed, so each way holds the other back
# for as long as what it was given should take (a short margin on a time estimate).
_kde_queue = []
_kde_lock = threading.Lock()
_kde_worker = [None]
_kde_until = 0.0 # keys typed through KDE Connect should have landed by then
_typed_until = 0.0 # when gamescope's agent will have typed everything it's been given
KDE_QUEUE_LIMIT = 200
KDE_WAIT = 90 # seconds to wait for KDE Connect before giving up on what's queued
TYPING_SETTLE = 0.6 # seconds past the estimate, once
def typing_seconds(touch):
"""How long gamescope's agent takes over these: it sleeps 8 ms after every key transition."""
transitions = 0
for e in touch:
if "text" in e:
transitions += sum(2 + 2 * frame_touch.ASCII[ch][1] for ch in e["text"] if ch in frame_touch.ASCII)
elif "key" in e:
transitions += 1
return transitions * 0.008
def _note_typed(touch):
global _typed_until
seconds = typing_seconds(touch)
if seconds:
with _kde_lock: # the end of the work, which queues up; the margin is added once, where it's checked
_typed_until = max(_typed_until, time.time()) + seconds
def _queue_for_kde(events):
with _kde_lock:
_kde_queue.extend(events)
del _kde_queue[:-KDE_QUEUE_LIMIT]
if _kde_worker[0] is None:
_kde_worker[0] = threading.Thread(target=_drain_kde, daemon=True)
_kde_worker[0].start()
def _drain_kde():
"""The only sender, in order. A batch leaves the queue while it's being sent (the queue
can be trimmed meanwhile) and goes back to the front if it didn't go."""
global _kde_until
deadline = time.time() + KDE_WAIT
while True:
with _kde_lock:
batch = _kde_queue[:INPUT_BATCH_LIMIT]
del _kde_queue[:len(batch)]
if not batch:
_kde_worker[0] = None # retired under the lock, so a new request starts another
return
wait = _typed_until + TYPING_SETTLE - time.time()
_kde_until = time.time() + 3600 # held while in flight
if wait > 0:
time.sleep(wait) # gamescope's typing, from just before, goes first
status = _input.send(batch)
with _kde_lock:
if status.get("sent"):
_kde_until = time.time() + len(batch) * 0.03 + TYPING_SETTLE
deadline = time.time() + KDE_WAIT
continue
_kde_until = 0.0
if status.get("state") == "error" or time.time() > deadline:
_kde_queue.clear() # not coming; don't hold it forever
_kde_worker[0] = None
return
_kde_queue[:0] = batch
del _kde_queue[:-KDE_QUEUE_LIMIT] # as when queuing: the oldest go first
time.sleep(0.4)
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
PANEL_WINDOW = re.compile(r"^\d{1,10}$")
PANEL_DISPLAY = re.compile(r"^:\d{1,2}$")
TOUCH_BUTTONS = ("left", "right", "middle")
def panels():
"""The headset's app panels (window, display, name, size) and which has focus."""
return json.loads(ssh("python3 - panels", stdin=(HERE / "frame_touch.py").read_text(), timeout=20))
def panel_target(q):
window, display = (q.get("window") or [""])[0], (q.get("display") or [""])[0]
if not PANEL_WINDOW.match(window) or not PANEL_DISPLAY.match(display):
raise Failure("window must be a window id and display an X display such as :1", 400)
return window, display
def panel_capture(query):
"""One frame of a panel's own window, as PNG (its pixels, without the room around it)."""
window, display = panel_target(parse_qs(query))
return ssh(f"DISPLAY={display} timeout 10 ffmpeg -hide_banner -loglevel error -nostdin -f x11grab "
f"-window_id {window} -i {display} -frames:v 1 -f image2pipe -c:v png -", timeout=20, text=False)
def touch_event(event):
"""A frame_touch.py event with only the fields it knows, in range."""
if not isinstance(event, dict):
raise Failure("each touch event must be an object", 400)
def num(name, limit):
value = event.get(name)
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
raise Failure(f"{name} must be a number", 400)
return max(-limit, min(limit, round(float(value), 4)))
out = {}
if "fx" in event or "fy" in event:
if "window" not in event:
raise Failure("a position needs the panel's window and display", 400)
out.update(fx=num("fx", 1), fy=num("fy", 1))
# Any event can name the panel it's meant for; the Frame drops it if another has focus.
if "window" in event:
window, display = event.get("window"), event.get("display")
if isinstance(window, bool) or not isinstance(window, int) or window <= 0:
raise Failure("window must be the panel's window id", 400)
if not isinstance(display, str) or not PANEL_DISPLAY.match(display):
raise Failure("display must be an X display such as :1", 400)
out.update(window=window, display=display)
for name in ("dx", "dy"):
if name in event:
out[name] = num(name, INPUT_MOVE_LIMIT)
if "button" in event:
if event["button"] not in TOUCH_BUTTONS:
raise Failure(f"button must be one of {', '.join(TOUCH_BUTTONS)}", 400)
out["button"], out["down"] = event["button"], event.get("down") is not False
if "scroll" in event:
sc = event["scroll"]
if not isinstance(sc, list) or len(sc) != 2:
raise Failure("scroll must be [dx, dy]", 400)
out["scroll"] = [num_value(v, 5000) for v in sc]
if "key" in event:
key = event["key"]
if isinstance(key, bool) or not isinstance(key, int) or not 0 < key < 768:
raise Failure("key must be a Linux key code", 400)
out["key"], out["down"] = key, event.get("down") is not False
if "text" in event:
text = event["text"]
if not isinstance(text, str) or not 0 < len(text) <= INPUT_TEXT_LIMIT:
raise Failure(f"text must be 1 to {INPUT_TEXT_LIMIT} characters", 400)
out["text"] = text
if not set(out) - {"window", "display"}:
raise Failure("touch event has nothing to do", 400)
return out
def num_value(value, limit):
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
raise Failure("scroll values must be numbers", 400)
return max(-limit, min(limit, round(float(value), 2)))
class TouchAgent(InputAgent):
"""frame_touch.py on the Frame, fed events over one long-lived ssh. Nothing to install:
it uses gamescope's own input socket and the libei that's on the image."""
def __init__(self):
super().__init__(source=HERE / "frame_touch.py", packages=[])
def deliver(self, report, force=False):
return ""
def command(self, folder=""):
code = base64.b64encode(self.source.read_bytes()).decode()
return "python3 -u -c " + shlex.quote(
f"import base64;exec(compile(base64.b64decode('{code}'),'frame_touch','exec'))")
_touch = TouchAgent()
def remote_touch(body):
"""{"events": [...]} points, clicks, scrolls and types into the focused panel."""
events = body.get("events", [])
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
return _touch.send([touch_event(e) for e in events])
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ---- # ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
@@ -1226,8 +1462,6 @@ def open_thing(body):
raise Failure("That screenshot isn't saved on this computer yet", 404) raise Failure("That screenshot isn't saved on this computer yet", 404)
frame_host.reveal_path(saved) frame_host.reveal_path(saved)
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"} return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
except frame_host.Unreachable as e:
raise Failure(str(e), 400) # theirs to turn on; nothing failed here
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)
@@ -2283,7 +2517,7 @@ def push_file(path, dest="Downloads/"):
else: else:
# Modern scp uses SFTP, so the remote path isn't parsed by a shell. # Modern scp uses SFTP, so the remote path isn't parsed by a shell.
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"] cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600) r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise Failure(f"Copying {name} timed out") raise Failure(f"Copying {name} timed out")
if r.returncode != 0: if r.returncode != 0:
@@ -2291,11 +2525,6 @@ def push_file(path, dest="Downloads/"):
return f"Sent {name} to ~/{dest}" return f"Sent {name} to ~/{dest}"
class ClientGone(Exception):
"""The page went away (a reload, the app quitting) before its reply was written:
nobody to answer, and nothing went wrong here."""
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
server_version = "FrameControl/1" server_version = "FrameControl/1"
timeout = 60 # per socket operation, so a stalled client can't hold a thread timeout = 60 # per socket operation, so a stalled client can't hold a thread
@@ -2328,11 +2557,8 @@ class Handler(BaseHTTPRequestHandler):
# Nobody may frame the UI (clickjacking). # Nobody may frame the UI (clickjacking).
self.send_header("X-Frame-Options", "DENY") self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", "frame-ancestors 'none'") self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
try:
self.end_headers() self.end_headers()
self.wfile.write(data) self.wfile.write(data)
except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError
raise ClientGone() from e
def send_json(self, obj, status=200): def send_json(self, obj, status=200):
self.send_bytes(json.dumps(obj).encode(), "application/json", status) self.send_bytes(json.dumps(obj).encode(), "application/json", status)
@@ -2375,8 +2601,6 @@ class Handler(BaseHTTPRequestHandler):
from apk_sources import _images from apk_sources import _images
try: try:
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1])) self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
except ClientGone:
raise
except Exception: except Exception:
self.send_json({"error": "Artwork unavailable"}, 404) self.send_json({"error": "Artwork unavailable"}, 404)
elif path == "/api/sources/details": elif path == "/api/sources/details":
@@ -2411,7 +2635,7 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/touch": elif path == "/api/touch":
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status)) self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
elif path == "/api/input": elif path == "/api/input":
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status)) self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
elif path == "/api/job": elif path == "/api/job":
self.send_json(job_status(url.query)) self.send_json(job_status(url.query))
elif path == "/api/android/displays": elif path == "/api/android/displays":
@@ -2454,8 +2678,6 @@ class Handler(BaseHTTPRequestHandler):
headers=[("X-Capture-Source", "gamescope")]) headers=[("X-Capture-Source", "gamescope")])
else: else:
self.send_json({"error": "not found"}, 404) self.send_json({"error": "not found"}, 404)
except ClientGone:
raise
except Failure as e: except Failure as e:
self.send_error_json(str(e), e.status, e.apk) self.send_error_json(str(e), e.status, e.apk)
except ValueError as e: except ValueError as e:
@@ -2487,12 +2709,9 @@ 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 ClientGone:
raise
except Failure as e: except Failure as e:
if e.status >= 500: if e.status >= 500:
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e) frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
@@ -2668,10 +2887,6 @@ class LoopbackServer(ThreadingHTTPServer):
socketserver.TCPServer.server_bind(self) socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = "127.0.0.1", self.server_address[1] self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
def handle_error(self, request, client_address):
if not isinstance(sys.exc_info()[1], ClientGone):
super().handle_error(request, client_address)
_ONE_SERVER = None _ONE_SERVER = None