mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f98f3e728b | ||
|
|
90034540ae | ||
|
|
bc0e0fc043 | ||
|
|
5b4efc87b3 | ||
|
|
01ba796881 | ||
|
|
b9f96866e5 | ||
|
|
8e8c24f370 | ||
|
|
79dda60bde | ||
|
|
0bb2f9eb03 | ||
|
|
d4b34e1bad | ||
|
|
22548bc42f | ||
|
|
1863c92101 | ||
|
|
0bceb5c928 | ||
|
|
22bb9c6469 | ||
|
|
fab04f0558 | ||
|
|
c0ae75d56b | ||
|
|
0520082586 | ||
|
|
36dd5a05f2 | ||
|
|
4b1bb1557d | ||
|
|
b151ae691e | ||
|
|
36db6b1ac6 | ||
|
|
12aaec7888 | ||
|
|
7ea999d5b4 | ||
|
|
ed4a0ad5cd | ||
|
|
cb0a90a9c3 | ||
|
|
32fbbfe475 | ||
|
|
fa6d4fd81b | ||
|
|
7cc4abaffa | ||
|
|
dc4a64a9c3 |
No files matched your search
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
|||||||
|
|
||||||
This is a first public test, so reports are really useful, especially from
|
This is a first public test, so reports are really useful, especially from
|
||||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
|
||||||
diagnostics with personal details removed, shows you exactly what's included,
|
diagnostics with personal details removed, shows you exactly what's included,
|
||||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
|
|||||||
|
|
||||||
## Assistant panel
|
## Assistant panel
|
||||||
|
|
||||||
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
|
||||||
To put the same page in the headset, with the HTTP server still running:
|
To put the same page in the headset, with the HTTP server still running:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+16
-4
@@ -3,8 +3,9 @@
|
|||||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||||
reached at more than one address: a LAN IP at home, another at the office, its
|
reached at more than one address: a LAN IP at home, another at the office, its
|
||||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||||
tab (key 5) lists them, and the connection pill in the header shows what the
|
tab (key 5) lists them. The connection pill in the header says whether the one in
|
||||||
app is doing to reach the one in use, step by step, as it happens.
|
use is connected and how (Tailscale, or the network's name); click it to add,
|
||||||
|
edit or reorder that headset's addresses, or to see each step of connecting.
|
||||||
|
|
||||||
The code is in three modules, all stdlib-only Python on your computer:
|
The code is in three modules, all stdlib-only Python on your computer:
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ and ranks them:
|
|||||||
5. addresses that only ever worked on other networks;
|
5. addresses that only ever worked on other networks;
|
||||||
6. Tailscale addresses while Tailscale is off.
|
6. Tailscale addresses while Tailscale is off.
|
||||||
|
|
||||||
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
|
||||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||||
still trying. If SSH to the winner fails in a way another address could fix
|
still trying. If SSH to the winner fails in a way another address could fix
|
||||||
(a different device answered there, or the link dropped), the next one that
|
(a different device answered there, or the link dropped), the next one that
|
||||||
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
|
|||||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||||
services and checks `ALIAS.local` and `frame.local`.
|
services and checks `ALIAS.local` and `frame.local`.
|
||||||
|
|
||||||
|
**The pill's dialog** lists the same addresses, with what each one answered, and
|
||||||
|
can add, edit, reorder and remove them without leaving the page you're on. When
|
||||||
|
the headset reports a LAN IP on the same network as this computer and that IP
|
||||||
|
isn't saved, it offers to add it. The offer puts the address first in the list,
|
||||||
|
so on that network it wins over the Tailscale name; away from home the Tailscale
|
||||||
|
name still leads. A new or edited address is tested straight away. While
|
||||||
|
connected, **Reconnect** applies your changes now rather than at the next
|
||||||
|
connection: it tries the addresses again, ranked as above, and the best-ranked
|
||||||
|
one that answers promptly wins. It doesn't pick a particular address.
|
||||||
|
|
||||||
## Networks
|
## Networks
|
||||||
|
|
||||||
A network is told apart by its default gateway: the router's IP address plus its
|
A network is told apart by its default gateway: the router's IP address plus its
|
||||||
@@ -122,7 +133,8 @@ file per headset instead, so saving or forgetting one headset's key never touche
|
|||||||
another's: a different device answering at one of its
|
another's: a different device answering at one of its
|
||||||
addresses is refused, and the pill says so. A headset's first connection trusts
|
addresses is refused, and the pill says so. A headset's first connection trusts
|
||||||
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
|
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
|
||||||
headset has a new key; **Forget identity** on the Devices tab lets the next
|
headset has a new key; **Forget identity** (Devices tab → **Advanced**, with the
|
||||||
|
SSH alias, user and port) lets the next
|
||||||
connection save the new one.
|
connection save the new one.
|
||||||
|
|
||||||
## One server at a time
|
## One server at a time
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
Locked real-Frame repeat, 2026-09-29
|
||||||
|
SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
|
||||||
|
mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
|
||||||
|
Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
|
||||||
|
Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
|
||||||
|
Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
|
||||||
|
Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
|
||||||
|
No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
|
||||||
|
Headset unworn. No Linux gaming host. No pairing or streaming session reached.
|
||||||
|
|
||||||
|
WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
|
||||||
|
Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
|
||||||
|
The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
|
||||||
|
Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
|
||||||
@@ -58,7 +58,7 @@ not been verified and is not guaranteed.
|
|||||||
|
|
||||||
## Casting
|
## Casting
|
||||||
|
|
||||||
**Cast headset view** starts the existing headset Live view and requests full
|
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
|
||||||
screen where supported. Show that screen to people in the room, or use the
|
screen where supported. Show that screen to people in the room, or use the
|
||||||
computer/phone's own screen mirroring. It creates no new stream transport,
|
computer/phone's own screen mirroring. It creates no new stream transport,
|
||||||
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
||||||
|
|||||||
+14
-9
@@ -17,14 +17,19 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
The window has five tabs: **Home** (what the headset's doing and what you'd do
|
||||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
next, the headset view, screenshots, family and comfort, the keyboard and
|
||||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
trackpad, and VR performance, folded away), **Games** (installed games,
|
||||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
sideloaded titles, getting games), **Android** (apps, the catalogue, display
|
||||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
settings, reports), **Tools** (in the headset: your computer's windows, the media
|
||||||
connection pill in the header always shows which headset, which network this
|
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
||||||
computer is on, the address in use or being tried, and each step of connecting
|
power) and **Devices** (your headsets and their addresses). Keys 1–5 switch
|
||||||
as it happens; click it for the whole timeline. When the Frame can't be
|
between them. The gear in the header (key 6) opens **Settings**: privacy and
|
||||||
|
updates, library artwork and the assistant. The battery chip opens the headset's
|
||||||
|
details: storage, memory, temperature, Wi-Fi, uptime and SteamOS build. Files can be dropped anywhere in the window. A
|
||||||
|
connection pill in the header always shows which headset and whether it's
|
||||||
|
connected, and how (Tailscale, or the network's name). Click it to add, edit or
|
||||||
|
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
|
||||||
reached, a banner says why in plain words, what was tried, and counts down to
|
reached, a banner says why in plain words, what was tried, and counts down to
|
||||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||||
counts them while they run.
|
counts them while they run.
|
||||||
@@ -178,5 +183,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
|
|||||||
require a separate user approval; power also retains its password prompt. The
|
require a separate user approval; power also retains its password prompt. The
|
||||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||||
operate the headset. Tools → Open assistant opens the page; the linked guide
|
operate the headset. Settings → Open assistant settings opens the page; the linked guide
|
||||||
covers putting it in a Chromium panel on the Frame.
|
covers putting it in a Chromium panel on the Frame.
|
||||||
+2
-2
@@ -114,8 +114,8 @@ don't.
|
|||||||
|
|
||||||
## Family and comfort
|
## Family and comfort
|
||||||
|
|
||||||
The shared Home card sets session limits, breaks and check-ins, and offers
|
The shared Home card sets session limits, breaks and check-ins; **Cast to this
|
||||||
**Cast headset view**. **Enable / test notifications** requests iOS notification
|
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
|
||||||
permission and sends a local test. These are local notifications, not APNs push;
|
permission and sends a local test. These are local notifications, not APNs push;
|
||||||
iOS background suspension can interrupt phone alerts. The headset timer still
|
iOS background suspension can interrupt phone alerts. The headset timer still
|
||||||
runs. See [the behavior and verification limits](family-comfort.md).
|
runs. See [the behavior and verification limits](family-comfort.md).
|
||||||
@@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected.
|
|||||||
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
||||||
SteamVR's original process remained running. No global settings changed.
|
SteamVR's original process remained running. No global settings changed.
|
||||||
|
|
||||||
|
### Locked repeat, 2026-09-29 (verified)
|
||||||
|
|
||||||
|
Acquired `/tmp/frame-test.lock` before installing or launching anything and
|
||||||
|
released it after cleanup. Battery was 44% and charging at preflight, 46–47%
|
||||||
|
during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
|
||||||
|
|
||||||
|
Reinstalled and launched both original APKs in immersive Lepton instances.
|
||||||
|
WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
|
||||||
|
ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
|
||||||
|
unmodified-client test, not the newer installer's automatic compatibility-layer
|
||||||
|
path. Neither reached a session that could be paired or exercised further.
|
||||||
|
Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
|
||||||
|
record the failures.
|
||||||
|
|
||||||
|
SteamVR's screenshot API returned a 1920×1080 headset capture after each
|
||||||
|
launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
|
||||||
|
and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
|
||||||
|
rendering or a working client. The headset was unworn; visibility, controllers,
|
||||||
|
frame rate and motion-to-photon latency could not be judged. The explicit
|
||||||
|
OpenXR errors, rather than the dark captures, establish the client blocker.
|
||||||
|
|
||||||
|
Both test installs, app data, shader caches, shortcuts, containers and temporary
|
||||||
|
capture files were removed. The original Steam and SteamVR process IDs were
|
||||||
|
unchanged. No reboot, power action or global setting change was used. Native
|
||||||
|
clients remain untested, and no Linux gaming host was available for Valve's
|
||||||
|
streaming path. The recommendation below is unchanged.
|
||||||
|
|
||||||
### Relation to the VR APK branch
|
### Relation to the VR APK branch
|
||||||
|
|
||||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||||
|
|||||||
+1
-1
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
|
|||||||
separate from Frame Control. Public reports describe some Proton support;
|
separate from Frame Control. Public reports describe some Proton support;
|
||||||
Windows-only does not by itself prove a Frame app cannot run. Local status
|
Windows-only does not by itself prove a Frame app cannot run. Local status
|
||||||
and sources are in [VR utilities](vr-utilities.md).
|
and sources are in [VR utilities](vr-utilities.md).
|
||||||
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
|
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
|
||||||
headset creates its own gamescope panel using built-in tools. It needs no
|
headset creates its own gamescope panel using built-in tools. It needs no
|
||||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -97,8 +97,8 @@ The same error is sent at most once every 10 minutes.
|
|||||||
|
|
||||||
## Report a problem
|
## Report a problem
|
||||||
|
|
||||||
**Report a problem** is the warning-sign button in the header, also in the
|
**Report a problem** is the speech-bubble button in the header, also in the
|
||||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report
|
||||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||||
same way as the analytics above, so only the maintainer can read it and
|
same way as the analytics above, so only the maintainer can read it and
|
||||||
nothing is published. It works whatever the analytics settings are, because
|
nothing is published. It works whatever the analytics settings are, because
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as
|
|||||||
an installed app with a blank tile.
|
an installed app with a blank tile.
|
||||||
|
|
||||||
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
||||||
Set the optional free key in Frame Control's **Library artwork settings**, or
|
Set the optional free key in Frame Control's **Settings → Library artwork**, or
|
||||||
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
||||||
settings override the saved key. Without a key there are no provider calls or
|
settings override the saved key. Without a key there are no provider calls or
|
||||||
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
|
|||||||
|
|
||||||
## Our performance HUD
|
## Our performance HUD
|
||||||
|
|
||||||
On **Home → VR comfort and performance**, the app shows a timestamped sample
|
On **Home → VR performance** (folded until you open it), the app shows a timestamped sample
|
||||||
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
||||||
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
||||||
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
||||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
|||||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
|
||||||
|
|
||||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||||
it running. This mode also returned live timing in a read-only device probe.
|
it running. This mode also returned live timing in a read-only device probe.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path.
|
|||||||
|
|
||||||
## Use it
|
## Use it
|
||||||
|
|
||||||
In **Tools → Media in the headset**, send a file, choose its layout and press
|
In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press
|
||||||
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
||||||
**Stop** removes both. Refresh reads the library and the player's state.
|
**Stop** removes both. Refresh reads the library and the player's state.
|
||||||
The screen follows your head; it isn't a saved world-space panel.
|
The screen follows your head; it isn't a saved world-space panel.
|
||||||
|
|||||||
@@ -55,6 +55,48 @@ const log = ()=>{}, toast = ()=>{};
|
|||||||
assert.equal($('sessionMinutes').disabled,false);
|
assert.equal($('sessionMinutes').disabled,false);
|
||||||
assert.equal($('sessionCancel').disabled,true);
|
assert.equal($('sessionCancel').disabled,true);
|
||||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||||
|
'''
|
||||||
|
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_a_finished_session_leaves_the_defaults(self):
|
||||||
|
"""A one-minute test session that has ended mustn't fill the form with 1 / 0 / 0."""
|
||||||
|
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||||
|
code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
|
||||||
|
setup = r'''
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const elements = new Map();
|
||||||
|
const $ = id => {
|
||||||
|
// The form's own defaults, as in the page's HTML.
|
||||||
|
const value = {sessionMinutes:'30', breakMinutes:'20', stillMinutes:'30'}[id] || '';
|
||||||
|
if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value, defaultValue:value,
|
||||||
|
checked:true, defaultChecked:true, type: /Alert$/.test(id) ? 'checkbox' : 'number'});
|
||||||
|
return elements.get(id);
|
||||||
|
};
|
||||||
|
const window = {frameApp:{notify:async()=>{}}};
|
||||||
|
const log = ()=>{}, toast = ()=>{};
|
||||||
|
'''
|
||||||
|
checks = r'''
|
||||||
|
renderComfort({id:'old', active:false, time:100, options:{minutes:1,breakMinutes:0,stillMinutes:0,batteryAlert:false,heatAlert:false},
|
||||||
|
events:[{id:'old:1',kind:'finished',time:90,message:'Session ended'}]});
|
||||||
|
assert.equal($('sessionMinutes').value, '30');
|
||||||
|
assert.equal($('breakMinutes').value, '20');
|
||||||
|
assert.equal($('batteryAlert').checked, true);
|
||||||
|
assert.match($('comfortEvents').textContent, /Session ended/);
|
||||||
|
assert.notEqual($('comfortEvents').textContent, 'Session ended'); // it carries a time
|
||||||
|
renderComfort({id:'new', active:true, time:200, remaining:600, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, events:[]});
|
||||||
|
assert.equal($('sessionMinutes').value, 45); // a running session shows its own settings
|
||||||
|
assert.equal($('heatAlert').checked, false);
|
||||||
|
// The page stays open while that session ends: the next one starts from the defaults again.
|
||||||
|
renderComfort({id:'new', active:false, time:900, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false},
|
||||||
|
events:[{id:'new:1',kind:'finished',time:899,message:'Session ended'}]});
|
||||||
|
assert.equal($('sessionMinutes').value, '30');
|
||||||
|
assert.equal($('breakMinutes').value, '20');
|
||||||
|
assert.equal($('heatAlert').checked, true);
|
||||||
|
renderComfort({id:'new', active:false, time:905, options:{minutes:45}, events:[]});
|
||||||
|
$('sessionMinutes').value = '50'; // what's typed for the next session
|
||||||
|
renderComfort({id:'new', active:false, time:910, options:{minutes:45}, events:[]});
|
||||||
|
assert.equal($('sessionMinutes').value, '50'); // later polls leave it alone
|
||||||
'''
|
'''
|
||||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
@@ -296,6 +296,20 @@ class Pins(Base):
|
|||||||
|
|
||||||
|
|
||||||
class Registry(Base):
|
class Registry(Base):
|
||||||
|
def test_an_address_added_first_wins_on_its_own_network(self):
|
||||||
|
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
|
||||||
|
# worked here before. The LAN address has to go ahead of it to be used at home.
|
||||||
|
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
|
||||||
|
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
|
||||||
|
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
|
||||||
|
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
|
||||||
|
addrs = self.reg.get(d["id"])["addresses"]
|
||||||
|
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
|
||||||
|
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
|
||||||
|
self.assertEqual(at_home[0], "192.168.1.40")
|
||||||
|
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
|
||||||
|
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
|
||||||
|
|
||||||
def test_address_editing(self):
|
def test_address_editing(self):
|
||||||
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||||
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||||
|
|||||||
@@ -457,6 +457,47 @@ class Connecting(unittest.TestCase):
|
|||||||
self.assertEqual(self.routes, [])
|
self.assertEqual(self.routes, [])
|
||||||
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
||||||
|
|
||||||
|
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
|
||||||
|
d = self.device("nothing.invalid")
|
||||||
|
entered = {1: threading.Event(), 2: threading.Event()}
|
||||||
|
release = {1: threading.Event(), 2: threading.Event()}
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def probe(host, port, update=None):
|
||||||
|
calls.append(host)
|
||||||
|
n = len(calls)
|
||||||
|
entered[n].set()
|
||||||
|
release[n].wait(10)
|
||||||
|
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
|
||||||
|
|
||||||
|
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
|
||||||
|
with mock.patch.object(fl, "probe", probe):
|
||||||
|
try:
|
||||||
|
tests[0].start()
|
||||||
|
self.assertTrue(entered[1].wait(5), "the first test never probed")
|
||||||
|
tests[1].start()
|
||||||
|
self.assertTrue(entered[2].wait(5), "the second test never probed")
|
||||||
|
# The first finishes while the second is still probing: it mustn't show its
|
||||||
|
# rows or mark the second done.
|
||||||
|
release[1].set()
|
||||||
|
tests[0].join(10)
|
||||||
|
self.assertFalse(tests[0].is_alive())
|
||||||
|
running = self.link.snapshot()["tests"][d["id"]]
|
||||||
|
self.assertFalse(running["done"])
|
||||||
|
self.assertEqual(running["rows"][0]["detail"], "Waiting")
|
||||||
|
release[2].set()
|
||||||
|
tests[1].join(10)
|
||||||
|
self.assertFalse(tests[1].is_alive())
|
||||||
|
finally:
|
||||||
|
for e in release.values():
|
||||||
|
e.set()
|
||||||
|
for t in tests:
|
||||||
|
if t.ident: # started
|
||||||
|
t.join(10)
|
||||||
|
result = self.link.snapshot()["tests"][d["id"]]
|
||||||
|
self.assertTrue(result["done"])
|
||||||
|
self.assertEqual(result["rows"][0]["detail"], "test 2")
|
||||||
|
|
||||||
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
||||||
self.device("localhost")
|
self.device("localhost")
|
||||||
self.hosts({"localhost": "ok"})
|
self.hosts({"localhost": "ok"})
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""The page calls showPage() while its first script is still running, before names declared
|
||||||
|
later (in that script or the second one) exist. Touching one of them there throws, and the
|
||||||
|
rest of the page's setup never runs: opening Frame Control at #games did exactly that."""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles']
|
||||||
|
|
||||||
|
RUN = r'''
|
||||||
|
const vm = require('vm');
|
||||||
|
const {code, hashes} = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||||
|
const failures = [];
|
||||||
|
for (const hash of hashes) {
|
||||||
|
const context = vm.createContext({
|
||||||
|
location: {hash}, document: {querySelectorAll: () => [], title: ''}, live: false,
|
||||||
|
});
|
||||||
|
try { vm.runInContext(code, context); }
|
||||||
|
catch (e) { failures.push(`${hash}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(failures));
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('node'), 'Node runs the page code')
|
||||||
|
class PageStartup(unittest.TestCase):
|
||||||
|
def test_opening_any_page_or_section_at_startup_runs(self):
|
||||||
|
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||||
|
first, second = re.findall(r'<script>(.*?)</script>', page, re.S)[:2]
|
||||||
|
tables = first[first.index('const PAGES ='):first.index('let page =')]
|
||||||
|
start = first.index('function showPage(')
|
||||||
|
show = first[start:first.index('\n}\n', start) + 3]
|
||||||
|
# Everything declared after the startup call is still uninitialised when it runs.
|
||||||
|
call = re.search(r'^showPage\(\);', first, re.M).end()
|
||||||
|
later = re.findall(r'^(?:const|let)\s+(\w+)', first[call:] + second, re.M)
|
||||||
|
later = [n for n in dict.fromkeys(later) if n not in STUBS and n not in ('PAGES', 'SECTION_PAGE', 'page')]
|
||||||
|
self.assertIn('link', later) # the name that broke #games
|
||||||
|
stubs = ''.join(f'function {n}() {{ return {{ classList: {{ toggle() {{}} }}, scrollIntoView() {{}} }}; }}\n'
|
||||||
|
for n in STUBS if n != '$')
|
||||||
|
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + show +
|
||||||
|
'showPage();\n' + ''.join(f'let {n};\n' for n in later))
|
||||||
|
hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#nowhere']
|
||||||
|
hashes += ['#' + k for k in re.findall(r'(\w+): "', tables)]
|
||||||
|
r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}),
|
||||||
|
capture_output=True, text=True)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
self.assertEqual(json.loads(r.stdout), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
+4
-2
@@ -623,7 +623,9 @@ class Registry:
|
|||||||
return a
|
return a
|
||||||
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
||||||
|
|
||||||
def add_address(self, device_id, host, kind=None, label=""):
|
def add_address(self, device_id, host, kind=None, label="", first=False):
|
||||||
|
"""Add an address at the end of the list, or at the front (first=True), where the
|
||||||
|
user's order makes it win over the others that work on the same network."""
|
||||||
with self._changing():
|
with self._changing():
|
||||||
d = self._find(device_id)
|
d = self._find(device_id)
|
||||||
a = new_address(host, kind, label)
|
a = new_address(host, kind, label)
|
||||||
@@ -631,7 +633,7 @@ class Registry:
|
|||||||
raise DeviceError(f"{a['host']} is already on the list")
|
raise DeviceError(f"{a['host']} is already on the list")
|
||||||
if len(d["addresses"]) >= 32:
|
if len(d["addresses"]) >= 32:
|
||||||
raise DeviceError("That's enough addresses for one headset")
|
raise DeviceError("That's enough addresses for one headset")
|
||||||
d["addresses"].append(a)
|
d["addresses"].insert(0 if first else len(d["addresses"]), a)
|
||||||
self.save()
|
self.save()
|
||||||
return copy.deepcopy(a)
|
return copy.deepcopy(a)
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -169,6 +169,7 @@ class Link:
|
|||||||
self.version = 0
|
self.version = 0
|
||||||
self.stopped = False
|
self.stopped = False
|
||||||
self.kicks = [] # reasons someone asked for a (re)connect
|
self.kicks = [] # reasons someone asked for a (re)connect
|
||||||
|
self.test_gen = {} # device id -> its newest test of the addresses (see test())
|
||||||
self.busy = False # the loop is handling kicks
|
self.busy = False # the loop is handling kicks
|
||||||
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
|
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
|
||||||
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
|
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
|
||||||
@@ -955,9 +956,13 @@ class Link:
|
|||||||
started = now()
|
started = now()
|
||||||
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
|
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
|
||||||
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
|
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
|
||||||
|
with self.cond:
|
||||||
|
gen = self.test_gen[device_id] = self.test_gen.get(device_id, 0) + 1
|
||||||
|
|
||||||
def put(**fields):
|
def put(**fields):
|
||||||
with self.cond:
|
with self.cond:
|
||||||
|
if gen != self.test_gen[device_id]:
|
||||||
|
return # a newer test has started: its results are the ones to show
|
||||||
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
|
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
|
||||||
self.version += 1
|
self.version += 1
|
||||||
self.cond.notify_all()
|
self.cond.notify_all()
|
||||||
@@ -1111,7 +1116,8 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
|
|||||||
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
|
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
|
||||||
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
|
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
|
||||||
elif action == "address-add":
|
elif action == "address-add":
|
||||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
|
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "",
|
||||||
|
first=body.get("first") is True)
|
||||||
if is_active and link.state["phase"] == "failed":
|
if is_active and link.state["phase"] == "failed":
|
||||||
link.kick("retry")
|
link.kick("retry")
|
||||||
msg = f"Added {a['host']}"
|
msg = f"Added {a['host']}"
|
||||||
|
|||||||
+541
-236
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user