mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 23:01:36 +02:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ce5c023b6 | ||
|
|
709fef0dbd | ||
|
|
49408dc171 | ||
|
|
c728e64fbd | ||
|
|
0626c52e12 | ||
|
|
b9b4a00485 | ||
|
|
eb125b59c7 | ||
|
|
7e53fe32cb | ||
|
|
ebae409632 | ||
|
|
08cb267ce4 | ||
|
|
0119544c3e | ||
|
|
2a9e7dcabf | ||
|
|
5a9099dddb | ||
|
|
ec4086db69 | ||
|
|
5a7c3541f8 | ||
|
|
525040162e | ||
|
|
21e771d16e |
No files matched your search
@@ -2,6 +2,29 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Show saved Android compatibility reports on store search cards and app details.
|
||||
Reports refresh in the background, so a stalled database or credential lookup
|
||||
cannot delay search. Warnings allow installation; build compatibility blockers
|
||||
still apply. Public copies use this computer's reports, not private shared ratings.
|
||||
|
||||
- Fix two cases where optional diagnostic logs could retain part of a headset
|
||||
display name or an SSH host name, while preserving existing privacy redaction
|
||||
(issue #81).
|
||||
|
||||
- Keep the full host-key recovery explanation readable in Devices, including
|
||||
long addresses, and remove doubled punctuation from the connection tooltip
|
||||
(issue #87). Refresh stays within the header and switching headsets selects
|
||||
the new headset's details.
|
||||
|
||||
- Fix experimental PC screen capture on Windows builds whose streaming libraries
|
||||
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
|
||||
windows are hidden with an explanation instead of failing when shown. A Windows
|
||||
VM streamed a real desktop to the Frame; physical input remains unverified.
|
||||
|
||||
- Add read-only battery health in Tools: charging state, temperature, cycle count,
|
||||
and available capacity with explicit unknown states. The parser was checked
|
||||
on a real Frame; the connected desktop UI was tested with synthetic readings.
|
||||
|
||||
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
|
||||
library/game install-size breakdown, with unknown sizes, partial inventories
|
||||
and permission/missing-drive states. Includes instructions for Steam's own
|
||||
|
||||
@@ -226,7 +226,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: your PC's windows and screens as panels in the Frame. Not yet tried on a real Windows desktop or headset |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.12",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.12",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -22,6 +22,15 @@ FC_API int fc_has_element(const char *name) {
|
||||
GstElementFactory *f=gst_element_factory_find(name);
|
||||
if(!f)return 0;gst_object_unref(f);return 1;
|
||||
}
|
||||
FC_API int fc_has_property(const char *element, const char *property) {
|
||||
/* Probe the same initialized GStreamer instance that capture uses. The
|
||||
* bundled MSYS2 plugin can omit optional WGC properties entirely. */
|
||||
GstElement *source=gst_element_factory_make(element,NULL);
|
||||
if(!source)return 0;
|
||||
int found=g_object_class_find_property(G_OBJECT_GET_CLASS(source),property)!=NULL;
|
||||
gst_object_unref(source);
|
||||
return found;
|
||||
}
|
||||
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
|
||||
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
|
||||
if(!b)return GST_PAD_PROBE_OK;
|
||||
|
||||
@@ -190,6 +190,35 @@ IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on th
|
||||
Frame as cards, with Play and a menu for Test, Report and Remove; the display
|
||||
settings and recent reports are below it.
|
||||
|
||||
Store search cards and app details also show **Reported not working** or
|
||||
**Reported issues** when saved compatibility reports say so. Details include
|
||||
the report date, version and notes. These are advisories: Install remains
|
||||
available unless the selected build has an existing compatibility blocker
|
||||
(for example, no arm64-v8a build or Android API above 30). Reports never clear
|
||||
those blockers. Apps reported not working are excluded from Home's featured
|
||||
app and recommendation shelves; search and category results still show them.
|
||||
An unrated install failure caused by the wrong APK architecture or Android API
|
||||
is ignored, using the same rules as the catalogue. A person's rating takes
|
||||
priority over automated results, with the latest rating winning.
|
||||
|
||||
Reports warm in a background worker at server start. Search and details read
|
||||
only the last complete in-memory snapshot and schedule a single background
|
||||
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
|
||||
the report database. During initial loading they show the usual build verdict;
|
||||
after a refresh, the next search or detail request sees the reports. Failed
|
||||
refreshes keep the previous snapshot and retry after another 60 seconds.
|
||||
|
||||
**Availability:** public copies benefit from their own saved reports, including
|
||||
reports for apps outside the bundled catalogue, across every source with the
|
||||
same package name. This does not give public users the private shared ratings.
|
||||
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
|
||||
report verdicts, so it cannot supply a useful public report snapshot. No private
|
||||
reports, free-text notes or credentials are exported or published. The existing
|
||||
maintainer loader still uses its configured database access in the background;
|
||||
the search feature adds no sharing, endpoint or network destination. Tests leave
|
||||
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
|
||||
server subprocesses) and use synthetic reports to exercise refresh behavior.
|
||||
|
||||
The store groups apps by what a headset is good for, not by F-Droid's list:
|
||||
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
|
||||
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
|
||||
|
||||
@@ -97,6 +97,12 @@ The app rereads the gateway every 5 seconds and Tailscale's state every
|
||||
|
||||
## The connection, stage by stage
|
||||
|
||||
When a connection fails, the header pill keeps its compact offline state and
|
||||
retry countdown; its tooltip and connection dialog contain the full reason.
|
||||
Devices shows a short reason in the headset row (with the full reason in its
|
||||
tooltip) and a wrapping recovery explanation in the selected headset's details.
|
||||
**Use this headset** also selects that headset's details.
|
||||
|
||||
The connector runs in the server (`frame_link.Link`) and moves through:
|
||||
|
||||
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Windows VM and real Frame test, 2026-10-24
|
||||
|
||||
The v0.4.10 installer could enumerate windows/screens and encode a synthetic
|
||||
Media Foundation stream, but every desktop capture failed at pipeline parsing:
|
||||
`no property "capture-api" in element "d3d11screencapturesrc"`.
|
||||
MSYS2's GStreamer 1.28.8 package disables WGC. After the fix, the same installed
|
||||
bundle captured the actual Windows desktop with Desktop Duplication and sent
|
||||
H.264 to a real Frame: 219 sent frame records, 215 decoded and drawn before the
|
||||
snapshot. Individual windows are explicitly unavailable with this bundle.
|
||||
|
||||
The base was `e9cbb8fd5555f5cfceeba1babadf2d7d35dcd3be` (`v0.4.10`, origin/main,
|
||||
including merged #121), in isolated branch `t3/test-windows-vm-headset`.
|
||||
|
||||
## Evidence
|
||||
|
||||
- [Capture before the fix](windows-vm-2026-10-24/capture-before.json): five
|
||||
synthetic H.264 frames and five JPEG frames; zero desktop/window frames,
|
||||
with the missing-property error for both codecs.
|
||||
- [Actual bundled properties](windows-vm-2026-10-24/plugin-properties.json):
|
||||
`monitor-handle` exists; `capture-api`, `window-handle`, `show-border` do not.
|
||||
[MSYS2 package build](https://github.com/msys2/MINGW-packages/blob/master/mingw-w64-gst-plugins-bad/PKGBUILD)
|
||||
sets `-Dd3d11-wgc=disabled`. Upstream
|
||||
[installs those properties conditionally](https://github.com/GStreamer/gstreamer/blob/main/subprojects/gst-plugins-bad/sys/d3d11/gstd3d11screencapturesrc.cpp).
|
||||
- [Capture after the fix](windows-vm-2026-10-24/capture-after.json): the real
|
||||
display produces five H.264 and five JPEG frames. Window sources are hidden
|
||||
with an explicit reason, and the display selects `capture_api=dxgi`.
|
||||
- [Raw locked headset session](windows-vm-2026-10-24/headset-session.json):
|
||||
Windows helper, Media Foundation encoder, reverse SSH tunnel, production
|
||||
panel launcher and shared viewer. The image below was captured from that
|
||||
viewer's own X window on the Frame, rather than the VM's console.
|
||||
- [Windows tests](windows-vm-2026-10-24/windows-tests.txt) and
|
||||
[guest system details](windows-vm-2026-10-24/windows-system.txt).
|
||||
|
||||

|
||||
|
||||
## Host and VM prerequisites
|
||||
|
||||
lxso2: Intel Core i7-8700, 12 logical CPUs, VT-x/KVM; Docker 29.1.3 and Compose
|
||||
2.40.3. Before starting: 31 GiB RAM, 15 GiB available; 147 GiB free disk.
|
||||
Swap was already full. No other VM or service workload was started or stopped.
|
||||
Docker listed no Windows VM, and ports 2222/8006/13389 were unused.
|
||||
|
||||
The execution sandbox hid KVM and denied Docker/network access. Checks outside
|
||||
it found `/dev/kvm`, `/dev/net/tun` and the Docker daemon. Direct user access to
|
||||
KVM was denied, but a disposable Docker container with `--device /dev/kvm`
|
||||
opened it successfully; no host permissions changed.
|
||||
|
||||
Created only `frame-winvm-priority3`, labelled
|
||||
`frame-control.task=priority3-windows-test`: 4 vCPUs, 8 GiB guest RAM, 32 GiB
|
||||
growable disk, no restart, 150-second stop timeout. SSH 2222 and console 8006
|
||||
bind only 127.0.0.1; RDP is unpublished. No GPU passthrough. Image:
|
||||
`dockurr/windows@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea`
|
||||
(Windows for Docker v6.06, QEMU v11.1.1).
|
||||
|
||||
The container downloaded Windows 11 from Microsoft, with no activation changes.
|
||||
It took about 16 minutes to reach the first desktop. Guest build 26200.8037,
|
||||
Red Hat VirtIO GPU DOD driver 100.103.104.30200. The old WindowsProductName
|
||||
registry field still says Windows 10 Pro; the actual desktop/setup is Windows
|
||||
11. After installing the app, the 32 GiB disk had about 9.3 GB free.
|
||||
|
||||
Official `Frame-Control-Setup-x64.exe` v0.4.10 SHA-256:
|
||||
`30165bc5aa39f941cacc697d195dc5c30b01ad0b9ffb472da8a18a5220549202`.
|
||||
Its installer exited 0. The VM stream used an intermediate Python/UI-only
|
||||
fix with the released DLL bundle and bundled Python 3.12.14. A later CI failure
|
||||
showed that probing GStreamer through a separately loaded DLL could see a
|
||||
different, uninitialized instance. The final fix probes optional properties
|
||||
through the capture DLL itself. Windows CI checks that native change; the VM
|
||||
stream was not repeated with the final DLL.
|
||||
|
||||
OEM setup added OpenSSH Server, PowerShell and a dedicated public login key;
|
||||
password authentication is disabled. Its config needed an explicit
|
||||
`AuthorizedKeysFile C:/ProgramData/ssh/administrators_authorized_keys` before
|
||||
key login worked. The default informational share was replaced with a private
|
||||
writable staging share inside this container. These were test-VM setup steps,
|
||||
not app defects. Host setup files and the stopped VM disk are retained under
|
||||
`/tmp/frame-winvm-priority3/`; no private keys are committed or printed.
|
||||
The Frame test key was generated inside Windows and its private key stayed
|
||||
there. Only its public key was temporarily authorized on the Frame. After
|
||||
testing, the guest key and scheduled task were removed; `docker stop -t 150`
|
||||
shut down the dedicated VM. Inspection confirmed `exited`, restart `no`
|
||||
(container exit 143; logs show the ACPI shutdown signal, waiting for Windows,
|
||||
and `Shutdown completed!`). The disk is retained
|
||||
for reuse; the VM is not running.
|
||||
|
||||
## Checks
|
||||
|
||||
- Baseline PC-focused suite: 24 tests passed, one native class skipped.
|
||||
- After fix: full Linux unittest suite **1260 tests, OK, 15 skipped**, 144.366 s;
|
||||
focused final PC suite **27 tests, OK, one native class skipped**. The C
|
||||
controller's frozen 600-state trace ran on Linux.
|
||||
- Actual Windows installer bundle with the intermediate Python/UI fix:
|
||||
**33 tests, OK, four skipped** (no C compiler, Linux PipeWire test, two
|
||||
CI-only x264 tests). Its GObject property probe, host/encoder check and
|
||||
x264-absence check ran. The final native probe is checked in the PR's
|
||||
Windows CI build, not by this VM run.
|
||||
- Browser check with the real production HTML/JavaScript and mocked Windows API
|
||||
responses: DXGI whole-screen Show remains available, the limitation appears
|
||||
once, WGC window sources retain Show, off hides controls, and the limitation
|
||||
text is escaped. This used a static asset server, without headset API access.
|
||||
- `node --test app/test/*.test.js`: passed, three test files.
|
||||
- `node --test site/test/*.test.mjs`: passed, one test file.
|
||||
- The first full Linux run inside the sandbox failed (11 failures, 139 errors),
|
||||
including loopback socket `PermissionError`. The successful full runs were
|
||||
outside that socket restriction.
|
||||
- An initial guest test archive omitted `apk-catalog`, causing one import
|
||||
error; the complete archive produced the 33-test passing Windows run.
|
||||
|
||||
Raw local logs: `/tmp/frame-windows-{pc,app,site}-tests.log`,
|
||||
`/tmp/frame-windows-full-tests-unrestricted.log`,
|
||||
`/tmp/frame-windows-full-fixed-tests.log`, and
|
||||
`/tmp/frame-winvm-priority3/`. The committed artifacts above preserve the
|
||||
capture, Windows test and headset evidence independently of those local files.
|
||||
|
||||
## Shared Frame session and cleanup
|
||||
|
||||
Read-only checks first: SteamOS 0.5.6, BUILD_ID `20261015.6141207`, battery 99%,
|
||||
Not charging (another supply reports Full). The lock was free.
|
||||
|
||||
Both attempts acquired `/tmp/frame-test.lock` atomically before any key or
|
||||
viewer changes, and removed only the lock acquired by this task. The first,
|
||||
35-second attempt failed because the test wrapper sent CRLF script input and
|
||||
left SSH stdin open. Production already sends script bytes and closes stdin;
|
||||
correcting the wrapper resolved both. Its key was removed and lock released.
|
||||
|
||||
The successful session held the lock **03:09:36–03:10:05 UTC (29 seconds)**.
|
||||
It used a dedicated `pc-view-priority3` profile and port range 47960–47964,
|
||||
leaving existing viewers/profiles alone. The app's Windows `PCView` captured
|
||||
`display:65537`, 1280×800, Light preset (30 fps ceiling), and opened panel
|
||||
`valve.steam.desktopgame.2001396898`. The viewer reported H.264 software decode,
|
||||
218 received frames, 215 decoded/drawn before the snapshot (219 sent records).
|
||||
|
||||
This mostly idle, roughly ten-second sample reported 19 fps and content delay
|
||||
p50/p95 51.6/75.2 ms. These are observations, not a performance-target pass.
|
||||
|
||||
Cleanup stopped the helper and tunnel, closed the own viewer, removed its two
|
||||
possible profile paths and log, then removed the exact public-key line. The
|
||||
`authorized_keys` SHA-256 was identical before/after. A follow-up login from
|
||||
Windows with that key failed with exit 255. Separate checks found no task-owned
|
||||
viewer processes, the profile absent, and the shared lock released. Battery
|
||||
remained 99%; the sampled gamescope focus properties matched before/after.
|
||||
No reboot, global settings changes, game launch or Steam/SteamVR stop occurred.
|
||||
|
||||
## Limits
|
||||
|
||||
This verifies a Windows VM's desktop capture, Media Foundation encoding and
|
||||
real Frame viewer decode/rendering. It does not verify physical-PC hardware
|
||||
encoding, WGC or individual-window capture, mixed DPI, multiple displays,
|
||||
physical laser alignment/clicks, keyboard layouts, resize/minimise, busy-scene
|
||||
performance, bitrate/encoder restarts, or worn-headset comfort. No input was
|
||||
injected during the real Frame session. The installed app’s Tools navigation was not exercised; its streaming
|
||||
controller, tunnel and viewer were exercised directly. No independent review was performed.
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
|
||||
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": true,
|
||||
"mfh264enc": true,
|
||||
"jpegenc": true,
|
||||
"x264enc": false
|
||||
},
|
||||
"windowCaptureReason": "Individual windows are unavailable in this build. Choose a whole screen instead.",
|
||||
"windows": [],
|
||||
"displays": [
|
||||
{
|
||||
"src": "display:65537",
|
||||
"name": "Display 1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"capture_api": "dxgi"
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 154
|
||||
},
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 7336
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 65
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 113339
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
|
||||
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": true,
|
||||
"mfh264enc": true,
|
||||
"jpegenc": true,
|
||||
"x264enc": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"src": "window:131836",
|
||||
"id": 131836,
|
||||
"title": "Administrator: C:\\WINDOWS\\system32\\cmd.exe - powershell -noprofile -executionpolicy bypass -file \\\\host.lan\\data\\bootstrap.ps1",
|
||||
"app": "Windows",
|
||||
"w": 1115,
|
||||
"h": 628,
|
||||
"x": 51,
|
||||
"y": 52
|
||||
},
|
||||
{
|
||||
"src": "window:327926",
|
||||
"id": 327926,
|
||||
"title": "Administrator: Install",
|
||||
"app": "Windows",
|
||||
"w": 979,
|
||||
"h": 512,
|
||||
"x": 85,
|
||||
"y": 78
|
||||
},
|
||||
{
|
||||
"src": "window:65856",
|
||||
"id": 65856,
|
||||
"title": "Program Manager",
|
||||
"app": "Windows",
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"displays": [
|
||||
{
|
||||
"src": "display:65537",
|
||||
"name": "Display 1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1280,
|
||||
"h": 800
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 154
|
||||
},
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 7336
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "h264",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "jpeg",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "window:131836",
|
||||
"codec": "h264",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "window:131836",
|
||||
"codec": "jpeg",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"gstreamer": "1.28.8",
|
||||
"plugins": [
|
||||
"coreelements",
|
||||
"videotestsrc",
|
||||
"videoconvertscale",
|
||||
"app",
|
||||
"jpeg",
|
||||
"videoparsersbad",
|
||||
"d3d11",
|
||||
"mediafoundation"
|
||||
],
|
||||
"packages": [
|
||||
"mingw-w64-ucrt-x86_64-gst-plugins-bad 1.28.8-2",
|
||||
"mingw-w64-ucrt-x86_64-gst-plugins-bad-libs 1.28.8-2",
|
||||
"mingw-w64-ucrt-x86_64-gstreamer 1.28.8-1"
|
||||
],
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": {
|
||||
"name": "The name of the object",
|
||||
"parent": "The parent of the object",
|
||||
"blocksize": "Size in bytes to read per buffer (-1 = default)",
|
||||
"num-buffers": "Number of buffers to output before sending EOS (-1 = unlimited)",
|
||||
"typefind": "Run typefind before negotiating (deprecated, non-functional)",
|
||||
"do-timestamp": "Apply current stream time to buffers",
|
||||
"automatic-eos": "Automatically EOS when the segment is done",
|
||||
"monitor-index": "Zero-based index for monitor to capture (-1 = primary monitor)",
|
||||
"monitor-handle": "A HMONITOR handle of monitor to capture",
|
||||
"show-cursor": "Whether to show mouse cursor",
|
||||
"crop-x": "Horizontal coordinate of top left corner for the screen capture area",
|
||||
"crop-y": "Vertical coordinate of top left corner for the screen capture area",
|
||||
"crop-width": "Width of screen capture area (0 = maximum)",
|
||||
"crop-height": "Height of screen capture area (0 = maximum)"
|
||||
},
|
||||
"d3d12screencapturesrc": null,
|
||||
"mfh264enc": {
|
||||
"name": "The name of the object",
|
||||
"parent": "The parent of the object",
|
||||
"qos": "Handle Quality-of-Service events from downstream",
|
||||
"min-force-key-unit-interval": "Minimum interval between force-keyunit requests in nanoseconds",
|
||||
"bitrate": "Bitrate in kbit/sec",
|
||||
"rc-mode": "Rate Control Mode",
|
||||
"adaptive-mode": "Adaptive Mode",
|
||||
"vbv-buffer-size": "VBV(HRD) Buffer Size in bytes (0 = MFT default)",
|
||||
"max-bitrate": "The maximum bitrate applied when rc-mode is \"pcvbr\" in kbit/sec",
|
||||
"quality-vs-speed": "Quality and speed tradeoff, [0, 33]: Low complexity, [34, 66]: Medium complexity, [67, 100]: High complexity",
|
||||
"cabac": "Enable CABAC entropy coding",
|
||||
"sps-id": "The SPS id to use",
|
||||
"pps-id": "The PPS id to use",
|
||||
"bframes": "The maximum number of consecutive B frames",
|
||||
"gop-size": "The number of pictures from one GOP header to the next. Depending on GPU vendor implementation, zero gop-size might produce only one keyframe at the beginning (-1 for automatic)",
|
||||
"threads": "The number of worker threads used by a encoder, (0 = MFT default)",
|
||||
"content-type": "Indicates the type of video content",
|
||||
"qp": "QP applied when rc-mode is \"qvbr\"",
|
||||
"low-latency": "Enable low latency encoding",
|
||||
"min-qp": "The minimum allowed QP applied to all rc-mode",
|
||||
"max-qp": "The maximum allowed QP applied to all rc-mode",
|
||||
"qp-i": "QP applied to I frames",
|
||||
"qp-p": "QP applied to P frames",
|
||||
"qp-b": "QP applied to B frames",
|
||||
"ref": "The number of reference frames",
|
||||
"d3d11-aware": "Whether device can support Direct3D11 interop"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
WindowsProductName WindowsVersion OsBuildNumber
|
||||
------------------ -------------- -------------
|
||||
Windows 10 Pro 2009 26200
|
||||
|
||||
|
||||
|
||||
Name DriverVersion Status
|
||||
---- ------------- ------
|
||||
Red Hat VirtIO GPU DOD controller 100.103.104.30200 OK
|
||||
|
||||
|
||||
|
||||
Used Free
|
||||
---- ----
|
||||
23951253504 9314816000
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
python.exe : test_original_mac_congestion_and_recovery
|
||||
(test_pc_controller.ControllerTrace.test_original_mac_congestion_and_recovery) ... skipped 'C
|
||||
compiler not installed; native build jobs cover this trace'
|
||||
At line:12 char:1
|
||||
+ & $python -B -m unittest discover -s tests -p 'test_pc*.py' -v 2>&1 | ...
|
||||
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
+ CategoryInfo : NotSpecified: (test_original_m...ver this trace':String) [], Remote
|
||||
Exception
|
||||
+ FullyQualifiedErrorId : NativeCommandError
|
||||
|
||||
test_controller_is_inert_after_close
|
||||
(test_pcview.AdapterTests.test_controller_is_inert_after_close) ... ok
|
||||
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties (test_pcview.AdapterTests.
|
||||
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties) ... ok
|
||||
test_idle_source_is_not_a_stall (test_pcview.AdapterTests.test_idle_source_is_not_a_stall) ... ok
|
||||
test_library_path_prepend_adds_no_empty_entry
|
||||
(test_pcview.AdapterTests.test_library_path_prepend_adds_no_empty_entry) ... ok
|
||||
test_linux_uses_only_portal_fd_and_node
|
||||
(test_pcview.AdapterTests.test_linux_uses_only_portal_fd_and_node) ... ok
|
||||
test_malformed_viewer_input_is_dropped_not_fatal
|
||||
(test_pcview.AdapterTests.test_malformed_viewer_input_is_dropped_not_fatal) ... ok
|
||||
test_pc_card_receives_window_capture_limitation
|
||||
(test_pcview.AdapterTests.test_pc_card_receives_window_capture_limitation) ... ok
|
||||
test_pc_reuses_launcher_and_namespaces_panel_ids
|
||||
(test_pcview.AdapterTests.test_pc_reuses_launcher_and_namespaces_panel_ids) ... ok
|
||||
test_portal_input_clamps_and_releases_held_state
|
||||
(test_pcview.AdapterTests.test_portal_input_clamps_and_releases_held_state) ... ok
|
||||
test_refused_windows_focus_never_clicks_the_covering_app
|
||||
(test_pcview.AdapterTests.test_refused_windows_focus_never_clicks_the_covering_app) ... ok
|
||||
test_session_end_releases_native_input_only_once
|
||||
(test_pcview.AdapterTests.test_session_end_releases_native_input_only_once) ... ok
|
||||
test_stats_keep_capture_time_and_bound_records
|
||||
(test_pcview.AdapterTests.test_stats_keep_capture_time_and_bound_records) ... ok
|
||||
test_windows_release_and_negative_monitor_origin
|
||||
(test_pcview.AdapterTests.test_windows_release_and_negative_monitor_origin) ... ok
|
||||
test_windows_sources_follow_actual_wgc_capabilities
|
||||
(test_pcview.AdapterTests.test_windows_sources_follow_actual_wgc_capabilities) ... ok
|
||||
test_windows_uses_wgc_and_media_foundation
|
||||
(test_pcview.AdapterTests.test_windows_uses_wgc_and_media_foundation) ... ok
|
||||
test_never_offered_off_windows_even_when_turned_on
|
||||
(test_pcview.GatingTests.test_never_offered_off_windows_even_when_turned_on) ... ok
|
||||
test_off_by_default_starts_nothing (test_pcview.GatingTests.test_off_by_default_starts_nothing)
|
||||
... ok
|
||||
test_only_windows_gets_the_pc_host (test_pcview.GatingTests.test_only_windows_gets_the_pc_host)
|
||||
... ok
|
||||
test_server_enable_action (test_pcview.GatingTests.test_server_enable_action) ... ok
|
||||
test_turning_off_stops_streams_and_the_helper
|
||||
(test_pcview.GatingTests.test_turning_off_stops_streams_and_the_helper) ... ok
|
||||
test_turning_on_checks_for_the_bundled_libraries
|
||||
(test_pcview.GatingTests.test_turning_on_checks_for_the_bundled_libraries) ... ok
|
||||
test_expiry_and_stop_before_redeem (test_pcview.GrantsTests.test_expiry_and_stop_before_redeem)
|
||||
... ok
|
||||
test_source_bound_retry_ack_and_stop
|
||||
(test_pcview.GrantsTests.test_source_bound_retry_ack_and_stop) ... ok
|
||||
test_agent_reports_its_host_and_encoder
|
||||
(test_pcview.NativeTests.test_agent_reports_its_host_and_encoder) ... ok
|
||||
test_auth_and_real_h264_stats (test_pcview.NativeTests.test_auth_and_real_h264_stats) ... skipped
|
||||
'x264 is only in CI test bundles, not the installer'
|
||||
test_bundled_pipewire_context_needs_no_system_modules
|
||||
(test_pcview.NativeTests.test_bundled_pipewire_context_needs_no_system_modules) ... skipped
|
||||
'Linux PipeWire client libraries'
|
||||
test_installer_bundle_leaves_out_gpl_x264
|
||||
(test_pcview.NativeTests.test_installer_bundle_leaves_out_gpl_x264) ... ok
|
||||
test_last_raw_picture_survives_a_closed_gate
|
||||
(test_pcview.NativeTests.test_last_raw_picture_survives_a_closed_gate) ... skipped 'x264 is only
|
||||
in CI test bundles, not the installer'
|
||||
test_native_property_probe_checks_the_instantiated_element
|
||||
(test_pcview.NativeTests.test_native_property_probe_checks_the_instantiated_element) ... ok
|
||||
test_card_says_experimental_and_what_is_unverified
|
||||
(test_pcview.PageTests.test_card_says_experimental_and_what_is_unverified) ... ok
|
||||
test_opt_in_is_remembered_with_the_client_preferences
|
||||
(test_pcview.PageTests.test_opt_in_is_remembered_with_the_client_preferences) ... ok
|
||||
test_tool_link_is_windows_only_and_marked_experimental
|
||||
(test_pcview.PageTests.test_tool_link_is_windows_only_and_marked_experimental) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 33 tests in 1.684s
|
||||
|
||||
OK (skipped=4)
|
||||
+31
-13
@@ -1,15 +1,19 @@
|
||||
# PC in the headset (Experimental)
|
||||
|
||||
**Tools → PC in the headset**, on Windows only. It's the Windows version of
|
||||
[Mac in the headset](mac-in-headset.md): a window or a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Place it with the dashboard's Float
|
||||
[Mac in the headset](mac-in-headset.md): a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Individual windows are available only
|
||||
with streaming libraries built with Windows Graphics Capture (WGC); the shipped
|
||||
MSYS2 libraries disable WGC, so this build explains that limitation and offers
|
||||
whole screens. Place the panel with the dashboard's Float
|
||||
in World, Move and Size, point and click with the laser, scroll with the
|
||||
thumbstick, and type on the PC's keyboard.
|
||||
|
||||
**It's experimental and not yet tried on a real Windows desktop or with a
|
||||
headset.** It builds and passes its protocol tests on Windows in CI, but desktop
|
||||
capture, Media Foundation encoding and laser input have never run on a real
|
||||
PC. Expect it not to work yet. Reports are welcome through the
|
||||
**It's experimental.** A Windows 11 VM has captured its real desktop with
|
||||
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
|
||||
hardware encoding and physical laser input remain unverified. See
|
||||
[the dated evidence](evidence/windows-vm-2026-10-24.md) for results and limits.
|
||||
Reports are welcome through the
|
||||
[feedback form](https://frame-control.pages.dev/feedback/).
|
||||
|
||||
## Turning it on
|
||||
@@ -31,17 +35,18 @@ streaming app. GStreamer is bundled as ordinary libraries.
|
||||
|
||||
| | Windows |
|
||||
|---|---|
|
||||
| Capture | Windows.Graphics.Capture, through GStreamer's `d3d11screencapturesrc capture-api=wgc`, by window (HWND) or screen (HMONITOR) |
|
||||
| Capture | GStreamer's `d3d11screencapturesrc`: Desktop Duplication for whole screens in the shipped build; Windows.Graphics.Capture by window (HWND) or screen (HMONITOR) when that implementation is present |
|
||||
| Encoding | Media Foundation's H.264 encoder (`mfh264enc`), low latency, no B-frames. Whether it uses the GPU depends on the PC; **not verified** |
|
||||
| Input | `SendInput`, mapped through the source's bounds, with per-monitor DPI awareness |
|
||||
|
||||
API choices are documented, not device-verified:
|
||||
API references:
|
||||
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
|
||||
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
|
||||
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html).
|
||||
|
||||
It needs Windows 10 or 11 with Windows.Graphics.Capture and a Media Foundation
|
||||
H.264 encoder (Windows "N" editions need the Media Feature Pack). Clicks and
|
||||
It needs Windows 10 or 11 with Desktop Duplication and a Media Foundation
|
||||
H.264 encoder (Windows "N" editions need the Media Feature Pack). WGC-enabled
|
||||
builds can also capture individual windows. Clicks and
|
||||
keys can't reach administrator apps or the sign-in screen, which an ordinary
|
||||
process can't drive. If Windows won't bring the chosen window to the front,
|
||||
input is refused rather than sent to the app covering it: bring the window
|
||||
@@ -77,6 +82,13 @@ UCRT64 packages. `libraries.json` records the GStreamer version, the plugins,
|
||||
each copied DLL and the exact package versions; licence texts are in
|
||||
`licenses/`.
|
||||
|
||||
MSYS2's `gst-plugins-bad` build disables `d3d11-wgc`. The presence of
|
||||
`d3d11screencapturesrc` alone does not prove WGC support: its `capture-api`,
|
||||
`window-handle` and `show-border` properties are conditional. The helper queries
|
||||
those properties on the instantiated element, advertises only supported
|
||||
sources, and omits all three properties for the DXGI whole-screen pipeline.
|
||||
It never substitutes a screen crop for an unavailable window capture.
|
||||
|
||||
The x264 encoder is **not** in the installer. It's GPL, and Windows encodes
|
||||
with Media Foundation, so only CI's test builds include it
|
||||
(`desktop/build.py --with-x264`). The macOS and Linux installers are unchanged.
|
||||
@@ -127,11 +139,17 @@ python scripts/macview-bench.py run --pc --scenario capture --source window:1234
|
||||
[earlier probe](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
|
||||
- **Verified, #38, on a Mac:** the C controller matched all 600 states of the
|
||||
Swift controller's congestion and recovery trace.
|
||||
- **Not verified:** anything on a real Windows desktop. Windows.Graphics.Capture,
|
||||
Media Foundation (hardware or software), encoder restarts when the bitrate
|
||||
- **Verified, 2026-10-24:** Windows 11 VM build 26200.8037 on lxso2, using
|
||||
the v0.4.10 installer with this fix, captured its actual desktop and streamed
|
||||
H.264 to Frame BUILD_ID `20261015.6141207`: 219 sent frame records, 215 decoded
|
||||
and drawn before the snapshot. The own-panel screenshot shows the Windows
|
||||
desktop. This short, mostly idle run does not establish performance targets.
|
||||
[Evidence, raw traces and cleanup](evidence/windows-vm-2026-10-24.md).
|
||||
- **Not verified:** Windows.Graphics.Capture, physical-PC capture, hardware
|
||||
Media Foundation encoding, encoder restarts when the bitrate
|
||||
changes, mixed-DPI and multi-monitor input alignment, window resize and
|
||||
minimise, non-US keyboard layouts, physical laser input, and how it performs
|
||||
in a worn headset. No numbers are claimed for these. Not tested with a
|
||||
headset from this branch.
|
||||
worn headset. See the dated VM evidence for the separate automated checks.
|
||||
- **Not reviewed independently.** #38 tried an independent review that never
|
||||
completed.
|
||||
+5
-3
@@ -203,9 +203,11 @@ and display name of the headset being tried (whole, whatever their case), and
|
||||
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
|
||||
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
|
||||
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
|
||||
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
|
||||
in some other wording can still get through, so check the log lines in **Show
|
||||
exactly what's included** before sending. A failure that repeats on every
|
||||
included) becomes `<user>@<host>:`; then the scrubbing below. Whole display names
|
||||
are hidden even when they contain a scrubber token such as `<host>` or overlap
|
||||
ssh's wording. Those overlaps also preserve redaction of URLs, home folders,
|
||||
and keys. A host name ssh mentions in some other wording can still get through,
|
||||
so check the log lines in **Show exactly what's included** before sending. A failure that repeats on every
|
||||
retry is written at most once every 5 minutes.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
|
||||
+33
-7
@@ -255,15 +255,19 @@ tests the installer's own bundle with its bundled Python and checks x264 isn't
|
||||
in it. `FRAME_PC_REQUIRE_NATIVE=1` and `FRAME_PC_REQUIRE_X264=1` make a missing
|
||||
bundle or encoder fail CI instead of skipping.
|
||||
|
||||
None of this captures a desktop, exercises Media Foundation or a GPU, or
|
||||
checks laser alignment. The [Windows test VM](#windows-test-vm) is the next
|
||||
step for that. See [what's verified](pc-in-headset.md#whats-verified).
|
||||
These automated CI checks do not capture a desktop, exercise Media Foundation
|
||||
or a GPU, or check laser alignment. A [Windows test VM](#windows-test-vm) run
|
||||
on 2026-10-24 captured its actual desktop with Desktop Duplication, encoded
|
||||
with Media Foundation and streamed to a real Frame. It found MSYS2 disables
|
||||
WGC: an element name alone does not prove its optional capture properties
|
||||
exist. Native property-probe tests and source-selection regressions now cover
|
||||
that boundary. [Dated evidence and limits](evidence/windows-vm-2026-10-24.md);
|
||||
[what's verified](pc-in-headset.md#whats-verified).
|
||||
|
||||
## Windows test VM
|
||||
|
||||
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||
depends on the Windows desktop (Remote Desktop, the installer, the bundled
|
||||
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
|
||||
Windows CI runs the unit tests and checks installer contents. Desktop capture,
|
||||
installer launch and interactive behavior need a Windows machine. This is a Windows 11 VM in a
|
||||
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
|
||||
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
|
||||
|
||||
@@ -283,7 +287,14 @@ Setting it up, once, on the Linux machine:
|
||||
`~/.ssh/id_ed25519_winvm` on the Mac) in
|
||||
`C:\ProgramData\ssh\administrators_authorized_keys`.
|
||||
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
|
||||
and the tests.
|
||||
and the tests. Inspect free memory/disk, KVM access and existing VMs before
|
||||
starting; leave other VMs alone. The first install can take tens of minutes
|
||||
(the 2026-10-24 run took about 16 minutes to reach the desktop), rather than
|
||||
the 1–2 minute startup of an already installed guest.
|
||||
- For PC streaming, check the actual bundled capture properties. MSYS2's
|
||||
GStreamer build disables WGC: this app falls back to Desktop Duplication for
|
||||
whole screens and explains why individual windows are unavailable. A VM
|
||||
capture test does not prove physical GPU encoding or laser alignment.
|
||||
|
||||
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
|
||||
|
||||
@@ -426,3 +437,18 @@ files, source preference, missing/invalid sensors and permission errors.
|
||||
`tests/control_center.cjs` covers charging states and optional health fields,
|
||||
alongside existing stale/offline handling. No headset is used. See
|
||||
[battery monitoring](battery-health.md) for sources, units and limitations.
|
||||
|
||||
## Host-key recovery presentation
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_link_ui.py -v` runs the page's
|
||||
connection/banner/selection functions in Node and checks the recovery CSS guard.
|
||||
`test_link.py` covers connection failures and short probe reasons with fake SSH
|
||||
and local sockets. Neither suite uses a real headset.
|
||||
|
||||
Browser verification on 2026-10-24 used the current page's markup, styles and
|
||||
recovery renderers with synthetic changed-key state and two headsets at
|
||||
1280×800. It caught a later `.item .s` rule overriding the earlier wrapping
|
||||
rule, which the original source-only assertion missed. Check computed wrapping,
|
||||
the complete explanation, tooltip punctuation, Refresh bounds and selection
|
||||
after **Use this headset** when changing these styles. This fixture does not
|
||||
exercise Electron integration or a real SSH identity change.
|
||||
@@ -13,13 +13,13 @@ function grab(name) {
|
||||
if (start < 0) fail(`no top-level ${name} in index.html`);
|
||||
let depth = 0;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
|
||||
for (const c of lines[i]) depth += c === "{" || c === "[" ? 1 : c === "}" || c === "]" ? -1 : 0;
|
||||
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
|
||||
}
|
||||
fail(`unbalanced ${name}`);
|
||||
}
|
||||
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeReportDetails", "storePickEligible", "STORE_CATS", "storeCat", "storeCard", "storeRow", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
|
||||
const code = names.map(grab).join("");
|
||||
|
||||
@@ -34,7 +34,7 @@ function page(api = async () => ({}), androidApps = []) {
|
||||
const document = { activeElement: null, querySelector: () => null };
|
||||
const console = { error() {} };
|
||||
const calls = [];
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp, storeCard, storeRow, storePickEligible };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps,
|
||||
async (title, key, work) => work(), () => {}, async (title, work) => work(),
|
||||
{ open() { fail("an in-app installation opened an external page"); } }, 0);
|
||||
@@ -114,4 +114,31 @@ for (const [job, expected] of [[null, 1], [{ done: false }, 0],
|
||||
if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
|
||||
if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
|
||||
}
|
||||
|
||||
// Report warnings appear on real cards, shelves and details; they are advisory.
|
||||
for (const [kind, tone, label] of [['no', 'warning', 'Reported not working'], ['maybe', 'issues', 'Reported issues']]) {
|
||||
const p = page();
|
||||
const o = {...offer, verdict: {tone, label: label+' on the Frame'},
|
||||
compat_report: {verdict: kind, lines: ['Reported <unsafe> notes', '2 reports, 0 working']}};
|
||||
const app = {name: o.name, offers: [o]};
|
||||
p.sourceState.apps = [app];
|
||||
p.sourceState.detail = {app, choice: 0};
|
||||
const card = p.storeCard(app), shelf = p.storeRow('VR picks', 'Synthetic fixtures', [app]);
|
||||
if (!card.includes('>'+label+'</span>') || !shelf.includes('>'+label+'</span>')) fail('warning missing on card or shelf');
|
||||
if (!p.renderAppDetail()) fail('report detail did not render');
|
||||
const html = p.els.detailBody.innerHTML;
|
||||
if (!html.includes('Saved compatibility reports') || !html.includes('Reported <unsafe> notes')) fail('report evidence missing or unescaped');
|
||||
if (html.includes('Reported <unsafe>')) fail('report notes injected HTML');
|
||||
if (p.els.detailInstall.disabled || p.els.detailInstall.textContent !== 'Install on the Frame') fail('report disabled installation');
|
||||
if (kind==='no' && p.storePickEligible(o)) fail('broken report is eligible for featured/VR picks');
|
||||
// A positive report cannot remove a hard blocker. Negative evidence stays visible too.
|
||||
for (const reportKind of [kind, 'works']) {
|
||||
const blocked = {...o, verdict: {tone: 'blocked', label: 'No arm64 build'}, compat_report: {...o.compat_report, verdict: reportKind}};
|
||||
app.offers = [blocked];
|
||||
if (!p.renderAppDetail() || !p.els.detailInstall.disabled) fail('report overrode hard blocker');
|
||||
if (p.storePickEligible(blocked)) fail('blocked app eligible for picks');
|
||||
if (reportKind!=='works' && !p.storeCard(app).includes('>'+label+'</span>')) fail('hard blocker hid report warning');
|
||||
}
|
||||
}
|
||||
|
||||
console.log("ok");
|
||||
@@ -22,6 +22,7 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
|
||||
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
|
||||
# A maintainer's machine holds the database key; send anything that slips through nowhere.
|
||||
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
|
||||
os.environ["FRAME_COMPAT_SEARCH_REPORTS"] = "0" # server subprocesses never start real report readers
|
||||
|
||||
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
|
||||
# its lock file) and the other per-user folders some modules use. A server started
|
||||
|
||||
@@ -17,6 +17,9 @@ PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').rea
|
||||
|
||||
class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
p = patch.object(search, 'compat_reports', None)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
with _images._lock:
|
||||
_images._urls.clear()
|
||||
_images._tokens.clear()
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Search report snapshots: synthetic evidence only, no credentials or services."""
|
||||
import sandbox # noqa: F401
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_catalog
|
||||
import frame_compat_db as db
|
||||
from apk_sources import search
|
||||
from test_apk_search import SettingsTest, fake, ENTRIES
|
||||
|
||||
|
||||
class ReportTests(SettingsTest):
|
||||
def hook(self, rows):
|
||||
by = frame_catalog.reports.by_package(rows)
|
||||
snapshot = {pkg: v for pkg, rs in by.items() if (v := frame_catalog.reports.verdict(rs))}
|
||||
p = patch.object(search, 'compat_reports', lambda: snapshot)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_search_and_details_share_catalogue_rules(self):
|
||||
pkg = ENTRIES[0]['package']
|
||||
self.hook([dict(package=pkg, date='2026-10-01', result='runs'),
|
||||
dict(package=pkg, date='2026-10-02', rating='broken', notes='Crashes <here>'),
|
||||
dict(package=pkg, date='2026-10-03', result='runs')])
|
||||
mod = fake()
|
||||
with patch.object(search, 'modules', return_value=([mod], [])):
|
||||
offer = search.search(timeout=.01)['apps'][0]['offers'][0]
|
||||
detail = search.details('one', ENTRIES[0]['id'])
|
||||
self.assertEqual(detail['compat_report']['verdict'], 'no')
|
||||
self.assertEqual(offer['compat_report'], detail['compat_report'])
|
||||
self.assertEqual(detail['verdict']['tone'], 'warning')
|
||||
self.assertIn('Reported not working', detail['verdict']['label'])
|
||||
|
||||
def test_warnings_never_change_installability_or_hard_blockers(self):
|
||||
entry = dict(ENTRIES[0], min_sdk=23, abis=['arm64-v8a'])
|
||||
for kind, tone in [('works', 'works'), ('issues', 'issues'), ('broken', 'warning')]:
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', rating=kind)])
|
||||
decorated = search.decorate(entry)
|
||||
self.assertEqual(decorated['verdict']['tone'], tone)
|
||||
self.assertTrue(decorated['fit']['installable'])
|
||||
for blocker in [dict(min_sdk=31), dict(abis=['x86_64']),
|
||||
dict(requires_meta_services=True), dict(engine='VrApi')]:
|
||||
blocked = search.decorate(dict(entry, **blocker))
|
||||
self.assertEqual(blocked['verdict']['tone'], 'blocked')
|
||||
self.assertIsNotNone(blocked['compat_report'])
|
||||
|
||||
def test_wrong_file_failures_do_not_warn_but_real_failures_do(self):
|
||||
entry = dict(ENTRIES[0], min_sdk=23, abis=[])
|
||||
for notes in ['no arm64-v8a build', 'needs Android API 32']:
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes=notes)])
|
||||
self.assertIsNone(search.decorate(entry)['compat_report'])
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', result='crashes')])
|
||||
self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning')
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes='installer crashed')])
|
||||
self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning')
|
||||
|
||||
def test_report_warning_does_not_prevent_install(self):
|
||||
self.hook([dict(package=ENTRIES[0]['package'], date='2026-10-01', rating='broken')])
|
||||
mod = fake()
|
||||
with patch.object(search, 'modules', return_value=([mod], [])), \
|
||||
patch.object(frame_catalog.frame_android, 'install', return_value={'package': ENTRIES[0]['package']}) as install:
|
||||
result = search.install('one', ENTRIES[0]['id'])
|
||||
self.assertEqual(result['package'], ENTRIES[0]['package'])
|
||||
install.assert_called_once()
|
||||
|
||||
def test_stalled_loader_does_not_block_search_or_details(self):
|
||||
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
|
||||
main = threading.get_ident()
|
||||
|
||||
def loader():
|
||||
self.assertNotEqual(threading.get_ident(), main)
|
||||
entered.set()
|
||||
try:
|
||||
if not release.wait(2):
|
||||
raise TimeoutError('test stalled')
|
||||
return []
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
cache = search.ReportCache(loader, frame_catalog.reports.verdict)
|
||||
from apk_sources import _web
|
||||
with patch.object(search, 'compat_reports', cache.read), \
|
||||
patch.object(search, 'modules', return_value=([fake()], [])), \
|
||||
patch.object(_web, 'prune'), \
|
||||
patch.object(db, 'key', side_effect=AssertionError('credentials on request')), \
|
||||
patch.object(db._opener, 'open', side_effect=AssertionError('network on request')), \
|
||||
patch.object(db.subprocess, 'run', side_effect=AssertionError('subprocess on request')):
|
||||
try:
|
||||
search.warm() # production server startup, before the first request
|
||||
self.assertTrue(entered.wait(1))
|
||||
start = time.monotonic()
|
||||
result = search.search(timeout=.01)
|
||||
detail = search.details('one', ENTRIES[0]['id'])
|
||||
self.assertLess(time.monotonic() - start, .2)
|
||||
self.assertTrue(result['apps'])
|
||||
self.assertIsNone(detail['compat_report'])
|
||||
finally:
|
||||
release.set()
|
||||
self.assertTrue(finished.wait(1))
|
||||
|
||||
|
||||
class CacheTests(unittest.TestCase):
|
||||
def wait_idle(self, cache):
|
||||
deadline = time.monotonic() + 1
|
||||
while time.monotonic() < deadline:
|
||||
with cache._lock:
|
||||
if not cache._running:
|
||||
return
|
||||
time.sleep(.001)
|
||||
self.fail('report worker did not finish')
|
||||
|
||||
def test_public_copy_reads_its_local_mirror_and_outbox(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mirror, outbox = Path(tmp) / 'mirror.json', Path(tmp) / 'outbox.jsonl'
|
||||
mirror.write_text(json.dumps({'reports': [dict(package='org.example.app', id='old',
|
||||
date='2026-10-01', result='runs')]}))
|
||||
outbox.write_text(json.dumps(dict(package='org.example.app', id='new',
|
||||
date='2026-10-02', rating='broken')) + '\n')
|
||||
with patch.object(db, 'MIRROR', str(mirror)), patch.object(db, 'OUTBOX', str(outbox)), \
|
||||
patch.object(db, '_mem', dict(at=0, reports=None, source=None)), \
|
||||
patch.object(db, 'shared', return_value=False), \
|
||||
patch.object(db, 'fetch_all', side_effect=AssertionError('private database read')):
|
||||
cache = search.ReportCache(db.load, frame_catalog.reports.verdict)
|
||||
cache.read()
|
||||
self.wait_idle(cache)
|
||||
self.assertEqual(cache.read()['org.example.app'][0], 'no')
|
||||
|
||||
def test_ttl_refresh_is_single_flight_and_keeps_last_good_snapshot(self):
|
||||
loader = Mock(return_value=[dict(package='org.example.app', date='2026-10-01', rating='issues')])
|
||||
cache = search.ReportCache(loader, frame_catalog.reports.verdict, ttl=60)
|
||||
cache.read()
|
||||
self.wait_idle(cache)
|
||||
first = cache.read()
|
||||
self.assertEqual(first['org.example.app'][0], 'maybe')
|
||||
self.assertEqual(loader.call_count, 1)
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
|
||||
def stalled():
|
||||
entered.set()
|
||||
release.wait(2)
|
||||
raise OSError('offline')
|
||||
|
||||
loader.side_effect = stalled
|
||||
cache._at -= 61
|
||||
try:
|
||||
self.assertIs(cache.read(), first)
|
||||
self.assertTrue(entered.wait(1))
|
||||
for _ in range(10):
|
||||
self.assertIs(cache.read(), first)
|
||||
self.assertEqual(loader.call_count, 2)
|
||||
finally:
|
||||
release.set()
|
||||
self.wait_idle(cache)
|
||||
self.assertIs(cache.read(), first)
|
||||
self.assertEqual(loader.call_count, 2) # failure also has a retry TTL
|
||||
loader.side_effect = None
|
||||
loader.return_value = [dict(package='org.example.app', date='2026-10-02', rating='works')]
|
||||
cache._at -= 61
|
||||
cache.read()
|
||||
self.wait_idle(cache)
|
||||
self.assertEqual(cache.read()['org.example.app'][0], 'works')
|
||||
self.assertEqual(first['org.example.app'][0], 'maybe') # published snapshots never mutated
|
||||
@@ -27,6 +27,9 @@ def fake(source_id='one', fn=None):
|
||||
|
||||
class SettingsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
p = patch.object(search, 'compat_reports', None)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json')
|
||||
|
||||
@@ -64,6 +64,7 @@ class LinkUI(unittest.TestCase):
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
|
||||
assert.ok($('conn').title.includes(changed.error.message));
|
||||
assert.ok(!$('conn').title.includes('..'), $('conn').title);
|
||||
// Without a short reason: the stage that failed.
|
||||
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
|
||||
renderPill();
|
||||
@@ -120,7 +121,9 @@ class LinkUI(unittest.TestCase):
|
||||
class HeaderLayout(unittest.TestCase):
|
||||
def test_the_devices_tab_explanation_wraps(self):
|
||||
# It was cut to one line with no way to read the rest.
|
||||
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
|
||||
# More specific than the later .item .s rule: the old equal-specificity
|
||||
# rule passed this test while the browser still clipped the explanation.
|
||||
self.assertIn(".item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }", PAGE)
|
||||
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
|
||||
|
||||
def test_the_header_buttons_dont_give_way(self):
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Issue #81: synthetic, deterministic log scrubbing; no sockets or private data."""
|
||||
import sandbox # noqa: F401
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_link as fl
|
||||
import frame_telemetry as tm
|
||||
|
||||
|
||||
def baseline(text, hosts):
|
||||
"""Frozen v0.4.11 connection pipeline, before issue #81's extra spans.
|
||||
|
||||
Keep these operations independent of frame_link's implementations so a change
|
||||
to its ordering or matching cannot silently weaken the differential oracle.
|
||||
The shared scrubber's default behavior is deliberately unchanged by this fix.
|
||||
"""
|
||||
keep = {'host', 'hostname', 'to', 'port', 'answer', 'the', 'a', 'an', 'this',
|
||||
'it', 'its', 'be', 'connect', 'find', 'work', 'try'}
|
||||
names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
|
||||
parts = re.split(r'(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)', text)
|
||||
for i in range(0, len(parts), 2):
|
||||
for name in names:
|
||||
parts[i] = re.sub(r'(?<![\w.:-])%s(?![\w-]|[:.%%]\w)' % re.escape(name),
|
||||
'<host>', parts[i], flags=re.I)
|
||||
plain = keep - {'host', 'hostname', 'to', 'port'}
|
||||
|
||||
def operand(match):
|
||||
word = match.group(2).rstrip('.')
|
||||
return (match.group(0) if word.lower() in plain else
|
||||
f'{match.group(1)} <host>{match.group(2)[len(word):]}')
|
||||
|
||||
text = re.sub(r'''(?i)\b(hostname|host|to(?:\s+host)?)\s+(?!<)([^\s:,;'"()|]+)''',
|
||||
operand, ''.join(parts))
|
||||
return tm.scrub(text, 600)
|
||||
|
||||
|
||||
class LogScrubbing(unittest.TestCase):
|
||||
def setUp(self):
|
||||
patch = mock.patch.object(tm, '_user_names', return_value={'SyntheticLogin'})
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
|
||||
def test_display_name_crosses_a_scrubber_token(self):
|
||||
name = '<host> Jane Doe headset'
|
||||
self.assertEqual(fl.scrub_failure(name + ' stopped answering', [name]), '<host> stopped answering')
|
||||
self.assertEqual(fl.scrub_failure('<user>@<host>: <ip>', ['user', 'host', 'ip']),
|
||||
'<user>@<host>: <ip>')
|
||||
self.assertEqual(fl.scrub_failure('<host>', ['<host>']), '<host>')
|
||||
|
||||
def test_all_standalone_tokens_survive_and_whole_names_cross_them(self):
|
||||
for token in ('host', 'user', 'email', 'ip', 'mac', 'steamid', 'hex',
|
||||
'token', 'ssh-key', 'pem', 'url', 'redacted'):
|
||||
token = '<' + token + '>'
|
||||
name = token + ' Synthetic Fixture headset'
|
||||
with self.subTest(token=token):
|
||||
self.assertEqual(fl.scrub_failure(token, [token, token[1:-1]]), token)
|
||||
self.assertEqual(fl.scrub_failure(name.upper() + ' failed', [name]), '<host> failed')
|
||||
|
||||
def test_a_name_crossing_the_output_limit_leaves_no_fragment(self):
|
||||
name = '<host> ' + 'Synthetic ' * 100 + 'headset'
|
||||
self.assertEqual(fl.scrub_failure(name + ' trailing detail', [name]), '<host>')
|
||||
|
||||
def test_display_name_cannot_swallow_ssh_operand_marker(self):
|
||||
text = 'ssh: connect to host bastion-jane port 22: Connection refused'
|
||||
self.assertEqual(fl.scrub_failure(text, ['to host']),
|
||||
'ssh: connect <host> <host> port 22: Connection refused')
|
||||
|
||||
def test_reverted_fix_counterexamples(self):
|
||||
cases = [
|
||||
('Could not connect to hostname bastion-jane', ['connect to'], ['bastion-jane']),
|
||||
('Jane Smith@<host>: Permission denied', ['Smith@<host>'], ['Jane', 'Smith']),
|
||||
('frame.local Jane Doe headset stopped answering', ['frame.local Jane Doe headset'], ['Jane', 'Doe']),
|
||||
('x ' * 295 + 'frame.local Jane Doe headset', ['frame.local Jane Doe headset'], ['Jane', 'Doe']),
|
||||
('https://jane@example.com/private(confidential)', ['jane@example.com'], ['private', 'confidential']),
|
||||
(r'C:\Users\Jane@workpc (Smith)\.ssh\config', ['Jane@workpc'], ['Jane', 'Smith']),
|
||||
('-----BEGIN PRIVATE KEY-----\nSYNTHETICBODY\n-----END PRIVATE KEY-----@frame',
|
||||
['KEY-----@frame'], ['SYNTHETICBODY']),
|
||||
('Could not connect to hostname /home/Jane Doe/.ssh/config', ['connect to'], ['Jane', 'Doe']),
|
||||
]
|
||||
for text, hosts, private in cases:
|
||||
with self.subTest(text=text):
|
||||
out = fl.scrub_failure(text, hosts)
|
||||
for value in private:
|
||||
self.assertNotIn(value, out)
|
||||
self.assertLessEqual(len(out), 600)
|
||||
|
||||
def test_overlapping_names_and_operands_and_literal_tokens(self):
|
||||
cases = [
|
||||
('Alpha Beta Gamma failed', ['Alpha Beta', 'Beta Gamma'], '<host> failed'),
|
||||
('<host> Jane Doe headset failed', ['<host> Jane Doe headset', 'Jane Doe'], '<host> failed'),
|
||||
('to hostname bastion-jane', [], 'to <host> <host>'),
|
||||
('<host failed', ['<host'], '<host> <host>'),
|
||||
]
|
||||
for text, hosts, expected in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertEqual(fl.scrub_failure(text, hosts), expected)
|
||||
|
||||
def test_randomized_differential_against_v0411(self):
|
||||
rng = random.Random(81)
|
||||
templates = [
|
||||
'https://{u}@example.invalid/{p}(confidential)?key={p}',
|
||||
r'C:\Users\{u}@workpc ({p})\.ssh\config',
|
||||
'/home/{u} {p}/.ssh/config',
|
||||
'-----BEGIN PRIVATE KEY-----\n{p}\n-----END PRIVATE KEY-----@frame',
|
||||
'{u}.{p}@example.invalid',
|
||||
'fe80::abcd%{u}',
|
||||
'{u} {p}@frame.local: Permission denied',
|
||||
'ssh-ed25519 {p}',
|
||||
'password={p}',
|
||||
'<host> {u} {p} headset stopped answering',
|
||||
'frame.local {u} {p} headset stopped answering',
|
||||
]
|
||||
for iteration in range(1200):
|
||||
user = 'Synthetic' + ''.join(rng.choices('abcdefghijklmnopqrstuvxyz', k=14))
|
||||
private = 'Fixture' + ''.join(rng.choices('abcdefghijklmnopqrstuvxyz', k=18))
|
||||
field = rng.choice(templates).format(u=user, p=private)
|
||||
prefix = rng.choice(['', 'Could not connect to hostname ', 'ssh: connect to host ',
|
||||
'Timed out talking to ', 'debug | ', 'x ' * 295])
|
||||
text = prefix + field + rng.choice(['', ' port 22: refused', '\nnext line', "'s password: "])
|
||||
# Names intentionally cut through the syntax later scrubber rules need.
|
||||
options = [user, user + '@workpc', private, 'connect to', 'to host',
|
||||
'<host> ' + user + ' ' + private + ' headset',
|
||||
'frame.local ' + user + ' ' + private + ' headset',
|
||||
'KEY-----@frame', 'host', '<host>', 'SyntheticLogin']
|
||||
hosts = rng.sample(options, rng.randrange(5))
|
||||
before, after = baseline(text, hosts), fl.scrub_failure(text, hosts)
|
||||
with self.subTest(iteration=iteration, text=text, hosts=hosts):
|
||||
trace = fl._ScrubTrace(text)
|
||||
traced = tm.scrub(fl.hide_operands(fl.hide_hosts(text, hosts, _trace=trace),
|
||||
_sub=trace.sub), 600, _sub=trace.sub)
|
||||
self.assertEqual(traced, before)
|
||||
# Compare every original three-character window, not just whole
|
||||
# canaries: even a partially exposed user/path/secret is a regression.
|
||||
# Mask placeholders with a separator so removing one cannot join
|
||||
# unrelated text into a misleadingly new three-character window.
|
||||
old_visible = fl.PLACEHOLDER.sub('\0', before)
|
||||
new_visible = fl.PLACEHOLDER.sub('\0', after)
|
||||
for start in range(len(text) - 2):
|
||||
fragment = text[start:start + 3]
|
||||
if fragment not in old_visible:
|
||||
self.assertNotIn(fragment, new_visible)
|
||||
self.assertLessEqual(len(after), 600)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+63
-1
@@ -141,7 +141,7 @@ class PageTests(unittest.TestCase):
|
||||
def test_card_says_experimental_and_what_is_unverified(self):
|
||||
self.assertIn('<span class="tag" id="mvTag" hidden>Experimental</span>', self.page)
|
||||
self.assertIn('<input type="checkbox" id="mvPcOn">', self.page)
|
||||
self.assertIn("hasn't been tried on a real\n Windows desktop or with a headset yet", self.page)
|
||||
self.assertIn('Tested in a Windows VM; physical PCs, hardware encoding and laser clicks remain unverified.', self.page)
|
||||
self.assertIn('docs/pc-in-headset.md', self.page)
|
||||
|
||||
def test_opt_in_is_remembered_with_the_client_preferences(self):
|
||||
@@ -151,6 +151,55 @@ class PageTests(unittest.TestCase):
|
||||
|
||||
|
||||
class AdapterTests(unittest.TestCase):
|
||||
def test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties(self):
|
||||
p = capture.pipeline(dict(src='display:65537', capture_api='dxgi'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
self.assertIn('monitor-handle=65537', p)
|
||||
self.assertNotIn('capture-api=', p)
|
||||
self.assertNotIn('show-border=', p)
|
||||
self.assertIn('mfh264enc', p)
|
||||
for source in (dict(src='window:42', capture_api='dxgi'),
|
||||
dict(src='display:42', capture_api='dxgi ! fakesink')):
|
||||
with self.assertRaises(ValueError):
|
||||
capture.pipeline(source, 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
|
||||
def test_windows_sources_follow_actual_wgc_capabilities(self):
|
||||
for wgc in (False, True):
|
||||
with self.subTest(wgc=wgc):
|
||||
native, windows = mock.Mock(), mock.Mock()
|
||||
native.has_property.return_value = wgc
|
||||
windows.sources.side_effect = lambda: ([dict(src='window:12', w=800, h=600)],
|
||||
[dict(src='display:4', w=1920, h=1080)])
|
||||
with mock.patch.object(agent.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(agent, 'Windows', return_value=windows):
|
||||
host = agent.Agent(native, 'test', ROOT/'ui/mac-view.html')
|
||||
wins, displays = host.lists()
|
||||
self.assertEqual(len(wins), int(wgc))
|
||||
self.assertEqual(displays[0]['capture_api'], 'wgc' if wgc else 'dxgi')
|
||||
self.assertEqual(host.source('display:4')['capture_api'], displays[0]['capture_api'])
|
||||
if wgc:
|
||||
self.assertEqual(host.source('window:12')['capture_api'], 'wgc')
|
||||
self.assertEqual(host.window_capture_reason, '')
|
||||
else:
|
||||
with self.assertRaisesRegex(ValueError, 'Individual windows are unavailable'):
|
||||
host.source('window:12')
|
||||
self.assertIn('whole screen', host.window_capture_reason)
|
||||
# Synthetic encoder/protocol checks remain available without WGC.
|
||||
self.assertEqual(host.source('test')['src'], 'test')
|
||||
|
||||
def test_pc_card_receives_window_capture_limitation(self):
|
||||
view = frame_pcview.PCView([], lambda *a: None, 'frame')
|
||||
view.enabled = True
|
||||
view.call = mock.Mock(side_effect=lambda path: {
|
||||
'/status': dict(screen=True, accessibility=True, windowCaptureReason=agent.WINDOW_CAPTURE_UNAVAILABLE),
|
||||
'/windows': dict(windows=[]), '/displays': dict(displays=[dict(src='display:4')])}[path])
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(frame_pcview, 'LIBRARY', mock.Mock(is_file=lambda: True)):
|
||||
state = view.state()
|
||||
self.assertTrue(state['available'])
|
||||
self.assertEqual(state['windowCaptureReason'], agent.WINDOW_CAPTURE_UNAVAILABLE)
|
||||
self.assertEqual(state['windows'], [])
|
||||
self.assertEqual(state['displays'], [dict(src='display:4')])
|
||||
|
||||
def test_windows_uses_wgc_and_media_foundation(self):
|
||||
for kind in ('window', 'display'):
|
||||
pipeline = capture.pipeline(dict(src=kind+':42'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
@@ -366,6 +415,19 @@ class NativeTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self.request('/close?k='+self.token, 'POST')
|
||||
|
||||
def test_native_property_probe_checks_the_instantiated_element(self):
|
||||
code = """
|
||||
from frame_pc_capture import Native
|
||||
n = Native()
|
||||
assert n.has_property('fakesink', 'sync')
|
||||
assert not n.has_property('fakesink', 'not-a-gstreamer-property')
|
||||
assert not n.has_property('not-a-gstreamer-element', 'sync')
|
||||
"""
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['PYTHONPATH'] = str(ROOT/'ui')
|
||||
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith('linux'), 'Linux PipeWire client libraries')
|
||||
def test_bundled_pipewire_context_needs_no_system_modules(self):
|
||||
# No portal request, remote connection or desktop capture: create and
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Optional store metadata: images {icon, banner, screenshots}, developer,
|
||||
description, popularity, open_source, requires_meta_services, frame_tested.
|
||||
Only an explicit frame_tested=True produces a working-on-Frame verdict.
|
||||
An explicit frame_tested=True or a saved working report produces a working verdict.
|
||||
"""
|
||||
import importlib
|
||||
import inspect
|
||||
@@ -24,6 +24,46 @@ _game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
|
||||
_status = {}
|
||||
TIMEOUT = 12
|
||||
BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping
|
||||
compat_reports = None # optional memory-only snapshot hook; enabled at server start, not import
|
||||
|
||||
|
||||
class ReportCache:
|
||||
"""Stale-while-refresh reports. Never hold the snapshot lock while loading.
|
||||
|
||||
The loader may block on disk, credentials or the database. Only its daemon
|
||||
worker calls it; readers keep the last complete snapshot, even after errors.
|
||||
"""
|
||||
def __init__(self, loader, report_verdict, ttl=60):
|
||||
self.loader, self.report_verdict, self.ttl = loader, report_verdict, ttl
|
||||
self._lock = threading.Lock()
|
||||
self._snapshot, self._at, self._running = {}, None, False
|
||||
|
||||
def read(self):
|
||||
with self._lock:
|
||||
snapshot = self._snapshot
|
||||
if not self._running and (self._at is None or time.monotonic() - self._at >= self.ttl):
|
||||
self._running = True
|
||||
threading.Thread(target=self._refresh, daemon=True).start()
|
||||
return snapshot
|
||||
|
||||
def _refresh(self):
|
||||
try:
|
||||
by = {}
|
||||
for report in self.loader():
|
||||
if report.get('package'):
|
||||
by.setdefault(report['package'], []).append(report)
|
||||
snapshot = {pkg: v for pkg, rows in by.items() if (v := self.report_verdict(rows))}
|
||||
with self._lock:
|
||||
self._snapshot = snapshot
|
||||
except Exception:
|
||||
pass # optional evidence must never break search or discard a good snapshot
|
||||
finally:
|
||||
with self._lock:
|
||||
self._at, self._running = time.monotonic(), False
|
||||
|
||||
|
||||
def _reports():
|
||||
return compat_reports() if compat_reports else {}
|
||||
|
||||
|
||||
def modules():
|
||||
@@ -149,7 +189,7 @@ def fit(entry):
|
||||
'reasons': reasons}
|
||||
|
||||
|
||||
def verdict(entry):
|
||||
def verdict(entry, report=None):
|
||||
compatibility = fit(entry)
|
||||
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
|
||||
if entry.get('requires_meta_services') is True:
|
||||
@@ -160,6 +200,12 @@ def verdict(entry):
|
||||
return {'label': "This version isn't made for the Frame", 'tone': 'blocked'}
|
||||
if 'vrapi' in hints:
|
||||
return {'label': "Made for older Quest headsets; won't run on the Frame", 'tone': 'blocked'}
|
||||
if report:
|
||||
kind, _ = report
|
||||
return {'label': {'works': 'Reported working on the Frame',
|
||||
'maybe': 'Reported issues on the Frame',
|
||||
'no': 'Reported not working on the Frame'}[kind],
|
||||
'tone': {'works': 'works', 'maybe': 'issues', 'no': 'warning'}[kind]}
|
||||
if entry.get('frame_tested') is True:
|
||||
return {'label': 'Works on the Frame', 'tone': 'works'}
|
||||
if compatibility['installable'] is True:
|
||||
@@ -167,10 +213,13 @@ def verdict(entry):
|
||||
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
|
||||
|
||||
|
||||
def decorate(entry, shots=12):
|
||||
def decorate(entry, shots=12, reports=None):
|
||||
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
|
||||
from apk_sources import _images
|
||||
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
|
||||
report = (reports if reports is not None else _reports()).get(entry.get('package'))
|
||||
evidence = {'verdict': report[0], 'lines': list(report[1])} if report else None
|
||||
return dict(entry, fit=fit(entry), verdict=verdict(entry, report), compat_report=evidence,
|
||||
artwork=_images.artwork(entry, shots),
|
||||
**categories.classify(entry))
|
||||
|
||||
|
||||
@@ -191,8 +240,9 @@ def offer_rank(entry):
|
||||
|
||||
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
|
||||
groups = {}
|
||||
reports = _reports() # one complete snapshot for every offer in this response
|
||||
for entry in entries:
|
||||
entry = decorate(entry, shots=3)
|
||||
entry = decorate(entry, shots=3, reports=reports)
|
||||
if not categories.matches(entry, category, phone):
|
||||
continue
|
||||
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
|
||||
@@ -310,6 +360,7 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
|
||||
|
||||
def warm():
|
||||
"""Start every enabled source's index download in the background (server start, new repo)."""
|
||||
_reports() # warm independently of potentially slow source indexes
|
||||
from apk_sources import _web
|
||||
_web.prune()
|
||||
items, _ = registry()
|
||||
|
||||
+143
-16
@@ -25,6 +25,7 @@ when the page asks.
|
||||
|
||||
Python stdlib only. Runs on this computer, never on the Frame.
|
||||
"""
|
||||
from bisect import bisect_right
|
||||
import copy
|
||||
import hashlib
|
||||
import ipaddress
|
||||
@@ -203,16 +204,33 @@ def probes_summary(probes):
|
||||
PLACEHOLDER = re.compile(r"(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)")
|
||||
|
||||
|
||||
def hide_hosts(text, hosts, keep=()):
|
||||
"""text with each of `hosts` (the headset's own addresses and names) replaced by <host>,
|
||||
longest first and whatever the case, so a bare name like "steamdeck" that the scrubber
|
||||
can't recognise goes too."""
|
||||
keep = {k.lower() for k in keep if k} | KEYWORDS # a headset called "host" mustn't eat ssh's wording
|
||||
names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
|
||||
parts = PLACEHOLDER.split(text) # never inside a scrubber's own <token> already there
|
||||
for i in range(0, len(parts), 2):
|
||||
for h in names:
|
||||
parts[i] = re.sub(r"(?<![\w.:-])%s(?![\w-]|[:.%%]\w)" % re.escape(h), "<host>", parts[i], flags=re.I)
|
||||
def _host_names(hosts, keep=()):
|
||||
keep = {k.lower() for k in keep if k} | KEYWORDS
|
||||
return sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
|
||||
|
||||
|
||||
def _host_pattern(name):
|
||||
return r"(?<![\w.:-])%s(?![\w-]|[:.%%]\w)" % re.escape(name)
|
||||
|
||||
|
||||
def hide_hosts(text, hosts, keep=(), *, _trace=None):
|
||||
"""Hide whole known names, longest first, preserving existing scrubber tokens."""
|
||||
names = _host_names(hosts, keep)
|
||||
parts = PLACEHOLDER.split(text)
|
||||
ranges, offset = [], 0
|
||||
for i, part in enumerate(parts):
|
||||
trace = _ScrubTrace(part) if _trace is not None else None
|
||||
if trace is not None:
|
||||
trace.ranges = _trace.ranges[offset:offset + len(part)]
|
||||
offset += len(part)
|
||||
if i % 2 == 0:
|
||||
sub = trace.sub if trace is not None else re.sub
|
||||
for name in names:
|
||||
parts[i] = sub(_host_pattern(name), "<host>", parts[i], flags=re.I)
|
||||
if trace is not None:
|
||||
ranges.extend(trace.ranges)
|
||||
if _trace is not None:
|
||||
_trace.ranges = ranges
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
@@ -223,19 +241,128 @@ PLAIN_WORDS = {"answer", "the", "a", "an", "this", "it", "its", "be", "connect",
|
||||
KEYWORDS = {"host", "hostname", "to", "port"} | PLAIN_WORDS
|
||||
|
||||
|
||||
def hide_operands(text):
|
||||
def hide_operands(text, *, _sub=re.sub):
|
||||
def one(m):
|
||||
word = m.group(2).rstrip(".")
|
||||
dots = m.group(2)[len(word):]
|
||||
return m.group(0) if word.lower() in PLAIN_WORDS else f"{m.group(1)} <host>{dots}"
|
||||
return OPERAND.sub(one, text)
|
||||
return _sub(OPERAND, one, text)
|
||||
|
||||
|
||||
class _ScrubTrace:
|
||||
"""Map every output character to its source interval through sequential redactions.
|
||||
|
||||
Unchanged prefixes/suffixes retain their origins. The changed middle inherits
|
||||
its source interval and is marked as replaced; extra spans can widen to cover
|
||||
that whole interval without restoring anything the old pipeline removed.
|
||||
"""
|
||||
def __init__(self, text):
|
||||
self.ranges = [(i, i + 1, False) for i in range(len(text))]
|
||||
self.redactions = []
|
||||
|
||||
def sub(self, pattern, replacement, text, flags=0):
|
||||
pattern = re.compile(pattern, flags)
|
||||
pieces, ranges, end = [], [], 0
|
||||
for match in pattern.finditer(text):
|
||||
start, stop = match.span()
|
||||
value = replacement(match) if callable(replacement) else match.expand(replacement)
|
||||
pieces.extend((text[end:start], value))
|
||||
ranges.extend(self.ranges[end:start])
|
||||
if value == match.group(0):
|
||||
ranges.extend(self.ranges[start:stop])
|
||||
else:
|
||||
original = match.group(0)
|
||||
prefix = 0
|
||||
while prefix < min(len(original), len(value)) and original[prefix] == value[prefix]:
|
||||
prefix += 1
|
||||
suffix = 0
|
||||
while (suffix < min(len(original), len(value)) - prefix
|
||||
and original[-suffix - 1] == value[-suffix - 1]):
|
||||
suffix += 1
|
||||
ranges.extend(self.ranges[start:start + prefix])
|
||||
source = self.ranges[start + prefix:stop - suffix]
|
||||
# A known name can itself be a prefix of "<host>". Attribute
|
||||
# inserted characters to its last character in that case.
|
||||
if not source:
|
||||
source = self.ranges[max(start, start + prefix - 1):start + prefix]
|
||||
interval = (source[0][0], source[-1][1])
|
||||
self.redactions.append(interval)
|
||||
ranges.extend([(*interval, True)] * (len(value) - prefix - suffix))
|
||||
if suffix:
|
||||
ranges.extend(self.ranges[stop - suffix:stop])
|
||||
end = stop
|
||||
pieces.append(text[end:])
|
||||
ranges.extend(self.ranges[end:])
|
||||
self.ranges = ranges
|
||||
return "".join(pieces)
|
||||
|
||||
|
||||
def _failure_spans(text, hosts):
|
||||
"""Extra redactions discovered before any replacement can destroy context."""
|
||||
spans = []
|
||||
tokens = [m.span() for m in PLACEHOLDER.finditer(text)]
|
||||
token_starts = [start for start, end in tokens]
|
||||
for name in _host_names(hosts):
|
||||
for match in re.finditer(_host_pattern(name), text, re.I):
|
||||
# Standalone scrubber tokens survive; a whole display name may cross one.
|
||||
token = bisect_right(token_starts, match.start()) - 1
|
||||
if token < 0 or match.end() > tokens[token][1]:
|
||||
spans.append(match.span())
|
||||
start = 0
|
||||
while True:
|
||||
match = OPERAND.search(text, start)
|
||||
if not match:
|
||||
break
|
||||
word = match.group(2).rstrip(".")
|
||||
if word.lower() not in PLAIN_WORDS:
|
||||
spans.append((match.start(2), match.start(2) + len(word)))
|
||||
# An operand can itself be another marker ("to hostname bastion").
|
||||
start = match.start(2)
|
||||
merged = []
|
||||
for start, end in sorted(spans):
|
||||
if merged and start <= merged[-1][1]:
|
||||
merged[-1] = (merged[-1][0], max(end, merged[-1][1]))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def scrub_failure(text, hosts=()):
|
||||
"""Free text about a failed attempt, for the log: the attempt's own names, whole and
|
||||
longest first (case-insensitively, never ssh's own words), then anything ssh names as a
|
||||
host, then the shared scrubber (addresses, paths, user names)."""
|
||||
return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600)
|
||||
"""Keep the existing scrubbed log, then hide additional original name/ssh spans.
|
||||
|
||||
Source ranges carry those spans through the existing substitutions. Applying
|
||||
them only to that pipeline's output cannot reveal text it previously removed.
|
||||
Keep the old output boundary, then truncate again after extra redaction so
|
||||
shortening a name cannot pull previously omitted text into the log.
|
||||
"""
|
||||
text = str(text or "")
|
||||
hosts = tuple(hosts)
|
||||
spans = _failure_spans(text, hosts)
|
||||
# Also preserve rules whose context the old name/operand pass could destroy,
|
||||
# such as a URL, home folder, or PEM footer containing a display name.
|
||||
original = _ScrubTrace(text)
|
||||
frame_telemetry.scrub(text, limit=None, _sub=original.sub)
|
||||
privacy_spans = sorted(original.redactions)
|
||||
trace = _ScrubTrace(text)
|
||||
clean = frame_telemetry.scrub(
|
||||
hide_operands(hide_hosts(text, hosts, _trace=trace), _sub=trace.sub),
|
||||
limit=600, _sub=trace.sub)
|
||||
out, hidden, index, privacy_index = [], False, 0, 0
|
||||
for char, (start, end, replaced) in zip(clean, trace.ranges):
|
||||
while index < len(spans) and spans[index][1] <= start:
|
||||
index += 1
|
||||
redact = index < len(spans) and spans[index][0] < end
|
||||
while privacy_index < len(privacy_spans) and privacy_spans[privacy_index][1] <= start:
|
||||
privacy_index += 1
|
||||
if not replaced and privacy_index < len(privacy_spans):
|
||||
redact |= privacy_spans[privacy_index][0] < end
|
||||
if redact:
|
||||
if not hidden:
|
||||
out.append("<host>")
|
||||
else:
|
||||
out.append(char)
|
||||
hidden = redact
|
||||
return "".join(out)[:600]
|
||||
|
||||
|
||||
def failure_detail(error):
|
||||
|
||||
@@ -460,6 +460,7 @@ class MacView:
|
||||
windows = self.call("/windows").get("windows", []) if status.get("screen") else []
|
||||
displays = self.call("/displays").get("displays", [])
|
||||
return {"available": True, "host": self.host, "selecting": status.get("selecting", False),
|
||||
**({"windowCaptureReason": status["windowCaptureReason"]} if status.get("windowCaptureReason") else {}),
|
||||
"selectionError": status.get("selectionError", ""), "screen": status.get("screen", False),
|
||||
"accessibility": status.get("accessibility", False), "streams": status.get("streams", []),
|
||||
"windows": windows, "displays": displays, "tunnel": self.tunnel_up(),
|
||||
|
||||
@@ -29,6 +29,7 @@ from frame_stream_stats import Stats
|
||||
|
||||
# Viewer fields are untrusted JSON: float('x'), int(None), m['t'] missing.
|
||||
MALFORMED = (ValueError, TypeError, KeyError, OverflowError)
|
||||
WINDOW_CAPTURE_UNAVAILABLE = 'Individual windows are unavailable in this build. Choose a whole screen instead.'
|
||||
|
||||
|
||||
class Grants:
|
||||
@@ -386,6 +387,9 @@ class Agent:
|
||||
self.sessions, self.sources = {}, {}
|
||||
self.next_id, self.selecting, self.selection_error = 1, False, ''
|
||||
self.windows = Windows() if sys.platform == 'win32' else None
|
||||
self.wgc = bool(self.windows) and all(native.has_property('d3d11screencapturesrc', p)
|
||||
for p in ('capture-api', 'window-handle', 'show-border'))
|
||||
self.window_capture_reason = WINDOW_CAPTURE_UNAVAILABLE if self.windows and not self.wgc else ''
|
||||
self.encoder = 'mfh264enc' if self.windows else 'vah264enc' if native.has('vah264enc') else 'x264enc'
|
||||
self.shutting_down = False
|
||||
self.selection_generation = 0
|
||||
@@ -393,6 +397,10 @@ class Agent:
|
||||
def lists(self):
|
||||
if self.windows:
|
||||
windows, displays = self.windows.sources()
|
||||
if not self.wgc:
|
||||
windows = []
|
||||
for source in windows + displays:
|
||||
source['capture_api'] = 'wgc' if self.wgc else 'dxgi'
|
||||
self.sources = {s['src']: s for s in windows + displays}
|
||||
return windows, displays
|
||||
return [{k: v for k, v in s.items() if k not in ('portal', 'fd', 'node')} for s in self.sources.values()], []
|
||||
@@ -400,6 +408,8 @@ class Agent:
|
||||
def source(self, src):
|
||||
if src == 'test':
|
||||
return dict(src='test', title='Test pattern', w=1280, h=720)
|
||||
if self.window_capture_reason and isinstance(src, str) and src.startswith('window:'):
|
||||
raise ValueError(self.window_capture_reason)
|
||||
self.lists()
|
||||
if src not in self.sources:
|
||||
raise ValueError('Choose a window or screen on this computer first')
|
||||
@@ -498,6 +508,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
windows, displays = agent.lists()
|
||||
if method == 'GET' and url.path == '/status':
|
||||
data = dict(version=1, host='windows' if agent.windows else 'linux', screen=True,
|
||||
windowCaptureReason=agent.window_capture_reason,
|
||||
accessibility=True, selecting=agent.selecting, selectionError=agent.selection_error,
|
||||
encoder=agent.encoder, finished=[], streams=[dict(id=i, src=s.src,
|
||||
title=s.source.get('title', ''), stats=s.stats.summary(), controller=s.controller.state())
|
||||
|
||||
+11
-1
@@ -38,6 +38,7 @@ class Native:
|
||||
signatures = {
|
||||
'fc_now': (C.c_int64, []), 'fc_gst_init': (None, []),
|
||||
'fc_has_element': (C.c_int, [C.c_char_p]),
|
||||
'fc_has_property': (C.c_int, [C.c_char_p, C.c_char_p]),
|
||||
'fc_new': (C.c_void_p, [C.c_int, C.c_int]), 'fc_free': (None, [C.c_void_p]),
|
||||
'fc_ceiling': (None, [C.c_void_p, C.c_int]),
|
||||
'fc_gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
|
||||
@@ -69,6 +70,10 @@ class Native:
|
||||
def has(self, name):
|
||||
return bool(self.lib.fc_has_element(name.encode()))
|
||||
|
||||
def has_property(self, element, name):
|
||||
"""Check optional element properties in the initialized capture DLL."""
|
||||
return bool(self.lib.fc_has_property(element.encode(), name.encode()))
|
||||
|
||||
def now(self):
|
||||
return self.lib.fc_now()
|
||||
|
||||
@@ -133,8 +138,13 @@ def pipeline(source, platform, encoder, w, h, fps, bitrate, codec='h264'):
|
||||
kind, ident = source['src'].split(':')
|
||||
if kind not in ('window', 'display') or not re.fullmatch(r'[0-9]+', ident):
|
||||
raise ValueError('Invalid Windows capture source')
|
||||
api = source.get('capture_api', 'wgc')
|
||||
if api not in ('wgc', 'dxgi') or (api == 'dxgi' and kind != 'display'):
|
||||
raise ValueError('This build can only capture whole screens without Windows Graphics Capture')
|
||||
prop = 'window-handle' if kind == 'window' else 'monitor-handle'
|
||||
capture = 'd3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true %s=%d' % (prop, int(ident))
|
||||
# DXGI-only builds don't expose capture-api or show-border at all.
|
||||
mode = ' capture-api=wgc show-border=true' if api == 'wgc' else ''
|
||||
capture = 'd3d11screencapturesrc%s show-cursor=true %s=%d' % (mode, prop, int(ident))
|
||||
else:
|
||||
capture = 'pipewiresrc fd=%d path=%d do-timestamp=true' % (source['fd'], source['node'])
|
||||
# The source gate runs before conversion/encoding. There is no leaky queue
|
||||
|
||||
+11
-9
@@ -240,27 +240,29 @@ def _scrub_url(u):
|
||||
return f'{parts.scheme}://{host}/…' if host else '<url>'
|
||||
|
||||
|
||||
def scrub(text, limit=2000):
|
||||
def scrub(text, limit=2000, *, _sub=re.sub):
|
||||
"""Text with URLs, home folders, user names, addresses, hosts, ids and keys replaced."""
|
||||
# The internal substitution hook lets the connection log retain source ranges through
|
||||
# this exact pipeline. Ordinary callers keep the same substitutions and output limit.
|
||||
if text is None:
|
||||
return None
|
||||
t = URL_RE.sub(lambda m: _scrub_url(m.group(0)), str(text)) # first, before anything splits a URL
|
||||
t = _sub(URL_RE, lambda m: _scrub_url(m.group(0)), str(text)) # first, before anything splits a URL
|
||||
home = str(Path.home())
|
||||
if len(home) > 3:
|
||||
t = t.replace(home, '~')
|
||||
t = _sub(re.escape(home), '~', t)
|
||||
# A home folder's whole name ("C:\Users\Jane Doe", "/Users/O'Brien"), up to the next separator.
|
||||
t = re.sub(r'''(?i)(/Users/|/home/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\n"]+''', r'\1<user>', t)
|
||||
t = _sub(r'''(?i)(/Users/|/home/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\n"]+''', r'\1<user>', t)
|
||||
# ssh's "user@host: ..." and "user@host's password:", the whole field: the user even with
|
||||
# spaces or a DOMAIN\ prefix, the host even a full domain name. Before the email rule, which
|
||||
# would otherwise take only the last word of the user.
|
||||
t = re.sub(r'''(?m)(?:^|(?<=: )|(?<=\| ))[^@\n:|"<]{1,64}@[\w.\[\]%:<>-]+?(?=:(?:\s|$)|'s\s)''',
|
||||
t = _sub(r'''(?m)(?:^|(?<=: )|(?<=\| ))[^@\n:|"<]{1,64}@[\w.\[\]%:<>-]+?(?=:(?:\s|$)|'s\s)''',
|
||||
'<user>@<host>', t)
|
||||
for pattern, repl in SCRUBS:
|
||||
t = pattern.sub(repl, t)
|
||||
t = re.sub(r'(?<![\w.+\\<-])[\w.+\\-]+@(?=[A-Za-z\[<])', '<user>@', t) # any other word@host
|
||||
t = IPV6_RE.sub(_ipv6, t)
|
||||
t = _sub(pattern, repl, t)
|
||||
t = _sub(r'(?<![\w.+\\<-])[\w.+\\-]+@(?=[A-Za-z\[<])', '<user>@', t) # any other word@host
|
||||
t = _sub(IPV6_RE, _ipv6, t)
|
||||
for name in _user_names():
|
||||
t = re.sub(r'\b%s\b' % re.escape(name), '<user>', t)
|
||||
t = _sub(r'\b%s\b' % re.escape(name), '<user>', t)
|
||||
return t[:limit]
|
||||
|
||||
|
||||
|
||||
+21
-11
@@ -175,7 +175,8 @@
|
||||
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||||
padding: 8px 8px; font: inherit; font-size: 13px; }
|
||||
.addr .t { overflow-wrap: anywhere; }
|
||||
.addr .s, .dev-problem .s { white-space: normal; }
|
||||
.addr .s { white-space: normal; }
|
||||
.item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }
|
||||
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
|
||||
.addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; }
|
||||
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } }
|
||||
@@ -627,6 +628,8 @@
|
||||
.runs { font-size:11px; font-weight:500; padding:3px 8px; border-radius:10px; white-space:nowrap; }
|
||||
.runs.works { color:#c6efb0; background:#70bb5426; } .runs.ready { color:#b7d8ec; background:#6ec9ff1a; }
|
||||
.runs.unknown { color:#a8b5c2; background:#ffffff0d; } .runs.blocked { color:#f1c28a; background:#d9a23a22; }
|
||||
.runs.warning, .runs.issues { color:#f1c28a; background:#d9a23a22; white-space:normal; }
|
||||
.store-compat-badges { display:flex; flex-wrap:wrap; gap:4px; }
|
||||
.store-hero > .store-art { position:absolute; inset:0; }
|
||||
.store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; }
|
||||
.hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; }
|
||||
@@ -773,6 +776,7 @@
|
||||
.detail-buy .store-primary { width:100%; min-height:46px; border-radius:7px; margin:8px 0; font-size:15px; }
|
||||
.detail-verdict { color:#b6d6a8; font-size:12px; line-height:1.55; margin:0 0 15px; }
|
||||
.detail-verdict.blocked { color:#e3b77a; }
|
||||
.detail-verdict.warning, .detail-verdict.issues { color:#e3b77a; }
|
||||
.detail-verdict.unknown { color:#adbac7; }
|
||||
.detail-price { font-size:23px; color:white; font-weight:600; }
|
||||
.detail-small { font-size:11px; color:var(--muted); margin:4px 0 18px; }
|
||||
@@ -1426,7 +1430,7 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's windows and screens</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="vrTools">
|
||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||
@@ -1723,9 +1727,9 @@
|
||||
<div class="shelf-head"><h2 id="mvTitle">Mac in the headset</h2><span class="tag" id="mvTag" hidden>Experimental</span><span class="count" id="mvCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="mvRefresh">Refresh</button></div>
|
||||
<div id="mvPc" hidden>
|
||||
<p class="hint" style="color:var(--warn)">Experimental. Shows this PC's windows and screens as panels in the
|
||||
headset, with laser and keyboard input. It builds and passes its tests on Windows in CI, but hasn't been tried on a real
|
||||
Windows desktop or with a headset yet: capture, hardware encoding and laser clicks may not work. Clicks and keys
|
||||
<p class="hint" style="color:var(--warn)">Experimental. Shows a whole PC screen as a panel in the
|
||||
headset, with laser and keyboard input. Individual windows are offered only when this build supports them.
|
||||
Tested in a Windows VM; physical PCs, hardware encoding and laser clicks remain unverified. Clicks and keys
|
||||
can't reach administrator apps or the sign-in screen.
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/docs/pc-in-headset.md" target="_blank" rel="noopener noreferrer">What's verified</a></p>
|
||||
<label class="mv-opt"><input type="checkbox" id="mvPcOn"> Turn on PC in the headset
|
||||
@@ -4240,10 +4244,16 @@ async function loadSources() {
|
||||
} catch (e) { $("sourceError").textContent = e.message; }
|
||||
}
|
||||
// Keep installation confidence explicit, including apps that have not been checked yet.
|
||||
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"] };
|
||||
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"], warning: ["Reported not working", "warning"], issues: ["Reported issues", "issues"] };
|
||||
function storeRuns(o) {
|
||||
const [text, cls] = RUNS[o.verdict?.tone] || RUNS.unknown;
|
||||
return `<span class="runs ${cls}" title="${esc(o.verdict?.label || "Not yet checked on the Frame")}">${text}</span>`;
|
||||
const report=o.compat_report, extra=o.verdict?.tone==='blocked' && ['no','maybe'].includes(report?.verdict);
|
||||
return `<span class="store-compat-badges"><span class="runs ${cls}" title="${esc(o.verdict?.label || "Not yet checked on the Frame")}">${text}</span>${extra ? `<span class="runs warning">${report.verdict==='no' ? 'Reported not working' : 'Reported issues'}</span>` : ''}</span>`;
|
||||
}
|
||||
function storePickEligible(o) { return o.verdict?.tone !== 'blocked' && o.compat_report?.verdict !== 'no'; }
|
||||
function storeReportDetails(o) {
|
||||
const report=o.compat_report;
|
||||
return report ? `<div class="detail-verdict ${report.verdict==='works' ? 'works' : 'warning'}"><strong>Saved compatibility reports</strong>${report.lines.map(line=>`<p>${esc(line)}</p>`).join('')}<p>Reports can cover other versions or runtimes. You can still try installing unless this build has a compatibility blocker.</p></div>` : '';
|
||||
}
|
||||
const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app";
|
||||
function storeCard(app) {
|
||||
@@ -4271,7 +4281,7 @@ function renderStore() {
|
||||
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
|
||||
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
|
||||
// Home: one featured VR app, then a shelf per category; each app appears once, blocked ones not at all.
|
||||
const ok=apps.filter(a=>a.offers[0].verdict?.tone !== 'blocked'), shown=new Set();
|
||||
const ok=apps.filter(a=>storePickEligible(a.offers[0])), shown=new Set();
|
||||
const day=Math.floor(Date.now()/864e5), heroes=ok.filter(a=>a.offers[0].vr && a.offers[0].artwork?.banner);
|
||||
const featured=heroes[day % (heroes.length || 1)] || ok.find(a=>a.offers[0].artwork?.banner) || ok[0];
|
||||
if (!featured) { $("searchGrid").innerHTML=storeEmpty(); return; } // nothing here runs on the Frame
|
||||
@@ -4327,7 +4337,7 @@ function renderAppDetail() {
|
||||
function renderAppDetailBody() {
|
||||
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
|
||||
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
|
||||
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
|
||||
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p>${storeReportDetails(o)}<button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
|
||||
updateInstall();
|
||||
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
|
||||
}
|
||||
@@ -5674,7 +5684,7 @@ async function loadMacView(restart) {
|
||||
$("mvWinCount").textContent = wins.length ? `${wins.length}` : "";
|
||||
$("mvWindows").innerHTML = !s.screen ? `<div class="sub">Windows appear here once Screen Recording is allowed.</div>`
|
||||
: wins.length ? wins.map(w => mvRow(w.src, w.title || w.app, `${w.title ? w.app + " · " : ""}${w.w}×${w.h}`, w.w, w.h, liveAs(w.src), byStream(liveAs(w.src)))).join("")
|
||||
: `<div class="sub">No windows open on this ${pc ? "PC" : "Mac's current desktop"}.</div>`;
|
||||
: `<div class="sub">${pc && s.windowCaptureReason ? esc(s.windowCaptureReason) : `No windows open on this ${pc ? "PC" : "Mac's current desktop"}.`}</div>`;
|
||||
// Keep the numbers fresh while something is in the headset and the card is on screen.
|
||||
clearTimeout(mvTimer);
|
||||
const tick = () => { mvTimer = document.hidden || !box.offsetParent ? setTimeout(tick, 3000) : (loadMacView(), 0); };
|
||||
@@ -6618,7 +6628,7 @@ function renderPill() {
|
||||
}
|
||||
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(word)}` +
|
||||
`${via ? `<span class="via"> · ${esc(via)}</span>` : ""}</span></span>`;
|
||||
$("conn").title = `${dev}: ${detail}. Click to see or change its addresses.`;
|
||||
$("conn").title = `${dev}: ${detail.replace(/[.\s]+$/, "")}. Click to see or change its addresses.`;
|
||||
return true;
|
||||
}
|
||||
window.connPill = renderPill;
|
||||
|
||||
@@ -3593,6 +3593,9 @@ def main():
|
||||
args = ap.parse_args()
|
||||
httpd = LoopbackServer(("127.0.0.1", args.port), Handler)
|
||||
sweep_tmp()
|
||||
if os.environ.get('FRAME_COMPAT_SEARCH_REPORTS') != '0':
|
||||
apk_search.compat_reports = apk_search.ReportCache(
|
||||
frame_compat_db.load, frame_catalog.reports.verdict, ttl=frame_compat_db.TTL).read
|
||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||
frame_telemetry.start()
|
||||
frame_contact.start()
|
||||
|
||||
Reference in new issue
Block a user