mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 06:01:39 +02:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e1541e36e | ||
|
|
bcdd9d0830 | ||
|
|
ddd2d1c295 | ||
|
|
8f2e34a40e | ||
|
|
351230fcad | ||
|
|
5b35e9cc5d | ||
|
|
671c4c06e7 | ||
|
|
443813a088 | ||
|
|
ba0ac6022b | ||
|
|
8b9bd2119c | ||
|
|
a8888cb2fc | ||
|
|
1f51cc89d2 | ||
|
|
bcdb4c3b78 | ||
|
|
e9b02d61e7 | ||
|
|
02419ffbd4 | ||
|
|
5db9631571 |
No files matched your search
@@ -118,8 +118,9 @@ a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/ipho
|
||||
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
|
||||
From 0.4 it updates itself: when a new version is published, a banner offers
|
||||
**Update and restart**. It sends anonymous usage statistics, which you can turn
|
||||
off. Sharing compatibility results and error details is opt-in. See
|
||||
[docs/privacy.md](docs/privacy.md).
|
||||
off. Sharing compatibility results and error details is opt-in, and so is the
|
||||
mailing list it offers once on first launch (just an email address, **No
|
||||
thanks** to skip). See [docs/privacy.md](docs/privacy.md).
|
||||
Google doesn't publish `adb` for arm64 Linux, so that build uses your
|
||||
distribution's. If you already have `adb`, the app uses yours.
|
||||
|
||||
|
||||
+6
-2
@@ -116,10 +116,11 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
|
||||
// which sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
@@ -601,6 +602,9 @@ function buildMenu() {
|
||||
if (win && url && linkPage === win.webContents) win.webContents.send("report:open");
|
||||
else shell.openExternal("https://frame-control.pages.dev/feedback/"); // the page isn't up
|
||||
} },
|
||||
{ label: "Join the Mailing List…", click: () => {
|
||||
if (win && url && linkPage === win.webContents) win.webContents.send("mailing:open");
|
||||
} },
|
||||
{ label: "Release Notes", click: () => shell.openExternal(updater.RELEASES) },
|
||||
{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") },
|
||||
],
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.7",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.7",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -51,6 +51,7 @@
|
||||
"*.py",
|
||||
"*.html",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -40,6 +40,11 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
ipcRenderer.removeAllListeners("report:open");
|
||||
ipcRenderer.on("report:open", () => cb());
|
||||
},
|
||||
// Help → Join the Mailing List… opens the page's mailing list setting (ui/frame_contact.py).
|
||||
onJoinMailingList: (cb) => {
|
||||
ipcRenderer.removeAllListeners("mailing:open");
|
||||
ipcRenderer.on("mailing:open", () => cb());
|
||||
},
|
||||
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
|
||||
// answer is the server's (connected, display name), never the token.
|
||||
meta: {
|
||||
|
||||
+59
-3
@@ -17,11 +17,12 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
The window has six tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
Flatpaks, remote and power), **Devices** (your headsets and their addresses),
|
||||
and **Control** (the searchable Control Center, metrics, supported settings and
|
||||
headset guidance). Keys 1–6 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
@@ -96,6 +97,61 @@ counts them while they run.
|
||||
shut down open a terminal window because SteamOS asks for the sudo password
|
||||
over SSH.
|
||||
|
||||
## Control Center
|
||||
|
||||
Open **Control** or `#control` to search controls, status and help. Categories
|
||||
cover device/audio, VR/comfort, apps/files, connection/remote and Frame Control
|
||||
preferences. Search is local: it matches labels, descriptions and familiar terms
|
||||
such as “DPI”, “microphone”, “recenter” and “keep awake”. Queries are not sent to
|
||||
Steam, app catalogues, analytics or models. Choosing a workflow link opens its
|
||||
existing screen; it does not install, cast, open a helper or change a setting.
|
||||
Home, Games, Android, Tools and Devices retain their existing features.
|
||||
|
||||
The overview and detail cards use the shared `/api/status` sample: charge and
|
||||
power, user/system storage, memory, Wi-Fi, IP, OS/build, uptime, detected services
|
||||
and VR timings. Values refresh with the normal 30-second poll. A failed status
|
||||
request, lost connection, headset switch or sample received more than 90 seconds
|
||||
ago clears current readings and disables audio writes. The client receipt time
|
||||
and failed-read state are retained through page startup and UI redraws. Only a
|
||||
new successful status read clears a failure; reusing the retained sample does not.
|
||||
The headset timestamp is shown without assuming
|
||||
its clock matches this computer. Missing sensors show **Unavailable**;
|
||||
they are not converted to zero. Compositor FPS is separate from application FPS;
|
||||
GPU time is render time, not GPU utilisation. The temperature is the hottest
|
||||
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
|
||||
not a guessed universal temperature threshold.
|
||||
|
||||
**Audio** controls the default output sink through the existing `/api/volume`
|
||||
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
||||
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
||||
blocks duplicate/pending actions, and requests status after command acknowledgment
|
||||
for readback, draining an earlier poll first. Mute preserves an unapplied volume
|
||||
draft; switching headsets discards it. It reports
|
||||
unconfirmed or differing output separately from confirmed state. API validation
|
||||
rejects invalid types, out-of-range levels and invalid mute states before SSH.
|
||||
|
||||
Other settings link to the existing forms and their validation, progress,
|
||||
confirmations and reset behavior: per-instance Android resolution/density/text,
|
||||
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
|
||||
sources and artwork. Android display links reveal **On your Frame** before
|
||||
scrolling to the form. Mac streaming remains searchable on all platforms, with
|
||||
its action shown only when the server is on macOS.
|
||||
|
||||
Hardware guidance is deliberately separate from controls. Microphone/routing,
|
||||
brightness, refresh selection, optics, controllers, Bluetooth, passthrough,
|
||||
tracking-origin changes, saved spatial layouts, firmware updates and recovery
|
||||
have no verified remote setting here. Use the headset's own controls or the
|
||||
documented manual procedures. Movement comfort remains per-game; automatic
|
||||
sleep is in Steam Settings → Power → Sleep after inactivity. Keep-awake and
|
||||
Android data backup/restore remain script/MCP capabilities, not new UI switches.
|
||||
|
||||
**Verification:** the Control Center's JavaScript runs under Node with mocked
|
||||
API and DOM boundaries; the server tests mock SSH and reject invalid values
|
||||
before any device command. Packaging tests cover the shared CSS/JS in desktop
|
||||
and iPhone bundles. These checks establish wiring, search, navigation, offline,
|
||||
error and device-switch behavior; they do not establish wearer experience,
|
||||
physical settings persistence or a new hardware capability.
|
||||
|
||||
## How it works
|
||||
|
||||
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 483 KiB |
@@ -124,7 +124,10 @@ the redaction look for it.
|
||||
it has game data to add: once to add it, once for the test), watches it for
|
||||
about 30 seconds, and records what happened. **Test again** repeats only
|
||||
the test.
|
||||
5. **Disconnect** deletes the token from your computer and, in the desktop
|
||||
5. Choose **Install** on as many games as you like: they wait in the
|
||||
**Install queue** and install one after another, with Quest imports and
|
||||
Android store installs. **Remove** takes one out before it starts.
|
||||
6. **Disconnect** deletes the token from your computer and, in the desktop
|
||||
app, signs out of Meta in Frame Control (clears the sign-in window's
|
||||
storage). Games already on the Frame stay.
|
||||
|
||||
@@ -145,7 +148,8 @@ The local API: `GET /api/meta` (connected or not, never the token),
|
||||
`{"action": "connect", "token": "...", "acknowledged": true}`,
|
||||
`{"action": "disconnect"}`, `{"action": "cancel-signin", "attempt": "..."}`, `{"action": "install", "app": "APP_ID", "test": false,
|
||||
"allowDowngrade": false}` or `{"action": "test", "package": "..."}`. Install and
|
||||
test return a background job (`/api/job`). Sign in with Meta uses the same
|
||||
test return a background job (`/api/job`); an install waits its turn in the
|
||||
install queue (`/api/queue`, see [Import from my Quest](quest-import.md#using-it)). Sign in with Meta uses the same
|
||||
`connect` action: the desktop app's main process (`app/meta-signin.js`) posts
|
||||
it with the cookie's value and an `"attempt"` id (8 to 64 letters, digits or
|
||||
`-`) once the page's acknowledgement box is ticked. A connect whose attempt
|
||||
|
||||
+26
-11
@@ -33,6 +33,13 @@ computer, exactly as they were sent.
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
- `app_installed`, `app_updated` and `app_opened` carry `country`: a
|
||||
two-letter code such as `DE`, worked out on your computer from its time zone
|
||||
setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
|
||||
Control is used without knowing your address. The time zone itself isn't
|
||||
sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
|
||||
Copies that showed the first-run notice before it mentioned this (before
|
||||
0.4.6) show it again, and don't send a country until it has been seen.
|
||||
- `frame_connected` carries `headset_id`: the first 16 hex digits of a
|
||||
SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
|
||||
so the machine-id itself never leaves it. It is the same for one headset on
|
||||
@@ -44,9 +51,9 @@ computer, exactly as they were sent.
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `app_installed` | First run | `country` |
|
||||
| `app_updated` | First run of a new version | `from_version`, `country` |
|
||||
| `app_opened` | At most once a day | `country` |
|
||||
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
|
||||
@@ -211,21 +218,27 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
|
||||
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||
API key as `frame_compat_db.py sync`.
|
||||
|
||||
## Contact email (optional)
|
||||
## Mailing list and contact email (optional)
|
||||
|
||||
Frame Control never needs an email address. If you'd like to leave one, there
|
||||
are two separate choices, both off until you tick them:
|
||||
|
||||
| Choice | What it's for |
|
||||
|---|---|
|
||||
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
|
||||
| **Join the mailing list: new features and updates** | Occasional emails about new features and updates |
|
||||
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
|
||||
|
||||
You're asked once, in a bar at the top of the page, after the Frame has
|
||||
connected for the first time, and never in the same visit as the first-run
|
||||
privacy notice. **No thanks** hides it for good, and it isn't
|
||||
shown again even if you ignore it. **Contact email** in **Privacy & updates**
|
||||
is where you add, change or remove the address and either choice at any time.
|
||||
The first time Frame Control opens, a bar at the top of the page asks
|
||||
"Want to hear about new features? Join the Frame Control mailing list." with
|
||||
an empty email field, **Join** and **No thanks**. It only asks about the
|
||||
mailing list; follow-up questions are a separate choice in **Privacy &
|
||||
updates**. If the first-run privacy notice is showing, it waits until that's
|
||||
closed, so the two never appear together. It doesn't need the Frame to be set
|
||||
up. **No thanks** hides it for good, and it isn't shown again even if you
|
||||
ignore it. **Mailing list and contact email** in **Privacy & updates** (also
|
||||
**Help → Join the Mailing List…** in the desktop app) is where you join later,
|
||||
or change or remove the address and either choice, at any time. Unticking the
|
||||
mailing list is unsubscribing.
|
||||
|
||||
**What's sent, and where.** The address and the two choices go privately to
|
||||
Frame Control's PostHog project, the same place as problem reports, as a
|
||||
@@ -260,7 +273,9 @@ sooner, ask the maintainer (for example in a problem report).
|
||||
Nothing sends email yet: this only records who agreed to what. The
|
||||
maintainer lists the addresses with
|
||||
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
|
||||
personal API key as `inbox`.
|
||||
personal API key as `inbox`; `contacts updates` is the mailing list. If those
|
||||
addresses are ever moved to an email service to send the newsletter, this
|
||||
page will name it first.
|
||||
|
||||
## Turning it all off
|
||||
|
||||
|
||||
+12
-1
@@ -45,6 +45,13 @@ files in Android/data** to bring the game's external files too (some games
|
||||
keep saves there). Progress shows in the panel; the result and any reason it
|
||||
stopped appear in the message log.
|
||||
|
||||
You can choose **Import** on several games at once: they wait in the **Install
|
||||
queue** (above the Quest panel) and import one after another, together with
|
||||
installs from the Android store and My Meta library. **Remove** takes a game
|
||||
out of the queue before it starts. Each queued import runs on the headset that
|
||||
was selected when you queued it; if you switch headsets first, it stops with a
|
||||
message instead.
|
||||
|
||||
A game with game data (an OBB folder, or Android/data when ticked) takes two
|
||||
imports, because Android can only take that data while the game's Lepton
|
||||
instance is running, and there is no way to start the instance without the
|
||||
@@ -69,7 +76,11 @@ python3 ui/frame_quest.py import com.example.game --data --serial 1WMHH000XXXXXX
|
||||
The API is `GET /api/quest` (the Quest, or guidance when there isn't one, and
|
||||
its apps) and `POST /api/quest` with
|
||||
`{"action": "import", "package": "...", "serial": "...", "data": false}`,
|
||||
which returns a background job (`/api/job`) with a stage and percentage.
|
||||
which returns a background job (`/api/job`) with a stage and percentage. The
|
||||
job waits in the install queue first (`"queued": true`, stage `Queued: N
|
||||
installs before it`). `GET /api/queue` lists the running install and those
|
||||
waiting; `POST /api/queue` with `{"action": "remove", "job": "..."}` removes a
|
||||
waiting one (409 once it has started).
|
||||
|
||||
## What an import does
|
||||
|
||||
|
||||
@@ -15,6 +15,29 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
The Control Center is covered by `tests/test_control_center.py` and its Node
|
||||
behavior suite `tests/control_center.cjs` (also run by unittest discovery).
|
||||
They exercise production search, metrics, navigation and action handlers with
|
||||
mocked API/DOM boundaries: volume Apply/mute/readback, offline and failed reads,
|
||||
stale samples, selected-headset capture, duplicate actions and validation before
|
||||
SSH. Integration regressions run the production shared refresh, Home renderer,
|
||||
host callback and Control Center bootstrap: a pre-command status request must be
|
||||
drained before audio readback starts, and delayed startup retains sample receipt
|
||||
age, failed-read state and early host capabilities. The actual Flatpak install
|
||||
redraw cannot revive a failed sample; a new successful poll restores metrics and
|
||||
audio after either redraw or delayed mount. Draft regressions cover a pending headset
|
||||
switch and mute followed by Apply. Static asset HTTP tests and desktop/iPhone bundle checks catch missing CSS
|
||||
or JavaScript. A focused run is:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_control_center.py -v
|
||||
node --test tests/control_center.cjs
|
||||
node --check ui/control-center.js
|
||||
```
|
||||
|
||||
Responsive layout, actual browser rendering and keyboard/touch behavior still
|
||||
need browser verification. No Control Center test connects to a real headset.
|
||||
|
||||
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
|
||||
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
|
||||
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
|
||||
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate ui/frame_timezones.py, the time zone -> country table usage statistics use.
|
||||
|
||||
From the IANA tz database's zone.tab (each zone's country) and tzdata.zi (the old
|
||||
names that link to them), as installed on macOS and most Linux systems. It prints the
|
||||
old names left out because their zone is shared by several countries: check any new
|
||||
one, and add it to OLD_SPELLINGS if it's just an old spelling of a zone.tab zone.
|
||||
|
||||
Run: python3 scripts/timezones.py [/usr/share/zoneinfo]
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
src = Path(sys.argv[1] if len(sys.argv) > 1 else '/usr/share/zoneinfo')
|
||||
country = {}
|
||||
for line in (src / 'zone.tab').read_text().splitlines():
|
||||
if line and not line.startswith('#'):
|
||||
cc, _, zone = line.split('\t')[:3]
|
||||
country[zone] = cc
|
||||
links = {}
|
||||
for line in (src / 'tzdata.zi').read_text().splitlines():
|
||||
if line.startswith('L '):
|
||||
_, target, alias = line.split()
|
||||
links[alias] = target
|
||||
def resolve(z):
|
||||
for _ in range(5):
|
||||
z = links.get(z, z)
|
||||
return z
|
||||
|
||||
|
||||
# An old name links to a zone with the same clocks, which can be in another country
|
||||
# (Africa/Asmera, Eritrea, links to Africa/Nairobi), so it only gets a country when every
|
||||
# zone.tab zone sharing that target is in one country.
|
||||
countries_of = {}
|
||||
for zone, cc in country.items():
|
||||
countries_of.setdefault(resolve(zone), set()).add(cc)
|
||||
# Clock rules rather than places (EET links to Athens, WET to Lisbon): no country.
|
||||
GENERIC = {'CET', 'EET', 'MET', 'WET', 'EST', 'MST', 'HST', 'CST6CDT', 'EST5EDT', 'MST7MDT', 'PST8PDT'}
|
||||
# Places that have left zone.tab, so their target's group looks like another country's.
|
||||
PLACES = {'Pacific/Johnston': 'UM'}
|
||||
dropped = []
|
||||
for alias in links:
|
||||
if alias in country or alias in GENERIC or alias.startswith('Etc/'):
|
||||
continue
|
||||
if alias in PLACES:
|
||||
country[alias] = PLACES[alias]
|
||||
continue
|
||||
ccs = countries_of.get(resolve(alias), set())
|
||||
if len(ccs) == 1:
|
||||
country[alias] = next(iter(ccs))
|
||||
else:
|
||||
dropped.append(alias)
|
||||
# Old names for a place in a zone.tab zone whose target is shared, checked by hand.
|
||||
OLD_SPELLINGS = {
|
||||
'Africa/Asmera': 'Africa/Asmara', 'Africa/Timbuktu': 'Africa/Bamako', 'America/Coral_Harbour': 'America/Atikokan',
|
||||
'America/Montreal': 'America/Toronto', 'America/Nipigon': 'America/Toronto', 'America/Thunder_Bay': 'America/Toronto',
|
||||
'Canada/Eastern': 'America/Toronto', 'America/Virgin': 'America/St_Thomas', 'Antarctica/South_Pole': 'Antarctica/McMurdo',
|
||||
'Asia/Rangoon': 'Asia/Yangon', 'Atlantic/Jan_Mayen': 'Arctic/Longyearbyen', 'Europe/Belfast': 'Europe/London',
|
||||
'GB': 'Europe/London', 'GB-Eire': 'Europe/London', 'Iceland': 'Atlantic/Reykjavik', 'NZ': 'Pacific/Auckland',
|
||||
'Pacific/Samoa': 'Pacific/Pago_Pago', 'US/Samoa': 'Pacific/Pago_Pago', 'Singapore': 'Asia/Singapore',
|
||||
'US/Arizona': 'America/Phoenix', 'Pacific/Truk': 'Pacific/Chuuk', 'Pacific/Ponape': 'Pacific/Pohnpei',
|
||||
'Pacific/Yap': 'Pacific/Chuuk'}
|
||||
for alias, zone in OLD_SPELLINGS.items():
|
||||
if alias in links and zone in country:
|
||||
country[alias] = country[zone]
|
||||
dropped.remove(alias)
|
||||
version = (src / '+VERSION').read_text().strip() if (src / '+VERSION').exists() else 'unknown'
|
||||
rows = ''.join(f' {z!r}: {c!r},\n' for z, c in sorted(country.items()))
|
||||
out = Path(__file__).resolve().parent.parent / 'ui' / 'frame_timezones.py'
|
||||
out.write_text(f'''"""Time zone name -> two-letter country, from the IANA tz database {version} (zone.tab, and
|
||||
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||
Zones that aren't in one country, such as UTC, aren't here."""
|
||||
COUNTRY = {{
|
||||
{rows}}}
|
||||
''')
|
||||
print(f'{len(country)} zones, tz {version} -> {out}')
|
||||
print('no country (shared by several):', ' '.join(sorted(dropped)) or 'none')
|
||||
@@ -38,10 +38,11 @@
|
||||
<div class="head">
|
||||
<span class="kicker">Privacy</span>
|
||||
<h1>Privacy</h1>
|
||||
<p>Short version: the app collects nothing, and the website keeps only what you choose to send.</p>
|
||||
<p>Short version: the app sends anonymous usage statistics you can turn off, your email only if you join the mailing list, and the website keeps only what you choose to send.</p>
|
||||
</div>
|
||||
<div class="faq notes">
|
||||
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no analytics and no accounts.</p></section>
|
||||
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no accounts. It sends anonymous usage statistics, which you can turn off in Privacy & updates; <a href="https://github.com/saphid/frame-control/blob/main/docs/privacy.md">privacy.md</a> lists exactly what.</p></section>
|
||||
<section><h2>The mailing list</h2><p>The first time the app opens, it offers to add you to the Frame Control mailing list for news about new features and updates. It's optional: the email field starts empty, <b>No thanks</b> skips it for good, and the app works the same either way. Only your email address is sent, privately to the maintainer, and only if you join. Unsubscribe or remove the address any time in Privacy & updates.</p></section>
|
||||
<section><h2>The feedback form</h2><p>What you type becomes a public GitHub issue on <a href="https://github.com/saphid/frame-control/issues">saphid/frame-control</a>. To stop abuse, the form keeps a one-way hash of your IP address for about an hour to count submissions. The address itself isn't stored or published.</p></section>
|
||||
<section><h2>This website</h2><p>Hosted on Cloudflare Pages. No cookies, no analytics, no trackers. The download section asks GitHub for the latest version number. Donations go through Ko-fi, under Ko-fi's own privacy policy.</p></section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
// Real Control Center logic with a minimal DOM; no network, browser or headset.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const cc = require('../ui/control-center.js');
|
||||
|
||||
const sample = () => ({time:Date.now()/1000, hostname:'Test Frame', battery:{percent:99,status:'Discharging',watts:0,tempC:33,health:'Good'},
|
||||
power:{type:'C PD [PD_PPS]',watts:18}, disk:{home:{total:256*1073741824,free:128*1073741824}},
|
||||
memory:{total:16*1073741824,available:6*1073741824}, wifi:{ssid:'Test LAN',signal:80},
|
||||
performance:{compositorFps:90,appFps:45,frameMs:11.11,gpuMs:3,cpuPercent:12,gpuMHz:600},
|
||||
volume:{level:.4,muted:false}, os:{version:'0.4.1',build:'test-build'},services:{steamvr:true}, thermalAlerts:[],games:[],flatpaks:[]});
|
||||
function fixture() {
|
||||
const elements = new Map();
|
||||
function element(id) {
|
||||
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
|
||||
classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
|
||||
return elements.get(id);
|
||||
}
|
||||
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
|
||||
const buttons = cc.categories.map(([id])=>{const e=element('category-'+id);e.dataset.ccCategory=id;e.querySelector=()=>element('count-'+id);return e;});
|
||||
const metrics = Object.keys(cc.metrics(null)).map(id=>{const e=element('metric-'+id);e.dataset.ccMetric=id;return e;});
|
||||
const page = element('controlCenter');
|
||||
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
|
||||
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
|
||||
return {document:{getElementById:element},element,cards,buttons};
|
||||
}
|
||||
function mounted(options = {}) {
|
||||
const f = fixture(), calls = [];
|
||||
let context = {online:true,generation:1,device:'frame-a'}, refreshes = 0, opened = 0;
|
||||
const ui = cc.mount({document:f.document,platform:'darwin',api:async(...args)=>{calls.push(args);return {message:'Volume updated'};},
|
||||
refresh:async()=>{refreshes++;},getContext:()=>context,openConnection:()=>opened++,...options});
|
||||
return {...f,ui,calls,get context(){return context;},set context(v){context=v;},get refreshes(){return refreshes;},get opened(){return opened;}};
|
||||
}
|
||||
const pageSource = fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||
const deferred = () => { let resolve, reject; const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject}; };
|
||||
const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
|
||||
// Execute the actual shared refresh/deduplication, host callback and mount
|
||||
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
|
||||
// rendering helpers, timers and transport are stubbed.
|
||||
function sharedPage({productionApi=false} = {}) {
|
||||
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
|
||||
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
|
||||
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
|
||||
battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
|
||||
runJob(title,id){context.installing.add(id);return job.promise;},
|
||||
api:(path,body,device)=>{
|
||||
if(path==='/api/host')return host.promise;
|
||||
if(path==='/api/status'){const request=deferred();requests.push(request);return request.promise;}
|
||||
writes.push({path,body,device});return Promise.resolve({message:'Volume updated'});
|
||||
},renderPerformance(){},setConn(){},setOnline(ok){vm.runInContext(`online = ${ok}`,context);},log(){},failed(){},
|
||||
watchConnection(){context.watched=true;}};
|
||||
vm.createContext(context);
|
||||
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
|
||||
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
|
||||
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
|
||||
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
|
||||
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
|
||||
if(productionApi) {
|
||||
// Keep api/onConnection/setOnline intact: defer fetch itself, not api(), so
|
||||
// rejection classification and the real switch/reset notifications run.
|
||||
context.fetch=(path,opts)=>{
|
||||
if(path==='/api/host')return host.promise.then(data=>({ok:true,status:200,json:async()=>data}));
|
||||
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||||
};
|
||||
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||||
resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
vm.runInContext(`const UI_KEY = 'test-only';
|
||||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
|
||||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
|
||||
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
|
||||
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
|
||||
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
|
||||
vm.runInContext(pageSource.slice(apiStart,apiEnd)+pageSource.slice(onlineStart,onlineEnd)+pageSource.slice(connectionStart,connectionEnd),context);
|
||||
}
|
||||
const installStart=pageSource.indexOf('function installFlatpak(id) {'),installEnd=pageSource.indexOf('$("fpInstall").onclick',installStart);
|
||||
vm.runInContext(pageSource.slice(installStart,installEnd),context);
|
||||
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
|
||||
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
|
||||
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
|
||||
return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
|
||||
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
|
||||
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
|
||||
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
|
||||
}
|
||||
const response = data => ({ok:true,status:200,json:async()=>data});
|
||||
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
|
||||
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
|
||||
const f=sharedPage({productionApi:true});
|
||||
if(!delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||||
const old=f.refresh(),body=deferred();
|
||||
assert.equal(f.requests[0].path,'/api/status');
|
||||
if(rejection==='body') {
|
||||
f.requests[0].resolve({ok:true,status:200,json:()=>body.promise});await flush();
|
||||
}
|
||||
f.switchHeadset();
|
||||
assert.equal(f.requests.length,2,'real connected B callback starts its own status fetch');
|
||||
const next=sample();next.hostname='Second Frame';next.battery.percent=20;next.volume.level=.2;
|
||||
f.requests[1].resolve(response(next));await flush();
|
||||
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||||
assert.equal(vm.runInContext('stateRead.valid && online && devGen === 2',f.context),true);
|
||||
if(!delayedMount)assert.equal(f.element('metric-battery').textContent,'20%');
|
||||
(rejection==='body' ? body : f.requests[0]).reject(Error('Old A request failed'));await old;
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),true,'A must not invalidate B');
|
||||
assert.equal(vm.runInContext('stateRead.error',f.context),null);
|
||||
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||||
if(delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||||
const assertB=()=>{
|
||||
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||||
assert.equal(f.element('metric-battery').textContent,'20%');
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/No live sample|local server|Old A/);
|
||||
};
|
||||
assertB();vm.runInContext('render(state)',f.context);assertB();
|
||||
assert.equal(f.requests.length,2,'preservation and redraw need no recovery poll');
|
||||
// An ordinary failure on B must still invalidate the current sample.
|
||||
const failed=f.refresh();
|
||||
if(rejection==='body')f.requests[2].resolve({ok:true,status:200,json:async()=>{throw Error('B body failed');}});
|
||||
else f.requests[2].reject(Error('B transport failed'));
|
||||
await failed;
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),false);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
});
|
||||
}
|
||||
test('production headset-free API keeps transport errors and responses across a switch', async t => {
|
||||
for(const failure of [false,true]) {
|
||||
const f=sharedPage({productionApi:true}),ui=f.mount();t.after(()=>ui.destroy());
|
||||
const pending=vm.runInContext('api("/api/devices")',f.context);
|
||||
const result=pending.then(value=>({value}),error=>({error}));
|
||||
f.switchHeadset();f.requests[1].resolve(response(sample()));await flush();
|
||||
if(failure)f.requests[0].reject(Error('Device list transport failed'));
|
||||
else f.requests[0].resolve(response({devices:['frame-b']}));
|
||||
const settled=await result;
|
||||
if(failure){assert.equal(settled.error.stale,undefined);assert.match(settled.error.message,/local server isn't running/);}
|
||||
else assert.deepEqual(settled.value,{devices:['frame-b']});
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),true);
|
||||
}
|
||||
});
|
||||
test('search finds settings by aliases, all words, category and guidance', () => {
|
||||
const find = (q,category='all')=>cc.catalog.filter(item=>cc.matches(item,q,category)).map(item=>item.id);
|
||||
assert.ok(find('DPI').includes('display'));
|
||||
assert.ok(find('sleep inactivity').includes('power'));
|
||||
assert.ok(find('MOTION SICKNESS').includes('playspace'));
|
||||
assert.ok(find('font','apps').includes('display'));
|
||||
assert.deepEqual(find('font','vr'),[]);
|
||||
assert.deepEqual(find('<script>alert(1)</script>'),[]);
|
||||
assert.ok(find(' ').length > 20);
|
||||
});
|
||||
test('metrics use real fields, preserve zero, and distinguish unavailable values', () => {
|
||||
const m = cc.metrics(sample());
|
||||
assert.equal(m.battery,'99%');
|
||||
assert.equal(m.power,'0 W');
|
||||
assert.match(m.batteryDetail,/not charging/);
|
||||
assert.equal(m.fps,'90'); assert.equal(m.appFps,'45');
|
||||
assert.equal(m.storage,'128 GiB'); assert.equal(m.memory,'10 GiB');
|
||||
assert.equal(m.thermal,'No hot / critical trip reported');
|
||||
assert.equal(cc.metrics(null).fps,'Unavailable');
|
||||
assert.equal(cc.metrics({performance:{appFps:null,gpuMHz:0,cpuPercent:0}}).appFps,'Unavailable');
|
||||
assert.equal(cc.metrics({performance:{cpuPercent:0}}).cpu,'0%');
|
||||
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
|
||||
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
|
||||
});
|
||||
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
|
||||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
|
||||
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
|
||||
for (const change of [{level:-.1},{level:1.1},{level:NaN},{level:Infinity},{level:'0.5'},{level:null},{muted:'false'},{unknown:true},{}]) {
|
||||
await assert.rejects(controller.audio(change),/valid mute state/);
|
||||
}
|
||||
context.online=false;await assert.rejects(controller.audio({level:.5}),/Connect and refresh/);
|
||||
context.online=true;context.fresh=false;await assert.rejects(controller.audio({muted:true}),/Connect and refresh/);
|
||||
assert.equal(calls.length,0);
|
||||
});
|
||||
test('audio uses the captured device, prevents duplicate writes, and ignores switched-headset success', async () => {
|
||||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},complete,calls=[];
|
||||
const controller=cc.createController({api:(...args)=>{calls.push(args);return new Promise(r=>complete=r);},getContext:()=>context});
|
||||
const pending=controller.audio({level:.7});
|
||||
await assert.rejects(controller.audio({muted:true}),/still in progress/);
|
||||
assert.deepEqual(calls,[['/api/volume',{level:.7},'frame-a']]);
|
||||
context={...context,device:'frame-b',generation:2};complete({message:'Done'});
|
||||
assert.equal(await pending,null);assert.equal(controller.busy,false);
|
||||
});
|
||||
test('controller surfaces real API errors and allows a deliberate retry', async () => {
|
||||
let calls=0;
|
||||
const controller=cc.createController({api:async()=>{if(++calls===1)throw Error('SSH connection lost');return {message:'Done'};},
|
||||
getContext:()=>({online:true,fresh:true,sample:sample(),device:'frame-a',generation:1})});
|
||||
await assert.rejects(controller.audio({muted:true}),/SSH connection lost/);
|
||||
assert.equal(controller.busy,false);
|
||||
assert.deepEqual(await controller.audio({muted:true}),{message:'Done'});
|
||||
});
|
||||
test('mounted controls only write on Apply or Mute, show readback, and reset on switch', async t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.ui.status(sample());
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('ccVolume').value,40);
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
assert.equal(f.calls.length,0);
|
||||
assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||||
f.ui.status(sample()); // a poll must not overwrite an unapplied value
|
||||
assert.equal(f.element('ccVolume').value,'65');
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.deepEqual(f.calls,[['/api/volume',{level:.65},'frame-a']]);
|
||||
assert.equal(f.refreshes,1);
|
||||
await f.element('ccMute').onclick();
|
||||
assert.deepEqual(f.calls[1],['/api/volume',{muted:true},'frame-a']);
|
||||
f.context={online:null,generation:2,device:'frame-b'};f.ui.reset();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
});
|
||||
test('offline/errors clear live readings, disable audio and keep companion navigation usable', async t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
f.context.online=false;f.ui.connection(false);
|
||||
assert.equal(f.element('metric-fps').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccVolume').disabled,true);
|
||||
await f.element('ccMute').onclick();
|
||||
assert.equal(f.calls.length,0);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Connect and refresh/);
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='privacy').hidden,false);
|
||||
f.ui.unavailable(Error('Local server is unavailable'));
|
||||
assert.match(f.element('ccSampleState').textContent,/Local server is unavailable/);
|
||||
f.context.online=true;f.ui.status(sample());
|
||||
assert.equal(f.element('ccVolume').disabled,false);
|
||||
});
|
||||
test('mounted audio failure is visible and never reports success', async t => {
|
||||
const f=mounted({api:async()=>{throw Error('Permission denied');}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/Couldn’t apply audio: Permission denied/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.refreshes,0);
|
||||
});
|
||||
test('audio confirmation depends on fresh readback rather than command acknowledgment', async t => {
|
||||
let expected=.65, mode='confirmed';
|
||||
const f=mounted({refresh:async()=>{
|
||||
if(mode==='offline')return f.ui.unavailable(Error('Headset unreachable'));
|
||||
const reading=sample();reading.volume.level=mode==='different' ? .64 : expected;f.ui.status(reading);
|
||||
}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||||
mode='different';f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/reported output differs: 64%/);
|
||||
mode='offline';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/command sent; current output could not be confirmed/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
});
|
||||
test('pending audio never overwrites the new headset with old success or error', async t => {
|
||||
let complete;
|
||||
const f=mounted({api:()=>new Promise(resolve=>{complete=resolve;})});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
const pending=f.element('ccMute').onclick();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||||
const newSample=sample();newSample.hostname='Second Frame';newSample.volume.muted=true;f.ui.status(newSample);
|
||||
complete({message:'Old headset muted'});await pending;
|
||||
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
assert.equal(f.refreshes,0);
|
||||
assert.equal(f.element('ccMute').textContent,'Unmute');
|
||||
f.ui.unavailable({stale:true,message:'Switched headsets'});
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
assert.equal(f.element('ccMute').disabled,false);
|
||||
});
|
||||
test('pending old-headset volume cannot become a draft on the new headset', async t => {
|
||||
for(const failure of [false,true]) {
|
||||
const request=deferred(),f=mounted({api:()=>request.promise});t.after(()=>f.ui.destroy());
|
||||
f.ui.status(sample());f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const pending=f.element('ccApplyVolume').onclick();
|
||||
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||||
const next=sample();next.volume.level=.2;f.ui.status(next);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
if(failure)request.reject(Error('Old headset failed'));else request.resolve({message:'Done'});
|
||||
await pending;
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
assert.equal(f.element('ccVolumeValue').textContent,'20%');
|
||||
assert.equal(f.element('ccAudioReadback').textContent,'Reported 20% · unmuted');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
}
|
||||
});
|
||||
test('production host response works before and after mounting with the actual bridge shape', async t => {
|
||||
for(const early of [true,false]) {
|
||||
const f=sharedPage();let ui;
|
||||
if(!early){ui=f.mount();t.after(()=>ui.destroy());assert.equal(f.element('macLink').hidden,true);}
|
||||
f.host.resolve({os:'macOS',mobile:false});await flush();
|
||||
if(early){ui=f.mount();t.after(()=>ui.destroy());}
|
||||
assert.equal(f.context.watched,true);
|
||||
assert.equal(f.element('macLink').hidden,false);
|
||||
f.element('ccSearch').value='mac';f.element('ccSearch').input();
|
||||
assert.equal(f.element('macLink').hidden,false);
|
||||
ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||
}
|
||||
});
|
||||
test('production shared refresh drains a pre-command request then samples after acknowledgment', async t => {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||||
const prior=f.refresh();assert.equal(f.requests.length,1);
|
||||
assert.equal(f.refresh(),prior); // the ordinary poll/manual deduplication still holds
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const command=f.element('ccApplyVolume').onclick();await flush();
|
||||
assert.equal(f.writes.length,1);assert.equal(f.requests.length,1);
|
||||
f.requests[0].resolve(sample());await prior;await flush();
|
||||
assert.equal(f.requests.length,2,'readback must issue a real post-command status request');
|
||||
assert.doesNotMatch(f.element('ccAudioResult').textContent,/Confirmed|reported output differs/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
const after=sample();after.volume.level=.65;f.requests[1].resolve(after);await command;
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
});
|
||||
test('production audio readback survives an old poll failure and treats its own failed read as unconfirmed', async t => {
|
||||
for(const failedReadback of [false,true]) {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||||
const prior=f.refresh();f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const command=f.element('ccApplyVolume').onclick();await flush();
|
||||
f.requests[0].reject(Error('Pre-command poll failed'));await prior;await flush();
|
||||
assert.equal(f.requests.length,2);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
if(failedReadback)f.requests[1].reject(Error('Readback unavailable'));
|
||||
else {const after=sample();after.volume.level=.64;f.requests[1].resolve(after);}
|
||||
await command;
|
||||
assert.match(f.element('ccAudioResult').textContent,failedReadback ? /command sent; current output could not be confirmed/ : /reported output differs: 64%/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,failedReadback);
|
||||
}
|
||||
});
|
||||
test('production Flatpak redraw cannot revive a failed status; only a fresh successful poll recovers', async t => {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());
|
||||
const first=f.refresh(),reading=sample();f.requests[0].resolve(reading);await first;
|
||||
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
const failed=f.refresh();f.requests[1].reject(Error('Synthetic status parsing error'));await failed;
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.installFlatpak(); // actual production render(state), with job held pending
|
||||
assert.equal(f.requests.length,2,'redraw must not start a replacement reading');
|
||||
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
assert.match(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
const fresh=f.refresh();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true,'starting a poll does not clear failure');
|
||||
const next=sample();next.battery.percent=98;next.volume.level=.2;
|
||||
f.requests[2].resolve(next);await fresh;
|
||||
assert.equal(f.element('metric-battery').textContent,'98%');
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||||
});
|
||||
test('delayed production bootstrap retains failed-read state and fresh-poll recovery', async t => {
|
||||
for(const priorSuccess of [true,false]) {
|
||||
const f=sharedPage();
|
||||
if(priorSuccess){const first=f.refresh();f.requests[0].resolve(sample());await first;}
|
||||
const failed=f.refresh();f.requests.at(-1).reject(Error('Status failed before mount'));await failed;
|
||||
const calls=f.requests.length,ui=f.mount();t.after(()=>ui.destroy());
|
||||
assert.equal(f.requests.length,calls);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
assert.match(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
const fresh=f.refresh();assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.requests.at(-1).resolve(sample());await fresh;
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||||
}
|
||||
});
|
||||
test('same-object replay preserves invalidation and receipt age after a failed reading', t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();
|
||||
f.ui.status(reading);f.ui.unavailable(Error('Status failure'));
|
||||
f.ui.status(reading,Date.now()+1000);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.match(f.element('ccSampleState').textContent,/Status failure/);
|
||||
f.ui.status(sample());assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
});
|
||||
test('delayed production bootstrap preserves client receipt age despite headset clock skew', async t => {
|
||||
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=sharedPage(),prior=f.refresh();const reading=sample();reading.time-=3600;
|
||||
f.requests[0].resolve(reading);await prior;
|
||||
now+=91000;const ui=f.mount();t.after(()=>ui.destroy());
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.match(f.element('ccSampleState').textContent,/expired/);
|
||||
const fresh=f.refresh();f.requests[1].resolve(sample());await fresh;
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
});
|
||||
test('sample replay cannot renew receipt age; fresh receipts tolerate missing or skewed headset time', t => {
|
||||
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();f.ui.status(reading);
|
||||
now+=91000;f.ui.status(reading);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
for(const time of [undefined,null,'invalid',NaN,Infinity,now/1000-3600,now/1000+3600]) {
|
||||
f.ui.status({...sample(),time});
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Invalid Date/);
|
||||
}
|
||||
for(const receipt of [null,NaN,Infinity,now+1000,now-91000]) {
|
||||
f.ui.status(sample(),receipt);assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
}
|
||||
});
|
||||
test('mute-only readback preserves an unapplied volume until Apply sends it', async t => {
|
||||
let reading=sample();const f=mounted({api:async(path,change,device)=>{
|
||||
f.calls.push([path,change,device]);reading={...reading,volume:{...reading.volume,...change}};return {message:'Done'};
|
||||
},refresh:async()=>f.ui.status({...reading})});t.after(()=>f.ui.destroy());f.ui.status(reading);
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
await f.element('ccMute').onclick();
|
||||
assert.deepEqual(f.calls[0],['/api/volume',{muted:true},'frame-a']);
|
||||
assert.equal(f.element('ccVolume').value,'65');assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||||
assert.match(f.element('ccAudioReadback').textContent,/Reported 40% · muted/);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 40%, muted/);
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.deepEqual(f.calls[1],['/api/volume',{level:.65},'frame-a']);
|
||||
assert.equal(f.element('ccVolume').value,65);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, muted/);
|
||||
});
|
||||
test('live samples expire even when the status poll is paused', t => {
|
||||
const original=Date.now;let now=100000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
now+=91000;
|
||||
// A connection update must also re-check freshness before enabling a control.
|
||||
f.ui.connection(true);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
});
|
||||
test('mounted filters show empty state, counts, reset and host-dependent features', t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());
|
||||
f.element('ccSearch').value='dpi';f.element('ccSearch').input();
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='display').hidden,false);
|
||||
assert.equal(f.element('ccResultCount').textContent,'1 section');
|
||||
f.element('ccSearch').value='nothing-matches-this';f.element('ccSearch').input();
|
||||
assert.equal(f.element('ccEmpty').hidden,false);
|
||||
f.element('ccResetFilter').onclick();assert.equal(f.element('ccEmpty').hidden,true);
|
||||
f.ui.host({os:'Windows'});assert.equal(f.element('macLink').hidden,true);
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='mac').hidden,false);
|
||||
f.ui.host({os:'macOS'});assert.equal(f.element('macLink').hidden,false);
|
||||
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
|
||||
});
|
||||
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
|
||||
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
|
||||
let activePage,scrolled;
|
||||
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
||||
const sections=new Map();
|
||||
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
|
||||
window:{scrollTo(){}},$:id=>{
|
||||
if(!ids.has(id))return null;
|
||||
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
|
||||
return sections.get(id);
|
||||
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
|
||||
const androidStart=html.indexOf('function showAndroidTab(');
|
||||
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
|
||||
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
|
||||
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
|
||||
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
|
||||
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
|
||||
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||||
if(!href.startsWith('#')||href==='#connection')continue;
|
||||
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
|
||||
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
|
||||
assert.equal(activePage,expected[id],href);
|
||||
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
|
||||
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
|
||||
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
|
||||
}
|
||||
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
|
||||
assert.equal(vm.runInContext('PAGES[5]',context),'control');
|
||||
});
|
||||
@@ -23,9 +23,9 @@ const metaLib = { connected: true, results: new Map(), apps: [
|
||||
data_files: 0, skipped_files: ["main.com.crytek.climb.obb"] },
|
||||
{ id: "2", title: "Beat Saber", package: "com.beatgames.beatsaber", problem: "Meta's API said HTTP 500", skipped_files: [] },
|
||||
] };
|
||||
const render = new Function("$", "metaLib", "androidApps", "installing",
|
||||
const render = new Function("$", "metaLib", "androidApps", "installing", "queued",
|
||||
grab("esc") + grab("questSize") + "const META_VERDICT = {};\n" + grab("renderMetaApps") + "renderMetaApps();");
|
||||
render(id => els[id], metaLib, [], new Set());
|
||||
render(id => els[id], metaLib, [], new Set(["meta:1"]), new Set(["meta:1"]));
|
||||
const html = els.metaApps.innerHTML;
|
||||
if (els.metaCount.textContent !== "2 apps") fail("count: " + els.metaCount.textContent);
|
||||
const climb = html.split('class="item"')[1];
|
||||
@@ -34,7 +34,10 @@ if (!/Not installed: 1 required data file \(main\.com\.crytek\.climb\.obb\)/.tes
|
||||
const saber = html.split('class="item"')[2];
|
||||
if (!/<div class="s v-no"[^>]*>Meta's API said HTTP 500</.test(saber)) fail("problem not on its own line: " + saber);
|
||||
if (/Not installed/.test(saber)) fail("warning without skipped files");
|
||||
// Waiting in the install queue: marked, and not offered again.
|
||||
if (!/<span class="tag">Queued<\/span>/.test(climb) || !/data-meta-install="1" disabled/.test(climb)) fail("queued not shown: " + climb);
|
||||
if (/Queued|disabled/.test(saber)) fail("queued on the wrong row: " + saber);
|
||||
metaLib.apps = metaLib.apps.slice(0, 1);
|
||||
render(id => els[id], metaLib, [], new Set());
|
||||
render(id => els[id], metaLib, [], new Set(), new Set());
|
||||
if (els.metaCount.textContent !== "1 app") fail("singular count: " + els.metaCount.textContent);
|
||||
console.log("ok");
|
||||
+16
-5
@@ -228,10 +228,9 @@ class Contact(Base):
|
||||
|
||||
# ---- the one-time prompt
|
||||
|
||||
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
|
||||
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
|
||||
tm.frame_seen("20260901.1", "3.8")
|
||||
self.assertTrue(fc.state()["showPrompt"])
|
||||
def test_the_prompt_is_offered_on_first_launch_then_stays_dismissed(self):
|
||||
self.assertTrue(fc.state()["showPrompt"]) # a new install, before the Frame has connected
|
||||
self.assertEqual(self.got, []) # offering it sends nothing
|
||||
fc.prompt({"prompt": "dismissed"})
|
||||
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
|
||||
self.assertEqual(fc.load()["prompt"], "dismissed")
|
||||
@@ -241,12 +240,24 @@ class Contact(Base):
|
||||
fc.prompt({"prompt": "reset"})
|
||||
|
||||
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
|
||||
tm.frame_seen("20260901.1", "3.8")
|
||||
fc.prompt({"prompt": "shown"})
|
||||
self.assertFalse(fc.state()["showPrompt"])
|
||||
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
|
||||
self.assertEqual(fc.load()["prompt"], "answered")
|
||||
|
||||
def test_joining_from_the_prompt_is_the_mailing_list_opt_in_only(self):
|
||||
fc.save({"email": " me@example.com ", "updates": True, "fromPrompt": True})
|
||||
self.assertEqual((fc.load()["prompt"], fc.state()["showPrompt"]), ("answered", False))
|
||||
props = self.events()[-1]["properties"]
|
||||
self.assertEqual((props["email"], props["updates"], props["followup"], props["action"]),
|
||||
("me@example.com", True, False, "set"))
|
||||
|
||||
def test_a_failed_send_never_blocks_joining(self):
|
||||
with self.offline():
|
||||
s = fc.save({"email": "me@example.com", "updates": True, "fromPrompt": True})
|
||||
self.assertTrue(s["updates"] and s["waiting"]) # kept here, sent later by the retrier
|
||||
self.assertFalse(s["showPrompt"])
|
||||
|
||||
# ---- reports and the maintainer's list
|
||||
|
||||
def reports(self):
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Control Center API guards and shared-page behavior, without a real headset."""
|
||||
import sandbox # noqa: F401 (first: isolates app state, SSH and telemetry)
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import server
|
||||
import frame_status
|
||||
|
||||
|
||||
class AudioValidation(unittest.TestCase):
|
||||
def test_invalid_values_do_not_touch_device_even_with_valid_mute(self):
|
||||
invalid = [{}, {'level': None}, {'level': True}, {'level': '0.5'}, {'level': []},
|
||||
{'level': float('nan')}, {'level': float('inf')}, {'level': -1}, {'level': 2},
|
||||
{'muted': 'false'}, {'muted': 1}, {'muted': None}, {'action': 'mute'},
|
||||
{'level': -1, 'muted': True}, {'level': .5, 'muted': 'yes'}]
|
||||
for body in invalid:
|
||||
with self.subTest(body=body), mock.patch.object(server, 'ssh') as ssh:
|
||||
with self.assertRaises(server.Failure) as raised:
|
||||
server.set_volume(body)
|
||||
self.assertEqual(raised.exception.status, 400)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_supported_actions_use_wpctl_with_exact_values(self):
|
||||
for body, commands in [({'level': 0}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.00']),
|
||||
({'level': 1}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.00']),
|
||||
({'muted': False}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 0']),
|
||||
({'level': .65, 'muted': True}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 1',
|
||||
'wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.65'])]:
|
||||
with self.subTest(body=body), mock.patch.object(server, 'ssh', return_value='') as ssh:
|
||||
self.assertEqual(server.set_volume(body)['message'], 'Volume updated')
|
||||
self.assertEqual([c.args[0] for c in ssh.call_args_list], commands)
|
||||
|
||||
def test_device_error_propagates_without_claiming_success(self):
|
||||
with mock.patch.object(server, 'ssh', side_effect=server.Failure('Headset offline')):
|
||||
with self.assertRaisesRegex(server.Failure, 'Headset offline'):
|
||||
server.set_volume({'level': .5})
|
||||
|
||||
|
||||
class Coverage(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page JS')
|
||||
def test_control_center_behavior(self):
|
||||
result = subprocess.run(['node', '--test', str(ROOT / 'tests/control_center.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_shared_assets_ship_to_iphone_and_desktop(self):
|
||||
spec = importlib.util.spec_from_file_location('frame_bundle_cc', ROOT / 'ios/scripts/make_frame_bundle.py')
|
||||
bundle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bundle)
|
||||
files = set(bundle.files())
|
||||
for name in ('control-center.js', 'control-center.css', 'artwork-settings.js'):
|
||||
self.assertIn(ROOT / 'ui' / name, files)
|
||||
config = json.loads((ROOT / 'app/package.json').read_text())
|
||||
ui = next(item for item in config['build']['extraResources'] if item['from'] == '../ui')
|
||||
self.assertIn('*.js', ui['filter'])
|
||||
self.assertIn('*.css', ui['filter'])
|
||||
|
||||
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
|
||||
# Run the real aggregator with every external probe stubbed.
|
||||
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
|
||||
'disk', 'memory', 'max_temp', 'wifi', 'ip_addr', 'volume', 'port_listening', 'games', 'flatpaks')
|
||||
with mock.patch.multiple(frame_status, **{name: mock.DEFAULT for name in names}) as probes, \
|
||||
mock.patch.object(frame_status, 'thermal_alerts', return_value=[{'zone':'battery','tempC':45,'limitC':44}]):
|
||||
probes['read'].return_value = '120 0'
|
||||
probes['process_names'].return_value = set()
|
||||
result = frame_status.status()
|
||||
self.assertEqual(result['thermalAlerts'], [{'zone':'battery','tempC':45,'limitC':44}])
|
||||
@@ -0,0 +1,127 @@
|
||||
"""The install queue (server.queue_job): Android, Quest and Meta installs run one after another."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import server # noqa: E402
|
||||
|
||||
|
||||
def wait(job, timeout=5):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
j = server.job_status(f'id={job}')
|
||||
if j['done']:
|
||||
return j
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(f'job {job} never finished')
|
||||
|
||||
|
||||
class InstallQueue(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertEqual(server._queue, [])
|
||||
|
||||
def blocker(self):
|
||||
"""A queued install that runs until released."""
|
||||
go, started = threading.Event(), threading.Event()
|
||||
|
||||
def work():
|
||||
started.set()
|
||||
go.wait(5)
|
||||
return {'message': 'first done'}
|
||||
job = server.queue_job('Install first', work)['job']
|
||||
self.assertTrue(started.wait(5))
|
||||
self.addCleanup(go.set)
|
||||
return job, go
|
||||
|
||||
def test_runs_in_order_one_at_a_time(self):
|
||||
order, running = [], []
|
||||
|
||||
def work(name):
|
||||
def run(report):
|
||||
running.append(name)
|
||||
self.assertEqual(len(running), 1, 'two installs ran at once')
|
||||
report('Copying', 50)
|
||||
time.sleep(0.02)
|
||||
order.append(name)
|
||||
running.remove(name)
|
||||
return {'message': f'{name} done'}
|
||||
return run
|
||||
first, go = self.blocker()
|
||||
started = [server.queue_job(f'Install {n}', work(n), progress=True) for n in ('a', 'b', 'c')]
|
||||
self.assertEqual([s['queued'] for s in started], [1, 2, 3])
|
||||
self.assertIn('queued, 3 before it', started[2]['message'])
|
||||
q = server.install_queue()['jobs']
|
||||
self.assertEqual([j['label'] for j in q], ['Install first', 'Install a', 'Install b', 'Install c'])
|
||||
self.assertEqual([j['queued'] for j in q], [False, True, True, True])
|
||||
self.assertEqual(q[1]['stage'], 'Queued: 1 install before it')
|
||||
self.assertEqual(q[3]['stage'], 'Queued: 3 installs before it')
|
||||
go.set()
|
||||
self.assertEqual(wait(first)['message'], 'first done')
|
||||
for s, n in zip(started, 'abc'):
|
||||
j = wait(s['job'])
|
||||
self.assertEqual((j['message'], j['error'], j['queued']), (f'{n} done', None, False))
|
||||
self.assertEqual(order, ['a', 'b', 'c'])
|
||||
self.assertEqual(server.install_queue(), {'jobs': []})
|
||||
|
||||
def test_a_failure_does_not_stop_the_rest(self):
|
||||
def fail():
|
||||
raise server.Failure('no room on the Frame')
|
||||
bad = server.queue_job('Install bad', fail)['job']
|
||||
good = server.queue_job('Install good', lambda: {'message': 'ok'})['job']
|
||||
self.assertEqual(wait(bad)['error'], 'no room on the Frame')
|
||||
self.assertEqual(wait(good)['message'], 'ok')
|
||||
|
||||
def test_a_waiting_install_can_be_removed_but_not_a_running_one(self):
|
||||
ran = []
|
||||
first, go = self.blocker()
|
||||
second = server.queue_job('Install second', lambda: ran.append(2) or {})['job']
|
||||
third = server.queue_job('Install third', lambda: ran.append(3) or {})['job']
|
||||
self.assertIn('Removed Install second', server.queue_action({'action': 'remove', 'job': second})['message'])
|
||||
j = server.job_status(f'id={second}')
|
||||
self.assertTrue(j['done'] and j['cancelled'])
|
||||
self.assertEqual(server.job_status(f'id={third}')['stage'], 'Queued: 1 install before it')
|
||||
for body, status in (({'action': 'remove', 'job': first}, 409), ({'action': 'remove', 'job': second}, 409),
|
||||
({'action': 'nope', 'job': third}, 400)):
|
||||
with self.assertRaises(server.Failure) as e:
|
||||
server.queue_action(body)
|
||||
self.assertEqual(e.exception.status, status)
|
||||
go.set()
|
||||
wait(third)
|
||||
self.assertEqual(ran, [3])
|
||||
|
||||
def test_runs_only_on_the_headset_it_was_queued_for(self):
|
||||
class Link:
|
||||
device = 'frame-a'
|
||||
|
||||
def active_device(self):
|
||||
return {'id': self.device}
|
||||
link = Link()
|
||||
with patch.object(server, 'LINK', link):
|
||||
first, go = self.blocker()
|
||||
ran = []
|
||||
later = server.queue_job('Install later', lambda: ran.append(1) or {})['job']
|
||||
link.device = 'frame-b'
|
||||
go.set()
|
||||
self.assertIn('switched headsets', wait(later)['error'])
|
||||
self.assertEqual(ran, [])
|
||||
|
||||
def test_installs_from_each_place_are_queued(self):
|
||||
with patch.object(server, 'ensure_master'), patch.object(server, 'queue_job', return_value={'job': 'j'}) as q:
|
||||
server.quest({'action': 'import', 'package': 'com.example.game'})
|
||||
server.meta({'action': 'install', 'app': '123'})
|
||||
with patch.object(server.frame_catalog, 'app'):
|
||||
server.android({'action': 'install', 'package': 'com.example.game'})
|
||||
server.android({'action': 'install', 'package': 'com.example.game', 'url': 'https://f-droid.org/x.apk'})
|
||||
with patch.object(server.apk_search, 'resolve', return_value=(None, {'enabled': True})):
|
||||
server.source_install({'source': 'fdroid', 'id': 'com.example.game'})
|
||||
self.assertEqual(q.call_count, 5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+1
-1
@@ -1224,7 +1224,7 @@ class Server(unittest.TestCase):
|
||||
self.assertEqual({a['id'] for a in status['apps']}, {'111', '222'})
|
||||
self.assertIn('sharing', status)
|
||||
self.assertNotIn(TOKEN, json.dumps([result, status]))
|
||||
with patch.object(s, 'ensure_master'), patch.object(s, 'start_job', return_value={'job': 'j'}) as job:
|
||||
with patch.object(s, 'ensure_master'), patch.object(s, 'queue_job', return_value={'job': 'j'}) as job:
|
||||
self.assertEqual(s.meta({'action': 'install', 'app': '111', 'test': True}), {'job': 'j'})
|
||||
work = job.call_args.args[1]
|
||||
with patch.object(meta, 'install', side_effect=KeyError(f'access_token={TOKEN}')):
|
||||
|
||||
+1
-1
@@ -1130,7 +1130,7 @@ class Server(unittest.TestCase):
|
||||
with self.assertRaises(s.Failure) as e:
|
||||
s.quest(body)
|
||||
self.assertEqual(e.exception.status, 400, body)
|
||||
with patch.object(s, 'ensure_master'), patch.object(s, 'start_job', return_value={'job': 'j'}) as job:
|
||||
with patch.object(s, 'ensure_master'), patch.object(s, 'queue_job', return_value={'job': 'j'}) as job:
|
||||
self.assertEqual(s.quest({'action': 'import', 'package': PKG, 'serial': QUEST['serial'], 'data': True}),
|
||||
{'job': 'j'})
|
||||
self.assertTrue(job.call_args.kwargs['progress'])
|
||||
|
||||
@@ -79,6 +79,15 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(headers["X-Frame-Options"], "DENY")
|
||||
self.assertIn(b"<html", payload.lower())
|
||||
|
||||
def test_control_center_assets_served_with_correct_types(self):
|
||||
for path, mime, marker in (('/control-center.js', 'text/javascript', b'createController'),
|
||||
('/control-center.css', 'text/css', b'.cc-workspace')):
|
||||
with self.subTest(path=path):
|
||||
status, headers, payload = self.request('GET', path)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(mime, headers['Content-Type'])
|
||||
self.assertIn(marker, payload)
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
|
||||
@@ -159,6 +159,42 @@ class Lifecycle(Base):
|
||||
e = self.queued()[-1]
|
||||
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
|
||||
|
||||
def test_opens_installs_and_updates_carry_the_country(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
|
||||
tm.app_started()
|
||||
self.assertEqual([(e["event"], e["properties"].get("country")) for e in self.queued()],
|
||||
[("app_installed", "DE"), ("app_opened", "DE"), ("app_updated", "DE")])
|
||||
self.assertNotIn("Europe/Berlin", tm.OUTBOX.read_text())
|
||||
|
||||
def test_only_zones_in_the_tz_table_give_a_country(self):
|
||||
for tz, want in (("America/Argentina/Buenos_Aires", "AR"), ("Asia/Calcutta", "IN"), ("US/Pacific", "US"),
|
||||
("Europe/Vaduz", "LI"), ("Africa/Asmera", "ER"), ("Africa/Asmara", "ER"), ("Pacific/Truk", "FM"),
|
||||
("Pacific/Ponape", "FM"), ("America/Montreal", "CA"), ("America/Nassau", "BS"),
|
||||
("Europe/Amsterdam", "NL"), ("Etc/GMT+10", None), ("CET", None),
|
||||
("EET", None), ("WET", None), ("PST8PDT", None), ("HST", None), ("Pacific/Johnston", "UM"),
|
||||
("Japan", "JP"), ("UTC", None), ("alice", None),
|
||||
("home/alex/private", None), ("America/alex", None), ("../etc/passwd", None)):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}):
|
||||
self.assertEqual(tm.country(), want, tz)
|
||||
|
||||
def test_country_from_tz_or_localtime_in_a_checkout(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ":Asia/Tokyo"}):
|
||||
self.assertEqual(tm.country(), "JP")
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ""}):
|
||||
for target, want in (("/usr/share/zoneinfo/Europe/Paris", "FR"),
|
||||
("/var/db/timezone/zoneinfo/Australia/Sydney", "AU"),
|
||||
("/home/alex/zoneinfo/alex/private", None), ("/etc/mytime", None)):
|
||||
with mock.patch.object(tm.os.path, "realpath", return_value=target):
|
||||
self.assertEqual(tm.country(), want, target)
|
||||
|
||||
def test_a_new_user_who_opens_settings_first_still_gets_the_notice(self):
|
||||
tm.update_settings({"compat": True})
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
|
||||
def test_frame_build_once(self):
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
@@ -230,6 +266,39 @@ class Sending(Base):
|
||||
self.assertEqual(self.queued(), [])
|
||||
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
|
||||
|
||||
def test_an_older_notice_still_sends(self):
|
||||
got = self.serve()
|
||||
tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1})
|
||||
tm.capture("app_opened")
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual(len(got), 1)
|
||||
|
||||
def test_an_older_notice_is_shown_again_and_holds_back_the_country(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # as 0.4.5 saved it
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1) # the usual statistics still go
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
tm._save({**tm.settings(), "last_open_day": None}) # the next day
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual([b["batch"][0]["properties"].get("country") for _, b in got], [None, "DE"])
|
||||
|
||||
def test_turning_usage_on_still_waits_for_the_new_notice(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "usage": False, "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # 0.4.5, usage off
|
||||
tm.update_settings({"usage": True})
|
||||
self.assertFalse(tm.state()["noticeShown"]) # so the page shows it
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertNotIn("country", got[0][1]["batch"][0]["properties"])
|
||||
|
||||
def test_a_failed_send_keeps_the_events(self):
|
||||
self.serve(status=500)
|
||||
tm.update_settings({"noticeShown": True})
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Local to the sixth tab: the existing Home, Games, Android and Tools keep
|
||||
their layouts. The same Steam palette gives the dashboard a quieter rhythm. */
|
||||
.cc { gap:24px; }
|
||||
.cc :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
|
||||
.cc-hero { display:flex; justify-content:space-between; align-items:center; gap:24px; padding:28px 30px;
|
||||
border:1px solid #66c0f421; border-radius:12px; background:radial-gradient(ellipse at 90% 0%,#1a9fff19,transparent 65%),linear-gradient(125deg,#21364a,#152230); }
|
||||
.cc-eyebrow { font-size:11px; font-weight:700; letter-spacing:2px; text-transform:uppercase; color:var(--link); margin-bottom:7px; }
|
||||
.cc-hero h1 { font-size:clamp(25px,3vw,36px); font-weight:500; letter-spacing:-.7px; margin:0 0 8px; color:white; }
|
||||
.cc-hero p { color:#adb9c5; margin:0; max-width:580px; }
|
||||
.cc-hero > div { min-width:0; }
|
||||
.cc-hero-mark { flex:none; width:114px; height:82px; color:var(--link); opacity:.85; }
|
||||
.cc-topline { display:flex; gap:14px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
|
||||
.cc-topline h2 { font-size:17px; font-weight:500; margin:0; color:var(--bright); min-width:0; max-width:100%; overflow-wrap:anywhere; }
|
||||
.cc-topline button { margin-left:auto; border-radius:6px; }
|
||||
.cc-sample { font-size:12px; color:var(--muted); }.cc-offline { color:#cfb787; }
|
||||
.cc-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; }
|
||||
.cc-metric { border:1px solid #ffffff0c; border-radius:9px; padding:18px 20px; background:linear-gradient(145deg,#25374a80,#18232f); min-width:0; }
|
||||
.cc-metric > span { color:#a7b8c8; font-size:12px; }.cc-metric > strong { display:block; color:white; font-size:clamp(20px,2.2vw,30px); font-weight:500; margin:7px 0 3px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||||
.cc-metric > small { color:var(--muted); display:block; font-size:11.5px; }
|
||||
.cc-device-facts { margin:14px 0 0; padding:16px 20px; border:1px solid #ffffff0a; border-radius:9px; background:#10192366; }
|
||||
.cc-device-facts summary { cursor:pointer; color:#b8cbd9; font-size:12px; }.cc-device-facts dl { margin:18px 0 0; }
|
||||
.cc-readings { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px 18px; padding:0; }
|
||||
.cc-readings div { min-width:0; }.cc-readings dt { color:var(--muted); font-size:11px; }.cc-readings dd { margin:3px 0 0; color:#d9e7f2; font-size:13px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||||
.cc-device-facts .cc-readings { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||
.cc-services { display:flex; flex-wrap:wrap; gap:12px 22px; border-top:1px solid #ffffff0b; margin-top:18px; padding-top:14px; }
|
||||
.cc-services > span { display:flex; gap:7px; align-items:center; font-size:11px; color:#b3c5d4; }.cc-services b { color:var(--muted); font-weight:400; }
|
||||
.cc-workspace { display:grid; grid-template-columns:220px minmax(0,1fr); gap:26px; align-items:start; }
|
||||
.cc-workspace > * { min-width:0; width:100%; max-width:100%; }
|
||||
.cc-sidebar { position:sticky; top:90px; }.cc-sidebar > p { margin:0 0 12px 12px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:1.5px; }
|
||||
.cc-categories { display:flex; flex-direction:column; gap:5px; min-width:0; max-width:100%; }.cc-categories button { display:flex; align-items:center; text-align:left; gap:10px; border-radius:7px; background:transparent; min-height:43px; padding:10px 12px; color:#a8b8c8; font-size:12px; }
|
||||
.cc-categories svg { width:17px; height:17px; flex:none; }.cc-category-count { margin-left:auto; font-size:11px; opacity:.65; }
|
||||
.cc-categories button:hover { background:#66c0f40a; color:white; }.cc-categories button[aria-pressed=true] { background:#1a9fff1a; color:var(--link); box-shadow:inset 2px 0 var(--blue); }
|
||||
.cc-status { margin-top:24px; padding:18px 14px; border-top:1px solid #ffffff10; }.cc-status > div { display:flex; align-items:center; gap:8px; font-size:12px; color:#c4d2dc; }.cc-status p { margin:10px 0 0; color:var(--muted); font-size:11.5px; line-height:1.6; }
|
||||
.cc-search-row { display:flex; align-items:center; gap:14px; margin-bottom:18px; }.cc-search { display:flex; align-items:center; gap:10px; border:1px solid #66c0f426; border-radius:8px; padding:0 12px; background:#0e1824; flex:1; min-width:0; }
|
||||
.cc-search:focus-within { border-color:var(--blue); }.cc-search svg { width:18px; color:var(--muted); flex:none; }.cc-search input { width:100%; min-width:0; padding:13px 0; border:0; background:transparent; font-size:13px; }.cc-search input:focus { outline:none; }.cc-search button { padding:3px 7px; background:transparent; color:var(--muted); font-size:18px; }.cc-results { color:var(--muted); font-size:11px; white-space:nowrap; }
|
||||
.cc-cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }.cc-card { display:flex; flex-direction:column; min-width:0; padding:22px; background:linear-gradient(150deg,#202e3d,#17212c); border:1px solid #ffffff0e; border-radius:10px; }
|
||||
.cc-card-head { display:flex; gap:10px; align-items:center; justify-content:space-between; margin-bottom:16px; }.cc-icon { width:34px; height:34px; display:grid; place-items:center; background:#66c0f40c; border-radius:8px; color:#85bfe2; }.cc-icon svg { width:20px; height:20px; }
|
||||
.cc-kind { font-size:10px; padding:3px 7px; border-radius:4px; color:#a2b3c2; background:#ffffff06; letter-spacing:.2px; }.cc-kind-control { color:#92cdeb; background:#1a9fff12; }.cc-kind-headset { color:#d3c095; background:#d9a23a0b; }.cc-kind-read { color:#9fceb9; background:#59bf4009; }
|
||||
.cc-card h2 { font-size:17px; font-weight:500; color:white; margin:0 0 8px; }.cc-card > p { font-size:12.5px; color:#a4b4c3; line-height:1.65; margin:0 0 14px; }
|
||||
.cc-guidance { padding:11px 12px; border-left:2px solid #9e876544; border-radius:0 4px 4px 0; background:#ffffff03; color:#bcb7aa; font-size:11.5px; line-height:1.6; margin-bottom:14px; }
|
||||
.cc-links { display:flex; flex-wrap:wrap; gap:10px 18px; margin-top:auto; padding-top:8px; }.cc-links a { display:inline-flex; gap:8px; align-items:center; color:var(--link); font-size:12px; text-decoration:none; }.cc-links a:hover { color:white; text-decoration:underline; }.cc-links a span { font-size:14px; opacity:.65; }
|
||||
.cc-note { font-size:11px; line-height:1.6; color:var(--muted); }.cc-card p.cc-note { margin:10px 0 0; }.cc-audio label { display:flex; justify-content:space-between; font-size:12px; color:#b7c8d6; }.cc-audio output { color:var(--link); font-variant-numeric:tabular-nums; }.cc-audio input { width:100%; margin:15px 0; }.cc-audio-actions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }.cc-audio-actions button { border-radius:5px; }.cc-audio-actions span { flex-basis:100%; margin-top:4px; }.cc-audio input:disabled { opacity:.35; }
|
||||
.cc-empty { padding:40px 24px; border:1px dashed #66c0f433; border-radius:10px; text-align:center; color:var(--muted); }.cc-empty h2 { color:white; font-size:18px; font-weight:500; }.cc-empty button { margin-top:12px; border-radius:5px; }
|
||||
@media (min-width:641px) and (max-width:1250px) { .brand b { display:none; } header { gap:14px; } nav a { padding:0 9px; font-size:13px; } .pill { min-width:120px; } }
|
||||
@media (min-width:641px) and (max-width:900px) { html { scroll-padding-top:116px; } header { flex-wrap:wrap; height:auto; min-height:64px; padding-top:8px; padding-bottom:8px; gap:6px 12px; } nav { order:3; width:100%; height:36px; } nav a { flex:1; justify-content:center; padding:0 6px; } .hdr-end { flex:1; justify-content:flex-end; } .cc-sidebar { top:115px; } }
|
||||
@media (max-width:1100px) { .cc-workspace { grid-template-columns:185px minmax(0,1fr); gap:18px; }.cc-card { padding:18px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(3,minmax(0,1fr)); } }
|
||||
@media (max-width:900px) { .cc-workspace { grid-template-columns:minmax(0,1fr); }.cc-sidebar { position:static; }.cc-sidebar > p,.cc-status { display:none; }.cc-categories { flex-direction:row; overflow-x:auto; padding-bottom:6px; }.cc-categories button { flex:none; white-space:nowrap; }.cc-category-count { margin-left:2px; }.cc-categories button[aria-pressed=true] { box-shadow:inset 0 -2px var(--blue); }.cc-summary { gap:10px; }.cc-metric { padding:15px; } }
|
||||
@media (max-width:640px) { .cc { gap:20px; }.cc-hero { padding:22px 20px; }.cc-hero p { font-size:12px; }.cc-hero-mark { width:72px; height:55px; }.cc-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-metric > strong { font-size:24px; }.cc-topline { gap:8px; }.cc-sample { order:3; width:100%; }.cc-device-facts { padding:14px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-cards { grid-template-columns:minmax(0,1fr); gap:12px; }.cc-search-row { gap:8px; }.cc-results { font-size:10px; }.cc-card { padding:20px; }.cc-card h2 { font-size:18px; } }
|
||||
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-links a { min-height:44px; }.cc-audio-actions button,.cc-topline button { min-height:44px; }.cc-search button { min-width:36px; min-height:44px; } }
|
||||
@@ -0,0 +1,318 @@
|
||||
/* The Control Center uses the shared page's guarded api() and status poll.
|
||||
* Browsing never starts a stream, installs a helper or changes the headset. */
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.FrameControlCenter = factory();
|
||||
})(typeof window === 'object' ? window : globalThis, function () {
|
||||
'use strict';
|
||||
const categories = [
|
||||
['all', 'Everything', 'grid'], ['device', 'Device & audio', 'device'],
|
||||
['vr', 'VR & comfort', 'eye'], ['apps', 'Apps & files', 'apps'],
|
||||
['network', 'Connection & remote', 'network'], ['companion', 'Frame Control', 'settings'],
|
||||
];
|
||||
// Each destination is an existing workflow, with its own guards and confirmations.
|
||||
const catalog = [
|
||||
{id:'audio', category:'device', icon:'audio', title:'Audio', kind:'control', label:'Available here',
|
||||
description:'Set the default headset output volume or mute it. Changes apply only when you choose Apply or Mute.',
|
||||
keywords:'sound speakers headphones loud quiet', special:'audio'},
|
||||
{id:'health', category:'device', icon:'pulse', title:'Battery & thermal health', kind:'read', label:'Read-only metrics',
|
||||
description:'Inspect charge, signed battery power, charger capability, battery temperature and health. Heat warnings use the kernel’s own hot and critical trip points.',
|
||||
keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
|
||||
{id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
|
||||
description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
|
||||
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#apps','Linux apps'],['#power','SFTP & remote tools']]},
|
||||
{id:'microphone', category:'device', icon:'audio', title:'Microphone & audio routing', kind:'headset', label:'In the headset',
|
||||
description:'Frame Control controls the default output volume only. Microphone mute, input gain, output selection, balance and per-app mixers have no verified API here.',
|
||||
keywords:'mic input microphone audio sink output device gain mute eq equalizer spatial audio balance headphones',
|
||||
guidance:'Check the headset’s audio controls or the app’s own audio settings for these adjustments.'},
|
||||
{id:'power', category:'device', icon:'power', title:'Power & sleep', kind:'flow', label:'Existing controls',
|
||||
description:'Sleep, restart or shut down with confirmation and your Developer Mode password. A sleeping Frame must be woken on the device.',
|
||||
keywords:'battery suspend reboot off awake idle timeout keep awake inhibitor', links:[['#power','Power controls']],
|
||||
guidance:'Set automatic sleep in the headset: Steam Settings → Power → Sleep after inactivity. Keep-awake is available through the existing script/MCP tools; there is no switch here.'},
|
||||
{id:'system', category:'device', icon:'device', title:'SteamOS & hardware', kind:'headset', label:'In the headset',
|
||||
description:'Check your current OS build above. Manage system updates, Developer Mode and hardware settings in Steam Settings on the Frame.',
|
||||
keywords:'firmware update beta stable channel developer password bluetooth controller battery pairing calibration brightness refresh rate display IPD lenses passthrough camera eye hand tracking foveation recovery factory reset',
|
||||
guidance:'Brightness, refresh rate, optics, controllers, Bluetooth and passthrough have no verified remote controls here. Check the headset’s own controls. Recovery and factory reset are manual procedures.'},
|
||||
{id:'performance', category:'vr', icon:'pulse', title:'Performance monitor', kind:'read', label:'Read-only metrics',
|
||||
description:'CPU, GPU clock, app and compositor timings come from the Frame. Unavailable timings stay empty, including when SteamVR is in standby.',
|
||||
keywords:'fps frame rate gpu cpu heat temperature thermal sensor render memory', special:'performance', links:[['#vrTools','HUD & performance tools']]},
|
||||
{id:'view', category:'vr', icon:'eye', title:'Headset view & casting', kind:'flow', label:'Existing controls',
|
||||
description:'Capture the lenses or desktop, stream live video, and control a focused panel. Casting can reveal private content.',
|
||||
keywords:'screenshot camera video capture live stream desktop mirror control eye', links:[['#view','Open headset view'],['#shots','Screenshots']]},
|
||||
{id:'comfort', category:'vr', icon:'clock', title:'Family & comfort', kind:'flow', label:'Existing controls',
|
||||
description:'Set a session limit, breaks and check-ins, with battery and heat alerts. Headset reminders continue if your companion disconnects.',
|
||||
keywords:'parent child timer minutes notifications health break low battery', links:[['#comfort','Configure a session']],
|
||||
guidance:'A reminder, not a parental lock. Save and pause before the session returns to Steam Home.'},
|
||||
{id:'playspace', category:'vr', icon:'space', title:'Playspace & movement', kind:'headset', label:'In the headset',
|
||||
description:'Use SteamVR’s recenter and room setup. Choose seated play, snap-turn, teleport and vignettes in each game’s own settings.',
|
||||
keywords:'boundary guardian tracking origin floor standing seated motion sickness locomotion turn recenter room',
|
||||
guidance:'No verified universal locomotion switch or remote tracking-origin control is available.'},
|
||||
{id:'panels', category:'vr', icon:'grid', title:'Panels & workspace', kind:'flow', label:'Existing controls',
|
||||
description:'Find an open panel and bring it forward, or open the panel switcher in the headset. Move, size and dock panels with SteamVR’s dashboard.',
|
||||
keywords:'overlay window layout float world curvature docking theatre multitasking', links:[['#panels','Open panel switcher']],
|
||||
guidance:'Saved spatial layouts are not available. Panel placement stays in SteamVR.'},
|
||||
{id:'steam', category:'apps', icon:'game', title:'Steam library', kind:'flow', label:'Existing controls',
|
||||
description:'Browse owned games and Frame ratings, install with progress, or launch an installed game. Purchases and license prompts use Steam.',
|
||||
keywords:'games download install store owned compatibility rating verified proton', links:[['#library','Installed games'],['#getgames','Library & store']]},
|
||||
{id:'android', category:'apps', icon:'apps', title:'Android apps', kind:'flow', label:'Existing controls',
|
||||
description:'Discover apps, manage Lepton instances, and launch, stop, test or remove installed apps. View compatibility reports before installing.',
|
||||
keywords:'apk fdroid lepton application sideload sources repository reports', links:[['#appStore','Discover apps'],['#androidLibrary','Manage installed apps']]},
|
||||
{id:'sources', category:'apps', icon:'shield', title:'App sources & compatibility', kind:'flow', label:'Existing controls',
|
||||
description:'Choose catalogue sources or add a signed F-Droid repository from Sources. Review install availability and compatibility reports for the exact app.',
|
||||
keywords:'fdroid f-droid repository source fingerprint trust signature compatibility report apk sidequest data backup restore', links:[['#appStore','Catalogue & sources'],['#androidLibrary','Apps & reports']],
|
||||
guidance:'Android data backup and restore are script-only. This interface does not provide an app-data recovery workflow.'},
|
||||
{id:'display', category:'apps', icon:'display', title:'Android display & text', kind:'flow', label:'Existing controls',
|
||||
description:'Choose a running Android instance, then set resolution, UI density and text size, or reset to its defaults.',
|
||||
keywords:'dpi scaling font scale sharp native 1920 1080 2560 1440 resolution screen text accessibility', links:[['#display','Configure app display']],
|
||||
guidance:'Applies to that Lepton instance, not the headset’s lenses. Persistence after relaunch is unverified.'},
|
||||
{id:'sideload', category:'apps', icon:'game', title:'Sideloaded games', kind:'flow', label:'Existing controls',
|
||||
description:'Send a game folder, ZIP or executable. Frame Control selects Proton or the Linux runtime and adds a Steam library entry.',
|
||||
keywords:'windows exe zip linux devkit runtime shortcuts launch remove', links:[['#sideloaded','Manage titles'],['#transfer','Send a game']]},
|
||||
{id:'files', category:'apps', icon:'files', title:'Files & clipboard', kind:'flow', label:'Existing controls',
|
||||
description:'Send files to ~/Downloads, or send text to the Frame’s clipboard. Clipboard transfer needs the desktop panel open.',
|
||||
keywords:'upload transfer downloads paste copy text sftp storage', links:[['#transfer','Send files & text']]},
|
||||
{id:'media', category:'apps', icon:'play', title:'Video & stereo photos', kind:'flow', label:'Preview feature',
|
||||
description:'Send and play H.264/H.265 video, SBS/OU photos or small splats with the built-in player. Choose a layout and theatre surround.',
|
||||
keywords:'movie photo sbs ou 3d splat gaussian theatre media player', links:[['#media','Open media player']],
|
||||
guidance:'Seeking, subtitles, native HEIC/MPO and large splat scenes are not supported yet.'},
|
||||
{id:'flatpak', category:'apps', icon:'apps', title:'Linux desktop apps', kind:'flow', label:'Existing controls',
|
||||
description:'Install user Flatpaks by Flathub ID, choose quick picks like VLC and Moonlight, and manage installed apps.',
|
||||
keywords:'flatpak firefox vlc remmina moonlight flathub install remove', links:[['#apps','Manage Linux apps']]},
|
||||
{id:'connection', category:'network', icon:'network', title:'Headsets & addresses', kind:'flow', label:'Existing controls',
|
||||
description:'Manage headsets, LAN, USB-C, mDNS and Tailscale addresses. Inspect connection attempts and choose the device you want to control.',
|
||||
keywords:'wifi wi-fi network ip ssh pairing keys discovery alias tailscale usb offline reconnect', links:[['#devices','Manage headsets','desktop'],['#connection','Connection details']],
|
||||
guidance:'Join Wi-Fi networks on the headset. Frame Control manages connection addresses; it does not change Wi-Fi credentials.'},
|
||||
{id:'remote', category:'network', icon:'display', title:'Remote access', kind:'flow', label:'Existing controls',
|
||||
description:'Open SSH, SFTP, Steam Link or remote desktop using the host’s existing tools. Developer Mode enables headset SSH and xrdp.',
|
||||
keywords:'terminal shell remote desktop rdp xrdp streaming steam link sftp', links:[['#power','Open remote tools']]},
|
||||
{id:'input', category:'network', icon:'keyboard', title:'Keyboard & trackpad', kind:'flow', label:'Existing controls',
|
||||
description:'Type and point in the Frame’s apps with KDE Connect, or use Control on the live panel view. First use sets up the input helper.',
|
||||
keywords:'mouse pointer keyboard trackpad touch click kde input', links:[['#pad','Open keyboard & trackpad'],['#view','Panel control']]},
|
||||
{id:'mac', category:'network', icon:'display', title:'Mac in the headset', kind:'flow', label:'macOS only', platform:'mac',
|
||||
description:'Stream Mac screens or individual windows into headset panels. Choose quality and give each window its own display.',
|
||||
keywords:'macos mac window screen sharing stream quality sharp balanced light permissions', links:[['#macview','Configure Mac streaming']],
|
||||
guidance:'Requires Frame Control running on a Mac. It is unavailable when the server runs on Windows, Linux or the headset.'},
|
||||
{id:'privacy', category:'companion', icon:'shield', title:'Privacy & sharing', kind:'flow', label:'Companion settings',
|
||||
description:'Choose anonymous usage statistics, compatibility sharing and error details separately. Inspect what was sent and manage your optional contact email.',
|
||||
keywords:'telemetry analytics diagnostics consent data tracking email contact opt out', links:[['#privacy','Privacy settings']]},
|
||||
{id:'artwork', category:'companion', icon:'image', title:'Library artwork', kind:'flow', label:'Companion settings',
|
||||
description:'Configure an optional SteamGridDB key or refresh artwork. Without a key, source images and generated art remain available.',
|
||||
keywords:'cover image steamgriddb api key library art refresh', links:[['#artworkSettings','Artwork settings']]},
|
||||
{id:'assistant', category:'companion', icon:'spark', title:'Assistant & agents', kind:'flow', label:'Opt-in tools',
|
||||
description:'Use your own model endpoint and review proposed actions. Nothing goes to a model until you opt in; protected actions need approval.',
|
||||
keywords:'ai mcp model endpoint agent approvals chat assistant', links:[['/assistant','Open assistant']]},
|
||||
{id:'support', category:'companion', icon:'help', title:'Updates & diagnostics', kind:'flow', label:'Companion settings',
|
||||
description:'Check Frame Control updates in the desktop app or prepare a private problem report. Review the diagnostic preview before sending.',
|
||||
keywords:'app version bug feedback support update log error report licenses', links:[['#privacy','Updates & problem reports']]},
|
||||
];
|
||||
const paths = {
|
||||
grid:'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
|
||||
device:'M3 7h18v10H3z M8 11v2 M16 11v2', eye:'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0',
|
||||
apps:'M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z M17 14v6 M14 17h6',
|
||||
network:'M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01',
|
||||
settings:'M4 6h16 M4 12h16 M4 18h16 M8 3v6 M16 9v6 M10 15v6',
|
||||
audio:'M3 9h4l5-4v14l-5-4H3z M16 8a6 6 0 0 1 0 8 M19 5a10 10 0 0 1 0 14',
|
||||
power:'M12 2v10 M6 5a9 9 0 1 0 12 0', pulse:'M2 12h5l3-8 4 16 3-8h5',
|
||||
clock:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2',
|
||||
space:'M3 8l9-5 9 5v8l-9 5-9-5z M3 8l9 5 9-5 M12 13v8',
|
||||
game:'M6 8h12l3 9-3 2-4-4h-4l-4 4-3-2z M7 10v4 M5 12h4 M16 11h.01 M18 13h.01',
|
||||
display:'M3 4h18v13H3z M8 21h8 M12 17v4', files:'M3 5h7l2 3h9v12H3z',
|
||||
play:'M8 4l12 8-12 8z', keyboard:'M2 6h20v12H2z M6 10h.01 M10 10h.01 M14 10h.01 M18 10h.01 M6 14h12',
|
||||
shield:'M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6z M8 12l3 3 5-6',
|
||||
image:'M3 3h18v18H3z M3 17l6-6 5 5 3-3 4 4 M16 7h.01',
|
||||
spark:'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3z',
|
||||
help:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M9 8a3 3 0 0 1 6 1c0 2-3 2-3 5 M12 17h.01',
|
||||
};
|
||||
const icon = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[name] || paths.settings}"/></svg>`;
|
||||
function matches(item, query, category) {
|
||||
const text = [item.title, item.description, item.keywords, item.guidance, item.label,
|
||||
categories.find(([id])=>id === item.category)?.[1]].join(' ').toLowerCase();
|
||||
return (category === 'all' || item.category === category) && query.toLowerCase().trim().split(/\s+/).every(word => text.includes(word));
|
||||
}
|
||||
const number = (value, suffix = '', digits = 0) => Number.isFinite(value) ? value.toLocaleString(undefined, {maximumFractionDigits:digits}) + suffix : 'Unavailable';
|
||||
const gib = value => Number.isFinite(value) ? number(value / 1073741824, ' GiB', 1) : 'Unavailable';
|
||||
const duration = seconds => Number.isFinite(seconds) && seconds >= 0 ? seconds < 3600 ? `${Math.floor(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.floor(seconds % 3600 / 60)} min` : 'Unavailable';
|
||||
function metrics(sample) {
|
||||
const s = sample || {}, b = s.battery || {}, p = s.performance || {}, h = s.disk?.home, m = s.memory;
|
||||
const capacity = b.percent;
|
||||
const idle = !!s.power && Number.isFinite(b.watts) && Math.abs(b.watts) < .5;
|
||||
const charging = b.status === 'Charging';
|
||||
const full = b.status === 'Full' || (!!s.power && capacity >= 100);
|
||||
const batteryState = !sample ? 'Connect to read charge and power' : !s.battery ? 'Battery sensor unavailable' :
|
||||
full ? 'Fully charged' : charging ? 'Charging' : idle ? 'Plugged in · not charging' : s.power ? 'Plugged in · on battery' : 'On battery';
|
||||
return {
|
||||
battery:number(capacity, '%'), batteryDetail:batteryState,
|
||||
storage:h ? gib(h.free) : 'Unavailable', storageDetail:h ? `${gib(h.total)} total · user storage` : 'User storage unavailable',
|
||||
memory:m && Number.isFinite(m.total) && Number.isFinite(m.available) ? gib(m.total - m.available) : 'Unavailable',
|
||||
memoryDetail:m ? `${gib(m.total)} total · system memory used` : 'Memory sensor unavailable',
|
||||
rootFree:gib(s.disk?.root?.free), rootTotal:gib(s.disk?.root?.total), memoryAvailable:gib(m?.available),
|
||||
fps:number(p.compositorFps, '', 1), fpsDetail:'Compositor FPS · not game FPS',
|
||||
power:number(b.watts, ' W', 1), charger:s.power ? `${s.power.type || 'Connected'} · ${number(s.power.watts, ' W', 1)}` : sample ? 'No charger reported' : 'Unavailable',
|
||||
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health || 'Unavailable',
|
||||
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : !full && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
|
||||
wifi:s.wifi?.ssid || 'Unavailable', signal:number(s.wifi?.signal, '%'), ip:s.ip || 'Unavailable',
|
||||
uptime:duration(s.uptime), os:s.os?.version || 'Unavailable', build:s.os?.build || 'Unavailable',
|
||||
temperature:number(s.temp, ' °C', 1), cpu:number(p.cpuPercent, '%', 1), gpu:number(p.gpuMHz, ' MHz', 1),
|
||||
appFps:number(p.appFps, '', 1), frame:number(p.frameMs, ' ms', 2), render:number(p.gpuMs, ' ms', 2), compositorCpu:number(p.compositorCpuMs, ' ms', 2),
|
||||
thermal:!Array.isArray(s.thermalAlerts) ? 'Trip data unavailable' : s.thermalAlerts.length ? s.thermalAlerts.map(a => `${a.zone || 'Sensor'} ${number(a.tempC, ' °C')} ≥ ${number(a.limitC, ' °C')}`).join('; ') : 'No hot / critical trip reported',
|
||||
};
|
||||
}
|
||||
// Strict validation and captured device identity apply even if a stale enabled
|
||||
// button is clicked during a disconnect, headset switch, or pending request.
|
||||
function createController({api, getContext}) {
|
||||
let busy = false;
|
||||
return {
|
||||
get busy() { return busy; },
|
||||
async audio(change) {
|
||||
if (busy) throw new Error('An audio change is still in progress.');
|
||||
const context = getContext();
|
||||
if (context.online !== true || !context.fresh || !context.sample?.volume) throw new Error('Connect and refresh the Frame before changing audio.');
|
||||
const keys = Object.keys(change);
|
||||
if (!keys.length || keys.some(k => !['level','muted'].includes(k)) ||
|
||||
('level' in change && (typeof change.level !== 'number' || !Number.isFinite(change.level) || change.level < 0 || change.level > 1)) ||
|
||||
('muted' in change && typeof change.muted !== 'boolean')) throw new Error('Use a volume from 0 to 100% and a valid mute state.');
|
||||
busy = true;
|
||||
try {
|
||||
const result = await api('/api/volume', change, context.device);
|
||||
if (getContext().generation !== context.generation) return null;
|
||||
return result;
|
||||
} finally { busy = false; }
|
||||
},
|
||||
};
|
||||
}
|
||||
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform}) {
|
||||
const get = id => document.getElementById(id), page = get('controlCenter');
|
||||
let category = 'all', sample = null, receivedAt = null, available = false, error = '', dirty = false, sampleGeneration = null, audioPending = false;
|
||||
const metric = (id, label) => `<div><dt>${label}</dt><dd data-cc-metric="${id}">Unavailable</dd></div>`;
|
||||
const perf = `<dl class="cc-readings">${[['appFps','Application FPS'],['frame','Compositor period'],['render','Render GPU time'],['compositorCpu','Compositor CPU'],['cpu','System CPU'],['gpu','GPU clock'],['temperature','Hottest sensor']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.</p><p class="cc-note" data-cc-metric="thermal">Trip data unavailable</p>`;
|
||||
const audio = `<div class="cc-audio"><label for="ccVolume">Output volume <output id="ccVolumeValue" for="ccVolume">—</output></label><input id="ccVolume" type="range" min="0" max="100" step="1" value="0" disabled><div class="cc-audio-actions"><button class="action small" id="ccApplyVolume" disabled>Apply volume</button><button class="small" id="ccMute" disabled>Mute</button><span id="ccAudioReadback" class="cc-note">Waiting for an audio reading</span></div><p class="cc-note" id="ccAudioResult" role="status"></p></div>`;
|
||||
const health = `<dl class="cc-readings">${[['battery','Charge'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
|
||||
const storage = `<dl class="cc-readings">${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
|
||||
get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>`<button type="button" data-cc-category="${id}" aria-pressed="${id === 'all'}">${icon(symbol)}<span>${label}</span><span class="cc-category-count"></span></button>`).join('');
|
||||
const specials = {audio, performance:perf, health, storage};
|
||||
get('ccCards').innerHTML = catalog.map(item=>`<section class="cc-card" data-cc-card="${item.id}"><div class="cc-card-head"><span class="cc-icon">${icon(item.icon)}</span><span class="cc-kind cc-kind-${item.kind}">${item.label}</span></div><h2>${item.title}</h2><p>${item.description}</p>${specials[item.special] || ''}${item.guidance ? `<div class="cc-guidance">${item.guidance}</div>` : ''}${item.links ? `<div class="cc-links">${item.links.map(([href,label,desktop])=>`<a href="${href}"${desktop ? ' class="desk-only"' : ''}${item.platform === 'mac' ? ' data-cc-mac-link' : ''}>${label}<span aria-hidden="true">↗</span></a>`).join('')}</div>` : ''}</section>`).join('');
|
||||
// Host-only features stay searchable with a reason; only their unsupported
|
||||
// action disappears. The host API, rather than viewport/UA, decides availability.
|
||||
let macSupported = platform === 'darwin';
|
||||
const visibleCatalog = catalog;
|
||||
function filter() {
|
||||
const query = get('ccSearch').value;
|
||||
let count = 0;
|
||||
catalog.forEach(item => {
|
||||
const visible = visibleCatalog.includes(item) && matches(item, query, category);
|
||||
page.querySelector(`[data-cc-card="${item.id}"]`).hidden = !visible;
|
||||
if (visible) count++;
|
||||
});
|
||||
get('ccResultCount').textContent = `${count} ${count === 1 ? 'section' : 'sections'}`;
|
||||
get('ccEmpty').hidden = count > 0;
|
||||
get('ccClear').hidden = !query;
|
||||
page.querySelectorAll('[data-cc-category]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.ccCategory === category));
|
||||
button.querySelector('.cc-category-count').textContent = visibleCatalog.filter(item=>matches(item, query, button.dataset.ccCategory)).length;
|
||||
});
|
||||
page.querySelectorAll('[data-cc-mac-link]').forEach(link=>{link.hidden = !macSupported;});
|
||||
}
|
||||
get('ccSearch').addEventListener('input', filter);
|
||||
function resetFilter() { get('ccSearch').value = ''; category = 'all'; filter(); get('ccSearch').focus(); }
|
||||
get('ccClear').onclick = () => { get('ccSearch').value = ''; filter(); get('ccSearch').focus(); };
|
||||
get('ccResetFilter').onclick = resetFilter;
|
||||
get('ccCategories').onclick = event => {
|
||||
const button = event.target.closest('[data-cc-category]');
|
||||
if (!button) return;
|
||||
category = button.dataset.ccCategory; filter();
|
||||
};
|
||||
page.querySelector('[href="#connection"]').onclick = event => { event.preventDefault(); openConnection(); };
|
||||
// The headset clock can differ from this client's. Age uses the client
|
||||
// receipt time, retained by the shared poll when it buffers an early sample.
|
||||
const isFresh = () => available && Number.isFinite(receivedAt) && Date.now() >= receivedAt && Date.now() - receivedAt <= 90000;
|
||||
const context = () => ({...getContext(), sample, fresh:isFresh() && sampleGeneration === getContext().generation});
|
||||
const controller = createController({api, getContext:context});
|
||||
function audioAvailability() {
|
||||
const enabled = context().online === true && context().fresh && !!sample?.volume && !controller.busy && !audioPending;
|
||||
['ccVolume','ccApplyVolume','ccMute'].forEach(id=>{get(id).disabled = !enabled;});
|
||||
}
|
||||
function renderSample() {
|
||||
const values = metrics(isFresh() ? sample : null);
|
||||
page.querySelectorAll('[data-cc-metric]').forEach(el=>{el.textContent = values[el.dataset.ccMetric];});
|
||||
get('ccDeviceName').textContent = isFresh() ? sample.hostname || 'Steam Frame' : 'Steam Frame';
|
||||
const sampledAt = Number.isFinite(sample?.time) && sample.time > 0 && sample.time * 1000 <= 8640000000000000 ? new Date(sample.time * 1000) : null;
|
||||
get('ccSampleState').textContent = isFresh() ? `${sampledAt ? 'Sampled ' + sampledAt.toLocaleTimeString() : 'Received ' + new Date(receivedAt).toLocaleTimeString()} · updates every 30 seconds` :
|
||||
error || (receivedAt !== null ? 'Sample expired · refresh to read current metrics' : 'Connect your Frame to read live metrics');
|
||||
get('ccSampleState').classList.toggle('cc-offline', !isFresh());
|
||||
get('ccStatusLabel').textContent = isFresh() ? 'Connected' : 'No live sample';
|
||||
get('ccStatusDot').classList.toggle('on', isFresh());
|
||||
get('ccStatusNote').textContent = isFresh() ? 'Read-only status over SSH. Browse freely; controls run only when you use them.' :
|
||||
'Settings guides and companion tools remain available. Wake the Frame on the device, then reconnect.';
|
||||
get('ccBuild').textContent = isFresh() ? `SteamOS ${values.os} · build ${values.build}` : 'System information unavailable';
|
||||
const services = isFresh() ? sample.services || {} : {};
|
||||
get('ccServices').innerHTML = [['steamvr','SteamVR'],['desktop','Desktop'],['lepton','Lepton · port 5555'],['rdp','RDP process']].map(([key,label])=>`<span><i class="dot ${services[key] === true ? 'on' : ''}"></i>${label}<b>${typeof services[key] === 'boolean' ? services[key] ? 'Running' : 'Not detected' : 'Unavailable'}</b></span>`).join('');
|
||||
if (sample?.volume && isFresh()) {
|
||||
const pct = Math.round(sample.volume.level * 100);
|
||||
if (!dirty && !controller.busy && !audioPending) get('ccVolume').value = Math.min(100,Math.max(0,pct));
|
||||
get('ccVolumeValue').textContent = `${get('ccVolume').value}%`;
|
||||
get('ccAudioReadback').textContent = `Reported ${pct}% · ${sample.volume.muted ? 'muted' : 'unmuted'}`;
|
||||
get('ccMute').textContent = sample.volume.muted ? 'Unmute' : 'Mute';
|
||||
} else {
|
||||
get('ccAudioReadback').textContent = isFresh() ? 'Audio reading unavailable' : 'Waiting for an audio reading';
|
||||
get('ccVolumeValue').textContent = '—';
|
||||
}
|
||||
audioAvailability();
|
||||
}
|
||||
async function changeAudio(change) {
|
||||
if (audioPending) return;
|
||||
const generation = getContext().generation;
|
||||
audioPending = true;
|
||||
get('ccAudioResult').textContent = 'Applying…';
|
||||
try {
|
||||
const pending = controller.audio(change);
|
||||
audioAvailability();
|
||||
const result = await pending;
|
||||
if (!result || generation !== getContext().generation) return;
|
||||
if ('level' in change) dirty = false;
|
||||
get('ccAudioResult').textContent = `${result.message || 'Audio updated'}. Refreshing the reading…`;
|
||||
await refreshAudio();
|
||||
if (generation !== getContext().generation) return;
|
||||
if (!isFresh() || !sample?.volume) {
|
||||
get('ccAudioResult').textContent = 'Audio command sent; current output could not be confirmed. Reconnect and refresh.';
|
||||
} else {
|
||||
const reported = sample.volume;
|
||||
const matched = (!('level' in change) || Math.round(reported.level * 100) === Math.round(change.level * 100)) &&
|
||||
(!('muted' in change) || reported.muted === change.muted);
|
||||
get('ccAudioResult').textContent = `${matched ? 'Confirmed' : 'Command sent · reported output differs'}: ${Math.round(reported.level * 100)}%, ${reported.muted ? 'muted' : 'unmuted'}.${matched ? '' : ' Refresh to check again.'}`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (generation !== getContext().generation) return;
|
||||
get('ccAudioResult').textContent = `Couldn’t apply audio: ${e.message}`;
|
||||
} finally { audioPending = false; renderSample(); }
|
||||
}
|
||||
get('ccVolume').oninput = () => { dirty = true; get('ccVolumeValue').textContent = `${get('ccVolume').value}%`; };
|
||||
get('ccApplyVolume').onclick = () => changeAudio({level:Number(get('ccVolume').value) / 100});
|
||||
get('ccMute').onclick = () => changeAudio({muted:!sample?.volume?.muted});
|
||||
get('ccRefresh').onclick = async () => {
|
||||
get('ccRefresh').disabled = true;
|
||||
try { await refresh(); } finally { get('ccRefresh').disabled = false; }
|
||||
};
|
||||
filter(); renderSample();
|
||||
// Expire UI readings even when the shared poll is paused in a hidden tab.
|
||||
const timer = setInterval(()=>{if (receivedAt !== null && available && !isFresh()) { available = false; renderSample(); }}, 5000);
|
||||
return {
|
||||
host(value) { macSupported = !value.mobile && value.os === 'macOS'; filter(); },
|
||||
status(value, receipt = Date.now(), read = {valid: true, error: null}) {
|
||||
// A redraw is not a successful read: preserve receipt age and any local
|
||||
// invalidation. Shared validity also carries failures across late mount.
|
||||
const replay = sample === value;
|
||||
receivedAt = replay ? (Number.isFinite(receivedAt) && Number.isFinite(receipt) ? Math.min(receivedAt, receipt) : receivedAt) : receipt;
|
||||
sample = value;
|
||||
if (!read.valid) {
|
||||
available = false; error = `No live sample${read.error?.message ? ' · ' + read.error.message : ''}`;
|
||||
} else if (!replay) { available = true; error = ''; }
|
||||
sampleGeneration = getContext().generation; renderSample();
|
||||
},
|
||||
unavailable(reason) { if (reason?.stale) return; available = false; error = `No live sample${reason?.message ? ' · ' + reason.message : ''}`; renderSample(); },
|
||||
connection(ok) { if (!ok) { available = false; renderSample(); } else audioAvailability(); },
|
||||
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; renderSample(); },
|
||||
destroy() { clearInterval(timer); },
|
||||
};
|
||||
}
|
||||
return {catalog, categories, matches, metrics, createController, mount};
|
||||
});
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
Two separate opt-in choices, both off until ticked:
|
||||
|
||||
- updates: occasional notices about Frame Control releases and updates
|
||||
- updates: the mailing list, occasional emails about new features and updates
|
||||
- followup: the maintainer may ask follow-up questions, mainly about problem reports
|
||||
|
||||
The address and the choices are kept on this computer (frame_host.data_dir('contact')) and
|
||||
@@ -97,12 +97,12 @@ def from_report(email):
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet,
|
||||
and the Frame has connected at least once (setup worked), so it never greets a new install."""
|
||||
"""What the page shows. showPrompt: the one-time mailing list offer hasn't been shown or
|
||||
answered yet. It's offered on first launch (the page shows it after the first-run privacy
|
||||
notice is closed, never both at once), and works whether or not the Frame is set up."""
|
||||
s = load()
|
||||
set_up = bool(frame_telemetry.settings().get('frames_seen'))
|
||||
return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'],
|
||||
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up}
|
||||
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new'}
|
||||
|
||||
|
||||
def _event(s):
|
||||
|
||||
@@ -302,6 +302,7 @@ def status():
|
||||
"disk": {"root": disk("/"), "home": disk("/home")},
|
||||
"memory": memory(),
|
||||
"temp": max_temp(),
|
||||
"thermalAlerts": thermal_alerts(),
|
||||
"wifi": wifi(),
|
||||
"ip": ip_addr(),
|
||||
"volume": volume(),
|
||||
|
||||
+42
-12
@@ -18,7 +18,9 @@ Report a problem dialog (frame_report.py) sends bug reports privately to the
|
||||
same project whatever is chosen here.
|
||||
|
||||
Events are identified by a random id made on first run, not by the person or
|
||||
computer, and sent without person profiles or GeoIP. Nothing is sent without a
|
||||
computer, and sent without person profiles or GeoIP. Opens, installs and updates
|
||||
carry a country instead, worked out on this computer from its time zone setting
|
||||
(frame_timezones.py), to see roughly where Frame Control is used. Nothing is sent without a
|
||||
project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source
|
||||
checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or
|
||||
$FRAME_CONTROL_TELEMETRY=0.
|
||||
@@ -43,6 +45,7 @@ from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import frame_host
|
||||
import frame_timezones
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
STATE = frame_host.data_dir('telemetry')
|
||||
@@ -65,10 +68,11 @@ EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up', 'frame_auth')
|
||||
DEFAULT_HOST = 'https://us.i.posthog.com'
|
||||
|
||||
LEVELS = ('usage', 'compat', 'diagnostics')
|
||||
NOTICE_VERSION = 2 # raise when the notice lists something new: it's shown again (2: country)
|
||||
# Events the page may send through /api/telemetry, and the properties each may carry.
|
||||
PAGE_EVENTS = {'tab_viewed': {'tab'}, 'update_offered': {'to_version'},
|
||||
'update_started': {'to_version'}, 'update_failed': {'to_version', 'error_category'}}
|
||||
TABS = {'home', 'games', 'android', 'tools'}
|
||||
TABS = {'home', 'games', 'android', 'tools', 'devices', 'control'}
|
||||
|
||||
_lock = threading.RLock()
|
||||
_send_lock = threading.Lock() # held while sending; consent changes wait for it
|
||||
@@ -105,7 +109,7 @@ def blocked():
|
||||
|
||||
def _defaults():
|
||||
return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False,
|
||||
'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
|
||||
'notice_shown': False, 'notice_version': 0, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
|
||||
'frames_seen': [], 'compat_sent': []}
|
||||
|
||||
|
||||
@@ -147,12 +151,12 @@ def update_settings(changes):
|
||||
under way finishes first, so nothing leaves after this returns."""
|
||||
with _send_lock, _lock:
|
||||
s = settings()
|
||||
if 'noticeShown' in changes:
|
||||
s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown']
|
||||
if changes.get('noticeShown'):
|
||||
s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION
|
||||
for level in LEVELS:
|
||||
if level in changes:
|
||||
s[level] = bool(changes[level])
|
||||
s['notice_shown'] = True
|
||||
s['notice_shown'] = True # only the notice itself moves notice_version on
|
||||
_save(s)
|
||||
_drop_unwanted(s)
|
||||
if changes.get('compat'):
|
||||
@@ -161,10 +165,16 @@ def update_settings(changes):
|
||||
return state()
|
||||
|
||||
|
||||
def _notice_current(s):
|
||||
"""Whether the notice this person saw lists everything that's sent now. If not, the page
|
||||
shows it again, and what it added waits until it has been seen."""
|
||||
return s['notice_shown'] and s['notice_version'] >= NOTICE_VERSION
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows: the choices, why sending is blocked, and what was sent."""
|
||||
s = settings()
|
||||
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'],
|
||||
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': _notice_current(s),
|
||||
'diagnostics': s['diagnostics'],
|
||||
'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
|
||||
'sent': list(reversed(_read_lines(SENT)))[:50]}
|
||||
@@ -372,20 +382,36 @@ def page_event(body):
|
||||
return {'queued': capture(name, props)}
|
||||
|
||||
|
||||
def country():
|
||||
"""The two-letter country of the computer's time zone setting, or None. Only names in the
|
||||
tz database's table count, so nothing else in the setting can be sent. The app passes the
|
||||
zone from Electron, which knows it on every OS; from a checkout it's read from /etc/localtime."""
|
||||
tz = os.environ.get('FRAME_CONTROL_TIMEZONE') or os.environ.get('TZ', '').lstrip(':')
|
||||
if not tz:
|
||||
try:
|
||||
tz = os.path.realpath('/etc/localtime').split('/zoneinfo/', 1)[1]
|
||||
except (OSError, IndexError):
|
||||
return None
|
||||
return frame_timezones.COUNTRY.get(tz)
|
||||
|
||||
|
||||
def app_started():
|
||||
"""Once per server start: first install, an update, and one open a day."""
|
||||
"""Once per server start: first install, an update, and one open a day, with the country
|
||||
(left out when sending if the notice this person saw came before it: flush)."""
|
||||
if blocked():
|
||||
return
|
||||
with _lock:
|
||||
s = settings()
|
||||
version, today = app_version(), time.strftime('%Y-%m-%d')
|
||||
cc = country()
|
||||
where = {'country': cc} if cc else {}
|
||||
if not s['installed_sent']:
|
||||
capture('app_installed')
|
||||
capture('app_installed', where)
|
||||
s['installed_sent'] = True
|
||||
elif s['last_version'] and s['last_version'] != version:
|
||||
capture('app_updated', {'from_version': s['last_version']})
|
||||
capture('app_updated', {'from_version': s['last_version'], **where})
|
||||
if s['last_open_day'] != today:
|
||||
capture('app_opened')
|
||||
capture('app_opened', where)
|
||||
s['last_open_day'] = today
|
||||
s['last_version'] = version
|
||||
_save(s)
|
||||
@@ -580,10 +606,14 @@ def flush(timeout=20):
|
||||
if blocked() or not settings()['notice_shown']:
|
||||
return 0
|
||||
with _lock:
|
||||
_drop_unwanted(settings())
|
||||
s = settings()
|
||||
_drop_unwanted(s)
|
||||
batch = _read_lines(OUTBOX)[:100]
|
||||
if not batch:
|
||||
return 0
|
||||
if not _notice_current(s): # what a newer notice added stays here until it's been seen
|
||||
for e in batch:
|
||||
e['properties'].pop('country', None)
|
||||
try:
|
||||
post(batch, timeout)
|
||||
except SendError:
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Time zone name -> two-letter country, from the IANA tz database 2026c (zone.tab, and
|
||||
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||
Zones that aren't in one country, such as UTC, aren't here."""
|
||||
COUNTRY = {
|
||||
'Africa/Abidjan': 'CI',
|
||||
'Africa/Accra': 'GH',
|
||||
'Africa/Addis_Ababa': 'ET',
|
||||
'Africa/Algiers': 'DZ',
|
||||
'Africa/Asmara': 'ER',
|
||||
'Africa/Asmera': 'ER',
|
||||
'Africa/Bamako': 'ML',
|
||||
'Africa/Bangui': 'CF',
|
||||
'Africa/Banjul': 'GM',
|
||||
'Africa/Bissau': 'GW',
|
||||
'Africa/Blantyre': 'MW',
|
||||
'Africa/Brazzaville': 'CG',
|
||||
'Africa/Bujumbura': 'BI',
|
||||
'Africa/Cairo': 'EG',
|
||||
'Africa/Casablanca': 'MA',
|
||||
'Africa/Ceuta': 'ES',
|
||||
'Africa/Conakry': 'GN',
|
||||
'Africa/Dakar': 'SN',
|
||||
'Africa/Dar_es_Salaam': 'TZ',
|
||||
'Africa/Djibouti': 'DJ',
|
||||
'Africa/Douala': 'CM',
|
||||
'Africa/El_Aaiun': 'EH',
|
||||
'Africa/Freetown': 'SL',
|
||||
'Africa/Gaborone': 'BW',
|
||||
'Africa/Harare': 'ZW',
|
||||
'Africa/Johannesburg': 'ZA',
|
||||
'Africa/Juba': 'SS',
|
||||
'Africa/Kampala': 'UG',
|
||||
'Africa/Khartoum': 'SD',
|
||||
'Africa/Kigali': 'RW',
|
||||
'Africa/Kinshasa': 'CD',
|
||||
'Africa/Lagos': 'NG',
|
||||
'Africa/Libreville': 'GA',
|
||||
'Africa/Lome': 'TG',
|
||||
'Africa/Luanda': 'AO',
|
||||
'Africa/Lubumbashi': 'CD',
|
||||
'Africa/Lusaka': 'ZM',
|
||||
'Africa/Malabo': 'GQ',
|
||||
'Africa/Maputo': 'MZ',
|
||||
'Africa/Maseru': 'LS',
|
||||
'Africa/Mbabane': 'SZ',
|
||||
'Africa/Mogadishu': 'SO',
|
||||
'Africa/Monrovia': 'LR',
|
||||
'Africa/Nairobi': 'KE',
|
||||
'Africa/Ndjamena': 'TD',
|
||||
'Africa/Niamey': 'NE',
|
||||
'Africa/Nouakchott': 'MR',
|
||||
'Africa/Ouagadougou': 'BF',
|
||||
'Africa/Porto-Novo': 'BJ',
|
||||
'Africa/Sao_Tome': 'ST',
|
||||
'Africa/Timbuktu': 'ML',
|
||||
'Africa/Tripoli': 'LY',
|
||||
'Africa/Tunis': 'TN',
|
||||
'Africa/Windhoek': 'NA',
|
||||
'America/Adak': 'US',
|
||||
'America/Anchorage': 'US',
|
||||
'America/Anguilla': 'AI',
|
||||
'America/Antigua': 'AG',
|
||||
'America/Araguaina': 'BR',
|
||||
'America/Argentina/Buenos_Aires': 'AR',
|
||||
'America/Argentina/Catamarca': 'AR',
|
||||
'America/Argentina/ComodRivadavia': 'AR',
|
||||
'America/Argentina/Cordoba': 'AR',
|
||||
'America/Argentina/Jujuy': 'AR',
|
||||
'America/Argentina/La_Rioja': 'AR',
|
||||
'America/Argentina/Mendoza': 'AR',
|
||||
'America/Argentina/Rio_Gallegos': 'AR',
|
||||
'America/Argentina/Salta': 'AR',
|
||||
'America/Argentina/San_Juan': 'AR',
|
||||
'America/Argentina/San_Luis': 'AR',
|
||||
'America/Argentina/Tucuman': 'AR',
|
||||
'America/Argentina/Ushuaia': 'AR',
|
||||
'America/Aruba': 'AW',
|
||||
'America/Asuncion': 'PY',
|
||||
'America/Atikokan': 'CA',
|
||||
'America/Atka': 'US',
|
||||
'America/Bahia': 'BR',
|
||||
'America/Bahia_Banderas': 'MX',
|
||||
'America/Barbados': 'BB',
|
||||
'America/Belem': 'BR',
|
||||
'America/Belize': 'BZ',
|
||||
'America/Blanc-Sablon': 'CA',
|
||||
'America/Boa_Vista': 'BR',
|
||||
'America/Bogota': 'CO',
|
||||
'America/Boise': 'US',
|
||||
'America/Buenos_Aires': 'AR',
|
||||
'America/Cambridge_Bay': 'CA',
|
||||
'America/Campo_Grande': 'BR',
|
||||
'America/Cancun': 'MX',
|
||||
'America/Caracas': 'VE',
|
||||
'America/Catamarca': 'AR',
|
||||
'America/Cayenne': 'GF',
|
||||
'America/Cayman': 'KY',
|
||||
'America/Chicago': 'US',
|
||||
'America/Chihuahua': 'MX',
|
||||
'America/Ciudad_Juarez': 'MX',
|
||||
'America/Coral_Harbour': 'CA',
|
||||
'America/Cordoba': 'AR',
|
||||
'America/Costa_Rica': 'CR',
|
||||
'America/Coyhaique': 'CL',
|
||||
'America/Creston': 'CA',
|
||||
'America/Cuiaba': 'BR',
|
||||
'America/Curacao': 'CW',
|
||||
'America/Danmarkshavn': 'GL',
|
||||
'America/Dawson': 'CA',
|
||||
'America/Dawson_Creek': 'CA',
|
||||
'America/Denver': 'US',
|
||||
'America/Detroit': 'US',
|
||||
'America/Dominica': 'DM',
|
||||
'America/Edmonton': 'CA',
|
||||
'America/Eirunepe': 'BR',
|
||||
'America/El_Salvador': 'SV',
|
||||
'America/Ensenada': 'MX',
|
||||
'America/Fort_Nelson': 'CA',
|
||||
'America/Fort_Wayne': 'US',
|
||||
'America/Fortaleza': 'BR',
|
||||
'America/Glace_Bay': 'CA',
|
||||
'America/Godthab': 'GL',
|
||||
'America/Goose_Bay': 'CA',
|
||||
'America/Grand_Turk': 'TC',
|
||||
'America/Grenada': 'GD',
|
||||
'America/Guadeloupe': 'GP',
|
||||
'America/Guatemala': 'GT',
|
||||
'America/Guayaquil': 'EC',
|
||||
'America/Guyana': 'GY',
|
||||
'America/Halifax': 'CA',
|
||||
'America/Havana': 'CU',
|
||||
'America/Hermosillo': 'MX',
|
||||
'America/Indiana/Indianapolis': 'US',
|
||||
'America/Indiana/Knox': 'US',
|
||||
'America/Indiana/Marengo': 'US',
|
||||
'America/Indiana/Petersburg': 'US',
|
||||
'America/Indiana/Tell_City': 'US',
|
||||
'America/Indiana/Vevay': 'US',
|
||||
'America/Indiana/Vincennes': 'US',
|
||||
'America/Indiana/Winamac': 'US',
|
||||
'America/Indianapolis': 'US',
|
||||
'America/Inuvik': 'CA',
|
||||
'America/Iqaluit': 'CA',
|
||||
'America/Jamaica': 'JM',
|
||||
'America/Jujuy': 'AR',
|
||||
'America/Juneau': 'US',
|
||||
'America/Kentucky/Louisville': 'US',
|
||||
'America/Kentucky/Monticello': 'US',
|
||||
'America/Knox_IN': 'US',
|
||||
'America/Kralendijk': 'BQ',
|
||||
'America/La_Paz': 'BO',
|
||||
'America/Lima': 'PE',
|
||||
'America/Los_Angeles': 'US',
|
||||
'America/Louisville': 'US',
|
||||
'America/Lower_Princes': 'SX',
|
||||
'America/Maceio': 'BR',
|
||||
'America/Managua': 'NI',
|
||||
'America/Manaus': 'BR',
|
||||
'America/Marigot': 'MF',
|
||||
'America/Martinique': 'MQ',
|
||||
'America/Matamoros': 'MX',
|
||||
'America/Mazatlan': 'MX',
|
||||
'America/Mendoza': 'AR',
|
||||
'America/Menominee': 'US',
|
||||
'America/Merida': 'MX',
|
||||
'America/Metlakatla': 'US',
|
||||
'America/Mexico_City': 'MX',
|
||||
'America/Miquelon': 'PM',
|
||||
'America/Moncton': 'CA',
|
||||
'America/Monterrey': 'MX',
|
||||
'America/Montevideo': 'UY',
|
||||
'America/Montreal': 'CA',
|
||||
'America/Montserrat': 'MS',
|
||||
'America/Nassau': 'BS',
|
||||
'America/New_York': 'US',
|
||||
'America/Nipigon': 'CA',
|
||||
'America/Nome': 'US',
|
||||
'America/Noronha': 'BR',
|
||||
'America/North_Dakota/Beulah': 'US',
|
||||
'America/North_Dakota/Center': 'US',
|
||||
'America/North_Dakota/New_Salem': 'US',
|
||||
'America/Nuuk': 'GL',
|
||||
'America/Ojinaga': 'MX',
|
||||
'America/Panama': 'PA',
|
||||
'America/Pangnirtung': 'CA',
|
||||
'America/Paramaribo': 'SR',
|
||||
'America/Phoenix': 'US',
|
||||
'America/Port-au-Prince': 'HT',
|
||||
'America/Port_of_Spain': 'TT',
|
||||
'America/Porto_Acre': 'BR',
|
||||
'America/Porto_Velho': 'BR',
|
||||
'America/Puerto_Rico': 'PR',
|
||||
'America/Punta_Arenas': 'CL',
|
||||
'America/Rainy_River': 'CA',
|
||||
'America/Rankin_Inlet': 'CA',
|
||||
'America/Recife': 'BR',
|
||||
'America/Regina': 'CA',
|
||||
'America/Resolute': 'CA',
|
||||
'America/Rio_Branco': 'BR',
|
||||
'America/Rosario': 'AR',
|
||||
'America/Santa_Isabel': 'MX',
|
||||
'America/Santarem': 'BR',
|
||||
'America/Santiago': 'CL',
|
||||
'America/Santo_Domingo': 'DO',
|
||||
'America/Sao_Paulo': 'BR',
|
||||
'America/Scoresbysund': 'GL',
|
||||
'America/Shiprock': 'US',
|
||||
'America/Sitka': 'US',
|
||||
'America/St_Barthelemy': 'BL',
|
||||
'America/St_Johns': 'CA',
|
||||
'America/St_Kitts': 'KN',
|
||||
'America/St_Lucia': 'LC',
|
||||
'America/St_Thomas': 'VI',
|
||||
'America/St_Vincent': 'VC',
|
||||
'America/Swift_Current': 'CA',
|
||||
'America/Tegucigalpa': 'HN',
|
||||
'America/Thule': 'GL',
|
||||
'America/Thunder_Bay': 'CA',
|
||||
'America/Tijuana': 'MX',
|
||||
'America/Toronto': 'CA',
|
||||
'America/Tortola': 'VG',
|
||||
'America/Vancouver': 'CA',
|
||||
'America/Virgin': 'VI',
|
||||
'America/Whitehorse': 'CA',
|
||||
'America/Winnipeg': 'CA',
|
||||
'America/Yakutat': 'US',
|
||||
'America/Yellowknife': 'CA',
|
||||
'Antarctica/Casey': 'AQ',
|
||||
'Antarctica/Davis': 'AQ',
|
||||
'Antarctica/DumontDUrville': 'AQ',
|
||||
'Antarctica/Macquarie': 'AU',
|
||||
'Antarctica/Mawson': 'AQ',
|
||||
'Antarctica/McMurdo': 'AQ',
|
||||
'Antarctica/Palmer': 'AQ',
|
||||
'Antarctica/Rothera': 'AQ',
|
||||
'Antarctica/South_Pole': 'AQ',
|
||||
'Antarctica/Syowa': 'AQ',
|
||||
'Antarctica/Troll': 'AQ',
|
||||
'Antarctica/Vostok': 'AQ',
|
||||
'Arctic/Longyearbyen': 'SJ',
|
||||
'Asia/Aden': 'YE',
|
||||
'Asia/Almaty': 'KZ',
|
||||
'Asia/Amman': 'JO',
|
||||
'Asia/Anadyr': 'RU',
|
||||
'Asia/Aqtau': 'KZ',
|
||||
'Asia/Aqtobe': 'KZ',
|
||||
'Asia/Ashgabat': 'TM',
|
||||
'Asia/Ashkhabad': 'TM',
|
||||
'Asia/Atyrau': 'KZ',
|
||||
'Asia/Baghdad': 'IQ',
|
||||
'Asia/Bahrain': 'BH',
|
||||
'Asia/Baku': 'AZ',
|
||||
'Asia/Bangkok': 'TH',
|
||||
'Asia/Barnaul': 'RU',
|
||||
'Asia/Beirut': 'LB',
|
||||
'Asia/Bishkek': 'KG',
|
||||
'Asia/Brunei': 'BN',
|
||||
'Asia/Calcutta': 'IN',
|
||||
'Asia/Chita': 'RU',
|
||||
'Asia/Choibalsan': 'MN',
|
||||
'Asia/Chongqing': 'CN',
|
||||
'Asia/Chungking': 'CN',
|
||||
'Asia/Colombo': 'LK',
|
||||
'Asia/Dacca': 'BD',
|
||||
'Asia/Damascus': 'SY',
|
||||
'Asia/Dhaka': 'BD',
|
||||
'Asia/Dili': 'TL',
|
||||
'Asia/Dubai': 'AE',
|
||||
'Asia/Dushanbe': 'TJ',
|
||||
'Asia/Famagusta': 'CY',
|
||||
'Asia/Gaza': 'PS',
|
||||
'Asia/Harbin': 'CN',
|
||||
'Asia/Hebron': 'PS',
|
||||
'Asia/Ho_Chi_Minh': 'VN',
|
||||
'Asia/Hong_Kong': 'HK',
|
||||
'Asia/Hovd': 'MN',
|
||||
'Asia/Irkutsk': 'RU',
|
||||
'Asia/Istanbul': 'TR',
|
||||
'Asia/Jakarta': 'ID',
|
||||
'Asia/Jayapura': 'ID',
|
||||
'Asia/Jerusalem': 'IL',
|
||||
'Asia/Kabul': 'AF',
|
||||
'Asia/Kamchatka': 'RU',
|
||||
'Asia/Karachi': 'PK',
|
||||
'Asia/Kashgar': 'CN',
|
||||
'Asia/Kathmandu': 'NP',
|
||||
'Asia/Katmandu': 'NP',
|
||||
'Asia/Khandyga': 'RU',
|
||||
'Asia/Kolkata': 'IN',
|
||||
'Asia/Krasnoyarsk': 'RU',
|
||||
'Asia/Kuala_Lumpur': 'MY',
|
||||
'Asia/Kuching': 'MY',
|
||||
'Asia/Kuwait': 'KW',
|
||||
'Asia/Macao': 'MO',
|
||||
'Asia/Macau': 'MO',
|
||||
'Asia/Magadan': 'RU',
|
||||
'Asia/Makassar': 'ID',
|
||||
'Asia/Manila': 'PH',
|
||||
'Asia/Muscat': 'OM',
|
||||
'Asia/Nicosia': 'CY',
|
||||
'Asia/Novokuznetsk': 'RU',
|
||||
'Asia/Novosibirsk': 'RU',
|
||||
'Asia/Omsk': 'RU',
|
||||
'Asia/Oral': 'KZ',
|
||||
'Asia/Phnom_Penh': 'KH',
|
||||
'Asia/Pontianak': 'ID',
|
||||
'Asia/Pyongyang': 'KP',
|
||||
'Asia/Qatar': 'QA',
|
||||
'Asia/Qostanay': 'KZ',
|
||||
'Asia/Qyzylorda': 'KZ',
|
||||
'Asia/Rangoon': 'MM',
|
||||
'Asia/Riyadh': 'SA',
|
||||
'Asia/Saigon': 'VN',
|
||||
'Asia/Sakhalin': 'RU',
|
||||
'Asia/Samarkand': 'UZ',
|
||||
'Asia/Seoul': 'KR',
|
||||
'Asia/Shanghai': 'CN',
|
||||
'Asia/Singapore': 'SG',
|
||||
'Asia/Srednekolymsk': 'RU',
|
||||
'Asia/Taipei': 'TW',
|
||||
'Asia/Tashkent': 'UZ',
|
||||
'Asia/Tbilisi': 'GE',
|
||||
'Asia/Tehran': 'IR',
|
||||
'Asia/Tel_Aviv': 'IL',
|
||||
'Asia/Thimbu': 'BT',
|
||||
'Asia/Thimphu': 'BT',
|
||||
'Asia/Tokyo': 'JP',
|
||||
'Asia/Tomsk': 'RU',
|
||||
'Asia/Ujung_Pandang': 'ID',
|
||||
'Asia/Ulaanbaatar': 'MN',
|
||||
'Asia/Ulan_Bator': 'MN',
|
||||
'Asia/Urumqi': 'CN',
|
||||
'Asia/Ust-Nera': 'RU',
|
||||
'Asia/Vientiane': 'LA',
|
||||
'Asia/Vladivostok': 'RU',
|
||||
'Asia/Yakutsk': 'RU',
|
||||
'Asia/Yangon': 'MM',
|
||||
'Asia/Yekaterinburg': 'RU',
|
||||
'Asia/Yerevan': 'AM',
|
||||
'Atlantic/Azores': 'PT',
|
||||
'Atlantic/Bermuda': 'BM',
|
||||
'Atlantic/Canary': 'ES',
|
||||
'Atlantic/Cape_Verde': 'CV',
|
||||
'Atlantic/Faeroe': 'FO',
|
||||
'Atlantic/Faroe': 'FO',
|
||||
'Atlantic/Jan_Mayen': 'SJ',
|
||||
'Atlantic/Madeira': 'PT',
|
||||
'Atlantic/Reykjavik': 'IS',
|
||||
'Atlantic/South_Georgia': 'GS',
|
||||
'Atlantic/St_Helena': 'SH',
|
||||
'Atlantic/Stanley': 'FK',
|
||||
'Australia/ACT': 'AU',
|
||||
'Australia/Adelaide': 'AU',
|
||||
'Australia/Brisbane': 'AU',
|
||||
'Australia/Broken_Hill': 'AU',
|
||||
'Australia/Canberra': 'AU',
|
||||
'Australia/Currie': 'AU',
|
||||
'Australia/Darwin': 'AU',
|
||||
'Australia/Eucla': 'AU',
|
||||
'Australia/Hobart': 'AU',
|
||||
'Australia/LHI': 'AU',
|
||||
'Australia/Lindeman': 'AU',
|
||||
'Australia/Lord_Howe': 'AU',
|
||||
'Australia/Melbourne': 'AU',
|
||||
'Australia/NSW': 'AU',
|
||||
'Australia/North': 'AU',
|
||||
'Australia/Perth': 'AU',
|
||||
'Australia/Queensland': 'AU',
|
||||
'Australia/South': 'AU',
|
||||
'Australia/Sydney': 'AU',
|
||||
'Australia/Tasmania': 'AU',
|
||||
'Australia/Victoria': 'AU',
|
||||
'Australia/West': 'AU',
|
||||
'Australia/Yancowinna': 'AU',
|
||||
'Brazil/Acre': 'BR',
|
||||
'Brazil/DeNoronha': 'BR',
|
||||
'Brazil/East': 'BR',
|
||||
'Brazil/West': 'BR',
|
||||
'Canada/Atlantic': 'CA',
|
||||
'Canada/Central': 'CA',
|
||||
'Canada/Eastern': 'CA',
|
||||
'Canada/Mountain': 'CA',
|
||||
'Canada/Newfoundland': 'CA',
|
||||
'Canada/Pacific': 'CA',
|
||||
'Canada/Saskatchewan': 'CA',
|
||||
'Canada/Yukon': 'CA',
|
||||
'Chile/Continental': 'CL',
|
||||
'Chile/EasterIsland': 'CL',
|
||||
'Cuba': 'CU',
|
||||
'Egypt': 'EG',
|
||||
'Eire': 'IE',
|
||||
'Europe/Amsterdam': 'NL',
|
||||
'Europe/Andorra': 'AD',
|
||||
'Europe/Astrakhan': 'RU',
|
||||
'Europe/Athens': 'GR',
|
||||
'Europe/Belfast': 'GB',
|
||||
'Europe/Belgrade': 'RS',
|
||||
'Europe/Berlin': 'DE',
|
||||
'Europe/Bratislava': 'SK',
|
||||
'Europe/Brussels': 'BE',
|
||||
'Europe/Bucharest': 'RO',
|
||||
'Europe/Budapest': 'HU',
|
||||
'Europe/Busingen': 'DE',
|
||||
'Europe/Chisinau': 'MD',
|
||||
'Europe/Copenhagen': 'DK',
|
||||
'Europe/Dublin': 'IE',
|
||||
'Europe/Gibraltar': 'GI',
|
||||
'Europe/Guernsey': 'GG',
|
||||
'Europe/Helsinki': 'FI',
|
||||
'Europe/Isle_of_Man': 'IM',
|
||||
'Europe/Istanbul': 'TR',
|
||||
'Europe/Jersey': 'JE',
|
||||
'Europe/Kaliningrad': 'RU',
|
||||
'Europe/Kiev': 'UA',
|
||||
'Europe/Kirov': 'RU',
|
||||
'Europe/Kyiv': 'UA',
|
||||
'Europe/Lisbon': 'PT',
|
||||
'Europe/Ljubljana': 'SI',
|
||||
'Europe/London': 'GB',
|
||||
'Europe/Luxembourg': 'LU',
|
||||
'Europe/Madrid': 'ES',
|
||||
'Europe/Malta': 'MT',
|
||||
'Europe/Mariehamn': 'AX',
|
||||
'Europe/Minsk': 'BY',
|
||||
'Europe/Monaco': 'MC',
|
||||
'Europe/Moscow': 'RU',
|
||||
'Europe/Nicosia': 'CY',
|
||||
'Europe/Oslo': 'NO',
|
||||
'Europe/Paris': 'FR',
|
||||
'Europe/Podgorica': 'ME',
|
||||
'Europe/Prague': 'CZ',
|
||||
'Europe/Riga': 'LV',
|
||||
'Europe/Rome': 'IT',
|
||||
'Europe/Samara': 'RU',
|
||||
'Europe/San_Marino': 'SM',
|
||||
'Europe/Sarajevo': 'BA',
|
||||
'Europe/Saratov': 'RU',
|
||||
'Europe/Simferopol': 'UA',
|
||||
'Europe/Skopje': 'MK',
|
||||
'Europe/Sofia': 'BG',
|
||||
'Europe/Stockholm': 'SE',
|
||||
'Europe/Tallinn': 'EE',
|
||||
'Europe/Tirane': 'AL',
|
||||
'Europe/Tiraspol': 'MD',
|
||||
'Europe/Ulyanovsk': 'RU',
|
||||
'Europe/Uzhgorod': 'UA',
|
||||
'Europe/Vaduz': 'LI',
|
||||
'Europe/Vatican': 'VA',
|
||||
'Europe/Vienna': 'AT',
|
||||
'Europe/Vilnius': 'LT',
|
||||
'Europe/Volgograd': 'RU',
|
||||
'Europe/Warsaw': 'PL',
|
||||
'Europe/Zagreb': 'HR',
|
||||
'Europe/Zaporozhye': 'UA',
|
||||
'Europe/Zurich': 'CH',
|
||||
'GB': 'GB',
|
||||
'GB-Eire': 'GB',
|
||||
'Hongkong': 'HK',
|
||||
'Iceland': 'IS',
|
||||
'Indian/Antananarivo': 'MG',
|
||||
'Indian/Chagos': 'IO',
|
||||
'Indian/Christmas': 'CX',
|
||||
'Indian/Cocos': 'CC',
|
||||
'Indian/Comoro': 'KM',
|
||||
'Indian/Kerguelen': 'TF',
|
||||
'Indian/Mahe': 'SC',
|
||||
'Indian/Maldives': 'MV',
|
||||
'Indian/Mauritius': 'MU',
|
||||
'Indian/Mayotte': 'YT',
|
||||
'Indian/Reunion': 'RE',
|
||||
'Iran': 'IR',
|
||||
'Israel': 'IL',
|
||||
'Jamaica': 'JM',
|
||||
'Japan': 'JP',
|
||||
'Kwajalein': 'MH',
|
||||
'Libya': 'LY',
|
||||
'Mexico/BajaNorte': 'MX',
|
||||
'Mexico/BajaSur': 'MX',
|
||||
'Mexico/General': 'MX',
|
||||
'NZ': 'NZ',
|
||||
'NZ-CHAT': 'NZ',
|
||||
'Navajo': 'US',
|
||||
'PRC': 'CN',
|
||||
'Pacific/Apia': 'WS',
|
||||
'Pacific/Auckland': 'NZ',
|
||||
'Pacific/Bougainville': 'PG',
|
||||
'Pacific/Chatham': 'NZ',
|
||||
'Pacific/Chuuk': 'FM',
|
||||
'Pacific/Easter': 'CL',
|
||||
'Pacific/Efate': 'VU',
|
||||
'Pacific/Enderbury': 'KI',
|
||||
'Pacific/Fakaofo': 'TK',
|
||||
'Pacific/Fiji': 'FJ',
|
||||
'Pacific/Funafuti': 'TV',
|
||||
'Pacific/Galapagos': 'EC',
|
||||
'Pacific/Gambier': 'PF',
|
||||
'Pacific/Guadalcanal': 'SB',
|
||||
'Pacific/Guam': 'GU',
|
||||
'Pacific/Honolulu': 'US',
|
||||
'Pacific/Johnston': 'UM',
|
||||
'Pacific/Kanton': 'KI',
|
||||
'Pacific/Kiritimati': 'KI',
|
||||
'Pacific/Kosrae': 'FM',
|
||||
'Pacific/Kwajalein': 'MH',
|
||||
'Pacific/Majuro': 'MH',
|
||||
'Pacific/Marquesas': 'PF',
|
||||
'Pacific/Midway': 'UM',
|
||||
'Pacific/Nauru': 'NR',
|
||||
'Pacific/Niue': 'NU',
|
||||
'Pacific/Norfolk': 'NF',
|
||||
'Pacific/Noumea': 'NC',
|
||||
'Pacific/Pago_Pago': 'AS',
|
||||
'Pacific/Palau': 'PW',
|
||||
'Pacific/Pitcairn': 'PN',
|
||||
'Pacific/Pohnpei': 'FM',
|
||||
'Pacific/Ponape': 'FM',
|
||||
'Pacific/Port_Moresby': 'PG',
|
||||
'Pacific/Rarotonga': 'CK',
|
||||
'Pacific/Saipan': 'MP',
|
||||
'Pacific/Samoa': 'AS',
|
||||
'Pacific/Tahiti': 'PF',
|
||||
'Pacific/Tarawa': 'KI',
|
||||
'Pacific/Tongatapu': 'TO',
|
||||
'Pacific/Truk': 'FM',
|
||||
'Pacific/Wake': 'UM',
|
||||
'Pacific/Wallis': 'WF',
|
||||
'Pacific/Yap': 'FM',
|
||||
'Poland': 'PL',
|
||||
'Portugal': 'PT',
|
||||
'ROC': 'TW',
|
||||
'ROK': 'KR',
|
||||
'Singapore': 'SG',
|
||||
'Turkey': 'TR',
|
||||
'US/Alaska': 'US',
|
||||
'US/Aleutian': 'US',
|
||||
'US/Arizona': 'US',
|
||||
'US/Central': 'US',
|
||||
'US/East-Indiana': 'US',
|
||||
'US/Eastern': 'US',
|
||||
'US/Hawaii': 'US',
|
||||
'US/Indiana-Starke': 'US',
|
||||
'US/Michigan': 'US',
|
||||
'US/Mountain': 'US',
|
||||
'US/Pacific': 'US',
|
||||
'US/Samoa': 'AS',
|
||||
'W-SU': 'RU',
|
||||
}
|
||||
+194
-52
@@ -455,6 +455,12 @@
|
||||
backdrop-filter: none; background: rgb(23, 29, 37); }
|
||||
.brand svg { width: 26px; height: 26px; }
|
||||
header .chip { height: 30px; padding: 0 10px; }
|
||||
/* Let connection text yield before battery and header actions. */
|
||||
.hdr-end { flex: 1; gap: 6px; }
|
||||
.hdr-end .pill, .hdr-end .devsel { min-width: 0; }
|
||||
.hdr-end .pill { padding: 0 8px; gap: 6px; }
|
||||
.hdr-end .pill > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
#reportBtn, #refreshAll { width: 38px; padding: 0; justify-content: center; }
|
||||
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
||||
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
|
||||
@@ -481,6 +487,9 @@
|
||||
.and-btns { padding-left: 0; }
|
||||
.banner { flex-wrap: wrap; }
|
||||
.banner .s { white-space: normal; }
|
||||
/* Reserve the message row; connection actions wrap below it. */
|
||||
#offline { align-items: flex-start; }
|
||||
#offline > .grow { flex: 1 0 calc(100% - 36px); overflow-wrap: anywhere; }
|
||||
button { height: 38px; }
|
||||
button.small { height: 32px; }
|
||||
.actions button { height: 46px; }
|
||||
@@ -642,6 +651,7 @@
|
||||
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
|
||||
|
||||
</style>
|
||||
<link rel="stylesheet" href="/control-center.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -660,6 +670,7 @@
|
||||
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
|
||||
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
|
||||
<a href="#devices" class="desk-only" title="Headsets, their addresses and the connection (5)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/><path d="M2 9a15 15 0 0 1 20 0"/></svg>Devices<kbd>5</kbd></a>
|
||||
<a href="#control" title="Search controls, settings and live metrics (6)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18M8 3v6M16 9v6M10 15v6"/></svg>Control<kbd>6</kbd></a>
|
||||
</nav>
|
||||
<div class="hdr-end">
|
||||
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
|
||||
@@ -684,7 +695,7 @@
|
||||
</div>
|
||||
<div class="notice" id="privacyNotice" hidden>
|
||||
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
|
||||
your operating system, which tabs you use, whether installs on the Frame worked, and how many headsets you use
|
||||
your operating system, your country (from the computer's time zone setting), which tabs you use, whether installs on the Frame worked, and how many headsets you use
|
||||
(a one-way hash of each headset's machine ID, the same on every computer, so they're counted once). Never file names, paths,
|
||||
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
|
||||
worked and the details of errors, which helps fix problems faster.</div>
|
||||
@@ -693,15 +704,12 @@
|
||||
<button class="action small" id="noticeOk">OK</button>
|
||||
</div>
|
||||
<form class="notice" id="contactNotice" hidden>
|
||||
<div class="grow"><b>Leave an email address?</b> Optional: Frame Control works the same either way, and
|
||||
you can change or remove it any time in Privacy & updates.
|
||||
<div><input type="email" id="cpEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address" required></div>
|
||||
<div class="contact-opts">
|
||||
<label><input type="checkbox" id="cpUpdates"> Email me about Frame Control updates</label>
|
||||
<label><input type="checkbox" id="cpFollowup"> The maintainer may contact me with follow-up questions</label></div></div>
|
||||
<div class="grow"><b>Want to hear about new features?</b> Join the Frame Control mailing list.
|
||||
Optional, unsubscribe any time in Privacy & updates.
|
||||
<div><input type="email" id="cpEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address" required></div></div>
|
||||
<span class="sub" id="cpMsg" role="status"></span>
|
||||
<button type="button" class="small" id="cpNo">No thanks</button>
|
||||
<button type="submit" class="action small" id="cpSave">Save</button>
|
||||
<button type="submit" class="action small" id="cpSave">Join</button>
|
||||
</form>
|
||||
<div class="banner" id="offline" role="alert" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
|
||||
@@ -713,6 +721,42 @@
|
||||
<button class="action small" id="offRetry">Retry now</button>
|
||||
</div>
|
||||
|
||||
<div class="page cc" data-page="control" id="controlCenter">
|
||||
<section class="cc-hero" aria-labelledby="ccTitle">
|
||||
<div><div class="cc-eyebrow">Your Frame, at a glance</div><h1 id="ccTitle">Control Center</h1><p>Everything you can see, change and configure. Find a setting, check your headset, or pick up where you left off.</p></div>
|
||||
<svg class="cc-hero-mark" viewBox="0 0 120 84" fill="none" aria-hidden="true"><path d="M14 28C14 15 106 15 106 28v23c0 18-33 23-39 11h-14c-6 12-39 7-39-11z" stroke="currentColor" stroke-width="1.5"/><path d="M26 32c0-6 25-6 25 0v15c0 11-25 11-25 0zM69 32c0-6 25-6 25 0v15c0 11-25 11-25 0z" stroke="currentColor" stroke-opacity=".4"/><path d="M7 33h7m92 0h7M48 18l4-7h16l4 7" stroke="currentColor" stroke-width="1.5"/><circle cx="60" cy="38" r="2" fill="currentColor"/></svg>
|
||||
</section>
|
||||
<section aria-labelledby="ccDeviceName">
|
||||
<div class="cc-topline"><h2 id="ccDeviceName">Steam Frame</h2><span class="cc-sample" id="ccSampleState" role="status">Connect your Frame to read live metrics</span><button class="small" id="ccRefresh">Refresh metrics</button></div>
|
||||
<div class="cc-summary">
|
||||
<div class="cc-metric"><span>Battery</span><strong data-cc-metric="battery">Unavailable</strong><small data-cc-metric="batteryDetail">Connect to read charge and power</small></div>
|
||||
<div class="cc-metric"><span>Storage free</span><strong data-cc-metric="storage">Unavailable</strong><small data-cc-metric="storageDetail">User storage unavailable</small></div>
|
||||
<div class="cc-metric"><span>Memory used</span><strong data-cc-metric="memory">Unavailable</strong><small data-cc-metric="memoryDetail">Memory sensor unavailable</small></div>
|
||||
<div class="cc-metric"><span>Compositor FPS</span><strong data-cc-metric="fps">Unavailable</strong><small data-cc-metric="fpsDetail">Compositor FPS · not game FPS</small></div>
|
||||
</div>
|
||||
<details class="cc-device-facts"><summary id="ccBuild">System information unavailable</summary>
|
||||
<dl class="cc-readings">
|
||||
<div><dt>Wi-Fi network</dt><dd data-cc-metric="wifi">Unavailable</dd></div><div><dt>Wi-Fi signal</dt><dd data-cc-metric="signal">Unavailable</dd></div>
|
||||
<div><dt>IP address</dt><dd data-cc-metric="ip">Unavailable</dd></div><div><dt>Uptime</dt><dd data-cc-metric="uptime">Unavailable</dd></div>
|
||||
<div><dt>Battery power · signed</dt><dd data-cc-metric="power">Unavailable</dd></div><div><dt>Charger</dt><dd data-cc-metric="charger">Unavailable</dd></div>
|
||||
<div><dt>Battery temperature</dt><dd data-cc-metric="batteryTemp">Unavailable</dd></div><div><dt>Battery health</dt><dd data-cc-metric="batteryHealth">Unavailable</dd></div>
|
||||
<div><dt>Battery estimate</dt><dd data-cc-metric="estimate">Unavailable</dd></div>
|
||||
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Estimates are reported by the battery and may be unavailable.</p>
|
||||
<div class="cc-services" id="ccServices"></div>
|
||||
</details>
|
||||
</section>
|
||||
<div class="cc-workspace">
|
||||
<aside class="cc-sidebar" aria-label="Control Center categories"><p>Browse controls</p><div class="cc-categories" id="ccCategories"></div>
|
||||
<div class="cc-status"><div><i class="dot" id="ccStatusDot"></i><span id="ccStatusLabel">No live sample</span></div><p id="ccStatusNote">Read-only status over SSH. Controls run only when you use them.</p></div>
|
||||
</aside>
|
||||
<div>
|
||||
<div class="cc-search-row"><div class="cc-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></svg><input id="ccSearch" type="search" placeholder="Search settings, controls and guides…" aria-label="Search Control Center" autocomplete="off"><button id="ccClear" type="button" aria-label="Clear search" hidden>×</button></div><span class="cc-results" id="ccResultCount" role="status" aria-live="polite"></span></div>
|
||||
<div class="cc-cards" id="ccCards"></div>
|
||||
<div class="cc-empty" id="ccEmpty" hidden><h2>No matching controls</h2><p>Try a different word or browse all categories.</p><button class="small" id="ccResetFilter">Reset search & category</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page on" data-page="home">
|
||||
<div class="grid-top" id="view">
|
||||
<section class="panel">
|
||||
@@ -959,6 +1003,12 @@
|
||||
library under its own name and opens in its own panel. Test launches it and records
|
||||
whether it stays up; Report records how it worked for you.</div>
|
||||
</div>
|
||||
<section class="panel" id="installQueue" hidden>
|
||||
<div class="shelf-head"><h2>Install queue</h2><span class="count" id="queueCount"></span></div>
|
||||
<div class="list" id="queueList"></div>
|
||||
<div class="hint">Games from the store, your Quest and your Meta library install one after another.
|
||||
Remove takes a game out before it starts.</div>
|
||||
</section>
|
||||
<section class="panel" id="quest">
|
||||
<div class="shelf-head"><h2>Import from my Quest</h2><span class="count" id="questCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="questScan" title="Look for a Meta Quest plugged into this computer by USB">Look for my Quest</button></div>
|
||||
@@ -1198,7 +1248,7 @@
|
||||
<section class="panel" id="privacy">
|
||||
<div class="shelf-head"><h2>Privacy & updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
|
||||
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
|
||||
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
|
||||
<span class="sub">Installs, opens, version, operating system, country (from the time zone setting, to see roughly where it's used), tabs used, and whether installs on the Frame
|
||||
worked (with an error category, never the message). F-Droid package names only. Each headset's SteamOS
|
||||
build, a one-way hash of its machine ID (to count headsets, not just installs), and how many you've set up.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
|
||||
@@ -1208,17 +1258,17 @@
|
||||
<span class="sub">Error messages and where in Frame Control they happened, with your home
|
||||
folder, user name, addresses and keys removed.</span></label>
|
||||
<div class="hint" id="tStatus"></div>
|
||||
<h3 style="margin-top:22px">Contact email (optional)</h3>
|
||||
<h3 style="margin-top:22px" id="mailingList">Mailing list and contact email (optional)</h3>
|
||||
<form id="contactForm">
|
||||
<input type="email" id="cEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address">
|
||||
<div class="contact-opts">
|
||||
<label><input type="checkbox" id="cUpdates"> Email me about Frame Control updates</label>
|
||||
<label><input type="checkbox" id="cUpdates"> Join the mailing list: new features and updates</label>
|
||||
<label><input type="checkbox" id="cFollowup"> The maintainer may contact me with follow-up questions</label></div>
|
||||
<div class="row" style="margin-top:10px"><button type="submit" class="small action" id="cSave">Save</button>
|
||||
<button type="button" class="small" id="cRemove">Remove my email</button></div>
|
||||
</form>
|
||||
<div class="hint" id="cStatus">Sent privately to the Frame Control maintainer, never shared or published.
|
||||
Updates are occasional release notices; follow-up questions are mainly about problem reports you send.</div>
|
||||
The mailing list is occasional news about new features and updates; follow-up questions are mainly about problem reports you send.</div>
|
||||
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
|
||||
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
|
||||
<button class="small" id="updateCheck" hidden>Check for updates</button>
|
||||
@@ -1444,7 +1494,7 @@ const HINTS = {
|
||||
flat: "The app panel in use in the headset, flat and still: it doesn't move as the wearer looks around. Pick another panel to watch it. Control lets you tap or click right where you want on it.",
|
||||
};
|
||||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", panel: "Desktop", shot: "Screenshot" };
|
||||
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||||
let state = null, stateReceivedAt = null, stateRead = {valid: false, error: null}, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||||
let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
|
||||
|
||||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
||||
@@ -1482,6 +1532,7 @@ $("bottombar").onclick = () => {
|
||||
const HOST = { computer: "computer" };
|
||||
function applyHostWording(h) {
|
||||
Object.assign(HOST, h);
|
||||
window.frameControlCenter?.host(h);
|
||||
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||||
document.body.classList.toggle("mobile", !!h.mobile);
|
||||
$("shotsFolder").hidden = !!h.mobile;
|
||||
@@ -1514,6 +1565,13 @@ let devGen = 0;
|
||||
const HEADSET_FREE = /^\/api\/(devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
|
||||
async function api(path, body, device = window.connDevice) {
|
||||
const gen = devGen;
|
||||
const checkGeneration = () => {
|
||||
if (gen !== devGen && !HEADSET_FREE.test(path)) {
|
||||
const err = new Error("Switched headsets");
|
||||
err.offline = err.stale = true; // leave the panel for the new headset's answer
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
// Changes name the headset the page was showing, so the server refuses one meant
|
||||
// for a headset it has since switched away from.
|
||||
const dev = device ? { "X-Frame-Device": device } : {};
|
||||
@@ -1522,15 +1580,12 @@ async function api(path, body, device = window.connDevice) {
|
||||
let r;
|
||||
try { r = await fetch(path, opts); }
|
||||
catch {
|
||||
checkGeneration(); // a late transport failure belongs to the old headset too
|
||||
throw new Error(window.frameApp?.platform === "ios" ? "Lost the connection to the Frame. The app reconnects on its own."
|
||||
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
|
||||
}
|
||||
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
||||
if (gen !== devGen && !HEADSET_FREE.test(path)) {
|
||||
const err = new Error("Switched headsets");
|
||||
err.offline = err.stale = true; // failed() leaves the panel alone: the new headset's answer fills it
|
||||
throw err;
|
||||
}
|
||||
checkGeneration();
|
||||
if (!r.ok) {
|
||||
const err = new Error(data.error || `HTTP ${r.status}`);
|
||||
err.offline = !!data.offline;
|
||||
@@ -1569,6 +1624,7 @@ let online = null;
|
||||
function setOnline(ok, why) {
|
||||
const was = online;
|
||||
online = ok;
|
||||
window.frameControlCenter?.connection(ok);
|
||||
$("offline").hidden = ok;
|
||||
if (!ok) {
|
||||
$("offMsg").textContent = why || "Can't reach the Frame";
|
||||
@@ -1600,11 +1656,15 @@ function refresh() {
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
state = await api("/api/status");
|
||||
stateReceivedAt = Date.now();
|
||||
stateRead = {valid: true, error: null};
|
||||
render(state);
|
||||
setConn(true, state.hostname);
|
||||
setOnline(true);
|
||||
} catch (e) {
|
||||
renderPerformance(null);
|
||||
if (!e.stale) stateRead = {valid: false, error: e};
|
||||
window.frameControlCenter?.unavailable(e);
|
||||
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
|
||||
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
|
||||
} finally {
|
||||
@@ -1614,6 +1674,12 @@ function refresh() {
|
||||
})();
|
||||
return refreshing;
|
||||
}
|
||||
async function refreshAfterAudio() {
|
||||
// A request already underway may contain pre-command audio. Preserve normal
|
||||
// poll deduplication, but drain it before requesting the command's readback.
|
||||
if (refreshing) await refreshing;
|
||||
return refresh();
|
||||
}
|
||||
|
||||
// ---- our VR HUD and optional third-party catalogue ----
|
||||
function renderPerformance(s) {
|
||||
@@ -1791,6 +1857,7 @@ function battery(b, power) {
|
||||
|
||||
function render(s) {
|
||||
renderPerformance(s);
|
||||
window.frameControlCenter?.status(s, stateReceivedAt, stateRead);
|
||||
battery(s.battery, s.power);
|
||||
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
||||
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
||||
@@ -2623,32 +2690,65 @@ document.body.addEventListener("click", async (e) => {
|
||||
// Installs run as server jobs: start one, then poll until it's done. The
|
||||
// bottom bar counts what's running; `installing` keeps re-rendered lists from
|
||||
// offering Install again for something already on its way.
|
||||
const installing = new Set();
|
||||
// Android installs wait their turn in the server's install queue (queue_job in
|
||||
// server.py); `queued` holds the ones still waiting.
|
||||
const installing = new Set(), queued = new Set();
|
||||
let jobsRunning = 0;
|
||||
function showJobs() {
|
||||
const waiting = queued.size, running = jobsRunning - waiting;
|
||||
$("jobs").hidden = !jobsRunning;
|
||||
$("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`;
|
||||
$("jobsText").textContent = [running ? `${running} install${running === 1 ? "" : "s"} running` : "",
|
||||
waiting ? `${waiting} queued` : ""].filter(Boolean).join(", ");
|
||||
}
|
||||
async function runJob(label, key, start, onProgress = () => {}) {
|
||||
installing.add(key); jobsRunning++; showJobs();
|
||||
log(label + "…");
|
||||
try {
|
||||
const { job } = await start();
|
||||
const started = await start();
|
||||
const { job } = started;
|
||||
log(started.queued ? started.message : label + "…");
|
||||
if (started.queued) { queued.add(key); showJobs(); }
|
||||
if (started.queued != null) loadQueue();
|
||||
for (let misses = 0; ;) {
|
||||
await new Promise(ok => setTimeout(ok, 1500));
|
||||
let j;
|
||||
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
|
||||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||||
if (j.queued !== queued.has(key)) { j.queued ? queued.add(key) : queued.delete(key); showJobs(); }
|
||||
onProgress(j);
|
||||
if (!j.done) continue;
|
||||
if (j.cancelled) { log(`${label}: removed from the install queue`); return null; }
|
||||
if (j.error) throw new Error(j.error);
|
||||
log(j.message, "ok"); toast(j.message);
|
||||
return j.result || {};
|
||||
}
|
||||
} catch (e) {
|
||||
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||||
} finally { installing.delete(key); jobsRunning--; showJobs(); }
|
||||
} finally { installing.delete(key); queued.delete(key); jobsRunning--; showJobs(); }
|
||||
}
|
||||
// The install queue as the server has it: also what an earlier visit to this page queued.
|
||||
let queueTimer;
|
||||
async function loadQueue() {
|
||||
clearTimeout(queueTimer);
|
||||
let jobs;
|
||||
try { ({ jobs } = await api("/api/queue")); } catch { jobs = null; }
|
||||
if (jobs) {
|
||||
$("installQueue").hidden = !jobs.length;
|
||||
$("queueCount").textContent = jobs.length ? `${jobs.length}` : "";
|
||||
$("queueList").innerHTML = jobs.map(j => `<div class="item"><div class="grow"><div class="t">${esc(j.label)}</div>
|
||||
<div class="s">${esc(j.queued ? j.stage || "Queued" : `${j.stage || "Starting"}${j.percent != null ? ` · ${Math.round(j.percent)}%` : ""}`)}</div></div>
|
||||
${j.queued ? `<button class="small" data-unqueue="${esc(j.job)}">Remove</button>` : `<span class="tag">Installing</span>`}</div>`).join("");
|
||||
}
|
||||
if (!jobs || jobs.length) queueTimer = setTimeout(loadQueue, 2000);
|
||||
}
|
||||
$("queueList").addEventListener("click", async e => {
|
||||
const b = e.target.closest("[data-unqueue]");
|
||||
if (!b) return;
|
||||
b.disabled = true;
|
||||
try { const d = await api("/api/queue", { action: "remove", job: b.dataset.unqueue }); toast(d.message); }
|
||||
catch (err) { toast(err.message, true); }
|
||||
loadQueue();
|
||||
});
|
||||
loadQueue();
|
||||
const POWER = { suspend: ["Put the Frame to sleep?", "Sleep"], reboot: ["Restart the Frame?", "Restart"],
|
||||
poweroff: ["Shut the Frame down?", "Shut down"] };
|
||||
function askPower(what) {
|
||||
@@ -3317,6 +3417,7 @@ async function installStoreApp() {
|
||||
if(existing&&!existing.done)return;
|
||||
const state={stage:'Downloading',percent:null,done:false};sourceState.jobs.set(key,state);updateInstall();
|
||||
const result=await runJob(`Install ${o.name}`,key,()=>api('/api/sources/install',{source:o.source,id:o.id,version_code:o.version_code??null}),j=>{Object.assign(state,{stage:j.stage||'Downloading',percent:j.percent??null,error:j.error});updateInstall();});
|
||||
if(result===null){sourceState.jobs.delete(key);updateInstall();return;} // removed from the install queue
|
||||
Object.assign(state,{done:true,result,error:result?null:state.error||"We couldn't finish the installation. Try again."});updateInstall();
|
||||
if(result)loadAndroid();
|
||||
}
|
||||
@@ -3346,7 +3447,19 @@ async function addSource(body) {
|
||||
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
|
||||
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
|
||||
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
|
||||
document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();});
|
||||
function showAndroidTab(tab) {
|
||||
const browse = tab === 'browse';
|
||||
$('appStore').hidden = !browse; $('androidLibrary').hidden = browse;
|
||||
$('repNew').hidden = browse; $('sourcesOpen').hidden = !browse;
|
||||
document.querySelectorAll('[data-store-tab]').forEach(button => {
|
||||
const selected = button.dataset.storeTab === tab;
|
||||
button.classList.toggle('on', selected); button.setAttribute('aria-pressed', String(selected));
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick = () => {
|
||||
showAndroidTab(button.dataset.storeTab);
|
||||
if (button.dataset.storeTab === 'library') loadAndroid();
|
||||
});
|
||||
loadSources().then(()=>searchSources());
|
||||
|
||||
// ---- Android apps (own Lepton instance each) + catalogue ----
|
||||
@@ -3408,7 +3521,7 @@ async function loadQuest() {
|
||||
if (a.meta) bits.push("Meta app: needs Meta's services");
|
||||
return `<div class="item"><div class="grow"><div class="t">${esc(a.package)}</div><div class="s">${esc(bits.join(" · "))}</div></div>
|
||||
${installed.has(a.package) ? `<span class="tag">On the Frame</span>` : ""}
|
||||
<button class="small" data-quest-import="${esc(a.package)}"${installing.has("quest:" + a.package) ? " disabled" : ""}>${installed.has(a.package) ? "Import again" : "Import"}</button></div>`;
|
||||
<button class="small" data-quest-import="${esc(a.package)}"${installing.has("quest:" + a.package) ? " disabled" : ""}>${queued.has("quest:" + a.package) ? "Queued" : installed.has(a.package) ? "Import again" : "Import"}</button></div>`;
|
||||
}).join("") : `<div class="sub">No games or apps you installed are on this Quest.</div>`;
|
||||
}
|
||||
$("questScan").onclick = loadQuest;
|
||||
@@ -3417,14 +3530,16 @@ $("questApps").addEventListener("click", e => {
|
||||
if (!b) return;
|
||||
const pkg = b.dataset.questImport, key = "quest:" + pkg;
|
||||
if (installing.has(key)) return;
|
||||
b.disabled = true;
|
||||
b.disabled = true; b.textContent = "Queued";
|
||||
const bar = $("questProgress");
|
||||
bar.hidden = false; bar.value = 0;
|
||||
// Several can be queued: the bar and the status line follow whichever is installing.
|
||||
runJob(`Import ${pkg} from Quest`, key,
|
||||
() => api("/api/quest", { action: "import", package: pkg, serial: quest.serial, data: $("questData").checked, allowDowngrade: $("questOlder").checked }),
|
||||
j => { if (j.stage) $("questState").textContent = `${pkg}: ${j.stage}`; if (j.percent != null) bar.value = j.percent; })
|
||||
.then(r => { bar.hidden = true; if (r && r.app) { $("questState").textContent = r.message || `Imported ${pkg}`; loadAndroid(); }
|
||||
else { b.disabled = false; $("questState").textContent = `${pkg} wasn't imported; the reason is in the message log below.`; } });
|
||||
j => { if (j.queued || j.done) return; b.textContent = "Importing…"; bar.hidden = false; bar.dataset.owner = key;
|
||||
if (j.stage) $("questState").textContent = `${pkg}: ${j.stage}`; bar.value = j.percent ?? 0; })
|
||||
.then(r => { if (bar.dataset.owner === key) bar.hidden = true; if (r && r.app) { b.textContent = "Imported"; $("questState").textContent = r.message || `Imported ${pkg}`; loadAndroid(); }
|
||||
else { b.disabled = false; b.textContent = "Import";
|
||||
if (r !== null) $("questState").textContent = `${pkg} wasn't imported; the reason is in the message log below.`; } });
|
||||
});
|
||||
|
||||
// ---- My Meta library, Experimental (frame_meta.py): the person's own token, games they own ----
|
||||
@@ -3473,11 +3588,12 @@ function renderMetaApps() {
|
||||
const verdict = v ? `<div class="s ${v[0]}" title="${esc((a.verdict.lines || []).join("\n"))}">Community: ${v[1]}</div>` : "";
|
||||
const r = metaLib.results.get(a.package);
|
||||
const result = r ? `<div class="s">${esc(r)}</div>` : "";
|
||||
const busy = installing.has("meta:" + a.id) ? " disabled" : "";
|
||||
const busy = installing.has("meta:" + a.id) ? " disabled" : "", waits = queued.has("meta:" + a.id);
|
||||
const on = installed.has(a.package);
|
||||
const can = a.package && !a.problem;
|
||||
return `<div class="item"><div class="grow"><div class="t">${esc(a.title)}</div><div class="s" title="${esc(line)}">${esc(line)}</div>${problem}${skipped}${verdict}${result}</div>
|
||||
${on ? `<span class="tag">On the Frame</span>` : ""}
|
||||
${waits ? `<span class="tag">Queued</span>` : ""}
|
||||
${can ? `<button class="small" data-meta-install="${esc(a.id)}"${busy}>${on ? "Install again" : "Install"}</button>
|
||||
<button class="small" data-meta-install="${esc(a.id)}" data-test="1"${busy} title="Install, start it, watch it for about 30 s and record whether it ran">Install and test</button>` : ""}
|
||||
${on ? `<button class="small" data-meta-test="${esc(a.package)}"${busy} title="Start it again, watch it for about 30 s and record whether it ran">Test again</button>` : ""}</div>`;
|
||||
@@ -3569,13 +3685,15 @@ $("metaApps").addEventListener("click", async e => {
|
||||
if (!app || installing.has("meta:" + app.id)) return;
|
||||
if (test && !confirm(`Install and test ${app.title}?\n\nFrame Control starts it in the headset (twice if it has game data to add), `
|
||||
+ "watches it for about 30 seconds and records whether it ran.")) return;
|
||||
b.disabled = true; bar.hidden = false; bar.value = 0;
|
||||
b.disabled = true;
|
||||
// Several can be queued: the bar and the status line follow whichever is installing.
|
||||
const r = await runJob(`${test ? "Install and test" : "Install"} ${app.title} from Meta`, "meta:" + app.id,
|
||||
() => api("/api/meta", { action: "install", app: app.id, test, allowDowngrade: $("metaOlder").checked }),
|
||||
j => { if (j.stage) $("metaState").textContent = `${app.title}: ${j.stage}`; if (j.percent != null) bar.value = j.percent; });
|
||||
bar.hidden = true;
|
||||
j => { renderMetaApps(); if (j.queued || j.done) return; bar.hidden = false; bar.dataset.owner = "meta:" + app.id;
|
||||
if (j.stage) $("metaState").textContent = `${app.title}: ${j.stage}`; bar.value = j.percent ?? 0; });
|
||||
if (bar.dataset.owner === "meta:" + app.id) bar.hidden = true; // not while another install still shows its progress
|
||||
if (r && r.message) { $("metaState").textContent = r.message; metaLib.results.set(app.package, r.test ? r.test.message : r.message); }
|
||||
else $("metaState").textContent = `${app.title} wasn't installed; the reason is in the message log below.`;
|
||||
else if (r !== null) $("metaState").textContent = `${app.title} wasn't installed; the reason is in the message log below.`;
|
||||
loadAndroid(); renderMetaApps(); if (test) loadReports();
|
||||
});
|
||||
loadMeta();
|
||||
@@ -4179,8 +4297,8 @@ $("macview").onclick = async e => {
|
||||
|
||||
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
|
||||
const PAGES = ["home", "games", "android", "tools"];
|
||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
|
||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", vrTools: "home", comfort: "home", pad: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||
display: "android", androidLibrary: "android", appStore: "android", transfer: "tools", media: "tools", artworkSettings: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", mailingList: "tools", panels: "tools" };
|
||||
let page = "home";
|
||||
function showPage() {
|
||||
const id = location.hash.slice(1);
|
||||
@@ -4190,6 +4308,8 @@ function showPage() {
|
||||
document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page));
|
||||
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
|
||||
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
|
||||
if (id === "display" || id === "androidLibrary") showAndroidTab("library");
|
||||
else if (id === "appStore") showAndroidTab("browse");
|
||||
const section = !PAGES.includes(id) && id && $(id);
|
||||
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
||||
if (page === "tools") { loadMacView(); loadPanelSwitcher(); }
|
||||
@@ -4430,7 +4550,6 @@ async function offerTest(m) {
|
||||
|
||||
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
|
||||
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
|
||||
let privacyNoticeShown = false; // this visit: then the contact prompt waits for another one
|
||||
function renderTelemetry(s) {
|
||||
Object.assign(telemetry, s);
|
||||
setRepHint();
|
||||
@@ -4441,7 +4560,6 @@ function renderTelemetry(s) {
|
||||
: "Nothing sent yet.";
|
||||
const showNotice = !s.blocked && !s.noticeShown && s.usage;
|
||||
$("privacyNotice").hidden = !showNotice;
|
||||
if (showNotice) privacyNoticeShown = true;
|
||||
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
|
||||
}
|
||||
async function loadTelemetry() {
|
||||
@@ -4455,24 +4573,25 @@ $("tUsage").onchange = e => setTelemetry({ usage: e.target.checked });
|
||||
$("tCompat").onchange = e => setTelemetry({ compat: e.target.checked });
|
||||
$("tDiag").onchange = e => setTelemetry({ diagnostics: e.target.checked });
|
||||
$("tSentBox").ontoggle = () => { if ($("tSentBox").open) loadTelemetry(); };
|
||||
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; };
|
||||
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; checkContactPrompt(); };
|
||||
$("noticeMore").onclick = async () => {
|
||||
$("privacyNotice").hidden = true;
|
||||
checkContactPrompt();
|
||||
await setTelemetry({ compat: true, diagnostics: true });
|
||||
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
|
||||
};
|
||||
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
|
||||
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; checkContactPrompt(); };
|
||||
const telemetryLoaded = loadTelemetry();
|
||||
|
||||
// ---- contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
|
||||
// ---- mailing list and contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
|
||||
const contact = { email: "", updates: false, followup: false };
|
||||
function renderContact(s) {
|
||||
Object.assign(contact, s);
|
||||
$("cEmail").value = s.email; $("cUpdates").checked = s.updates; $("cFollowup").checked = s.followup;
|
||||
$("cRemove").hidden = !s.email;
|
||||
$("cStatus").textContent = s.waiting ? "Saved here; it's sent to the maintainer when Frame Control can reach PostHog."
|
||||
: s.email ? `Saved. ${s.email} may get ${[s.updates && "update notices", s.followup && "follow-up questions"].filter(Boolean).join(" and ")}. Remove it any time.`
|
||||
: "Sent privately to the Frame Control maintainer, never shared or published. Updates are occasional release notices; follow-up questions are mainly about problem reports you send.";
|
||||
: s.email ? `Saved. ${s.email} may get ${[s.updates && "mailing list emails", s.followup && "follow-up questions"].filter(Boolean).join(" and ")}. Remove it any time.`
|
||||
: "Sent privately to the Frame Control maintainer, never shared or published. The mailing list is occasional news about new features and updates; follow-up questions are mainly about problem reports you send.";
|
||||
}
|
||||
async function saveContact(change) {
|
||||
const s = await api("/api/contact", change);
|
||||
@@ -4484,25 +4603,26 @@ async function loadContact() {
|
||||
try { renderContact(await api("/api/contact")); } catch { return; }
|
||||
checkContactPrompt();
|
||||
}
|
||||
// One time only, only once the Frame has connected, and never in a visit that showed the privacy
|
||||
// notice (two asks in a row is nagging): checked at load and whenever the Frame connects.
|
||||
// The mailing list offer: once, on first launch, and never on screen with the first-run privacy
|
||||
// notice (it waits for that to be closed). Checked at load, when the notice closes and when the
|
||||
// Frame connects (in case the server couldn't be asked earlier).
|
||||
async function checkContactPrompt() {
|
||||
await telemetryLoaded;
|
||||
if (privacyNoticeShown || !$("contactNotice").hidden) return;
|
||||
if (!$("privacyNotice").hidden || !$("contactNotice").hidden) return;
|
||||
let s;
|
||||
try { s = await api("/api/contact"); } catch { return; }
|
||||
if (!s.showPrompt || privacyNoticeShown || !$("contactNotice").hidden) return;
|
||||
if (!s.showPrompt || !$("privacyNotice").hidden || !$("contactNotice").hidden) return;
|
||||
$("contactNotice").hidden = false;
|
||||
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
|
||||
}
|
||||
$("contactNotice").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
if (!$("cpUpdates").checked && !$("cpFollowup").checked) { $("cpMsg").textContent = "Tick at least one, or choose No thanks."; return; }
|
||||
$("cpSave").disabled = true;
|
||||
try {
|
||||
await saveContact({ email: $("cpEmail").value, updates: $("cpUpdates").checked, followup: $("cpFollowup").checked, fromPrompt: true });
|
||||
// Joining is the mailing list only; follow-up questions keep whatever was agreed before.
|
||||
await saveContact({ email: $("cpEmail").value, updates: true, followup: contact.followup && contact.email.toLowerCase() === $("cpEmail").value.trim().toLowerCase(), fromPrompt: true });
|
||||
$("contactNotice").hidden = true;
|
||||
toast("Thanks! Change or remove it any time in Privacy & updates.");
|
||||
toast("Thanks for joining! Unsubscribe any time in Privacy & updates.");
|
||||
} catch (err) { $("cpMsg").textContent = `Couldn't save: ${err.message}`; }
|
||||
finally { $("cpSave").disabled = false; }
|
||||
};
|
||||
@@ -4525,6 +4645,13 @@ $("cRemove").onclick = async () => {
|
||||
catch (err) { toast(`Couldn't remove it: ${err.message}`, true); }
|
||||
};
|
||||
loadContact();
|
||||
// Help → Join the Mailing List… (desktop app): the Privacy & updates section, where joining later lives.
|
||||
function openMailingList() {
|
||||
location.hash = "#mailingList";
|
||||
showPage(); // also when the hash was already this, so hashchange doesn't fire
|
||||
$("cEmail").focus({ preventScroll: true });
|
||||
}
|
||||
if (window.frameApp && window.frameApp.onJoinMailingList) window.frameApp.onJoinMailingList(openMailingList);
|
||||
function pageEvent(event, properties) {
|
||||
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
|
||||
}
|
||||
@@ -4887,8 +5014,11 @@ function onConnection(s) {
|
||||
if (link.device !== null && devId !== link.device) {
|
||||
log(`Now using ${s.device.name}`, "ok");
|
||||
state = null;
|
||||
stateReceivedAt = null;
|
||||
stateRead = {valid: false, error: null};
|
||||
online = null;
|
||||
devGen++; // answers still on their way are about the other headset
|
||||
window.frameControlCenter?.reset();
|
||||
refreshing = null; // and the next status check must be a new one
|
||||
if (live) toggleLive(false); // the other headset's video or captures
|
||||
clearTimeout(volTimer); volTimer = null; // a volume change still waiting to be sent
|
||||
@@ -4995,7 +5125,7 @@ function setUpActive() {
|
||||
$("connSetup").onclick = setUpActive;
|
||||
|
||||
// ---- Devices tab ----
|
||||
PAGES.push("devices");
|
||||
PAGES.push("devices", "control");
|
||||
let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection
|
||||
async function loadDevices() {
|
||||
if (!link.live) return;
|
||||
@@ -5303,11 +5433,23 @@ if (window.frameApp && window.frameApp.onUseDevice) window.frameApp.onUseDevice(
|
||||
window.addEventListener("hashchange", () => { if (location.hash === "#devices") loadDevices(); });
|
||||
showPage(); // #devices is a page now
|
||||
api("/api/host").then(h => {
|
||||
Object.assign(HOST, h);
|
||||
window.frameControlCenter?.host(h);
|
||||
if (h.mobile) return;
|
||||
$("offSetup").hidden = false; $("offSetup").onclick = setUpActive; // the server opens it in a terminal
|
||||
watchConnection();
|
||||
}).catch(() => watchConnection());
|
||||
</script>
|
||||
<script src="/artwork-settings.js"></script>
|
||||
<script src="/control-center.js"></script>
|
||||
<script>
|
||||
window.frameControlCenter = FrameControlCenter.mount({document, api, refresh, refreshAudio: refreshAfterAudio,
|
||||
getContext: () => ({sample: state, online, generation: devGen, device: window.connDevice}),
|
||||
openConnection: () => $("conn").click(), platform: window.frameApp?.platform || ""});
|
||||
window.frameControlCenter.host(HOST);
|
||||
if (state) window.frameControlCenter.status(state, stateReceivedAt, stateRead);
|
||||
else if (stateRead.error) window.frameControlCenter.unavailable(stateRead.error);
|
||||
if (online === false) window.frameControlCenter.connection(false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+123
-32
@@ -137,7 +137,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||
# install's clean-up) to the other headset.
|
||||
_work_lock = threading.Lock()
|
||||
_work = [0]
|
||||
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
|
||||
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -286,40 +286,120 @@ def backfill_art(apps=(), titles=()):
|
||||
return True
|
||||
|
||||
|
||||
def start_job(label, work, progress=False):
|
||||
"""Run work() in the background. It returns a dict with a "message"."""
|
||||
def _new_job_locked(label, **fields):
|
||||
now = time.time()
|
||||
with _jobs_lock:
|
||||
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
|
||||
del _jobs[j]
|
||||
job = secrets.token_hex(8)
|
||||
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
|
||||
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
|
||||
del _jobs[j]
|
||||
job = secrets.token_hex(8)
|
||||
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now, **fields}
|
||||
return job
|
||||
|
||||
|
||||
def _run_job(job, label, work, progress):
|
||||
def report(stage, percent=None):
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(stage=stage, percent=percent)
|
||||
|
||||
def run():
|
||||
fields = {}
|
||||
try:
|
||||
result = work(report) if progress else work()
|
||||
fields = {"message": result.get("message") or f"{label}: done", "result": result}
|
||||
except (Failure, frame_android.FrameError) as e:
|
||||
fields = {"error": unreachable(str(e)) or str(e)}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
except SourceError as e: # already user-readable, and about a store, not the Frame
|
||||
fields = {"error": str(e)}
|
||||
except Exception as e:
|
||||
fields = {"error": f"{type(e).__name__}: {e}"}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
finally:
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
fields = {}
|
||||
try:
|
||||
result = work(report) if progress else work()
|
||||
fields = {"message": result.get("message") or f"{label}: done", "result": result}
|
||||
except (Failure, frame_android.FrameError) as e:
|
||||
fields = {"error": unreachable(str(e)) or str(e)}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
except SourceError as e: # already user-readable, and about a store, not the Frame
|
||||
fields = {"error": str(e)}
|
||||
except Exception as e:
|
||||
fields = {"error": f"{type(e).__name__}: {e}"}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
finally:
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
|
||||
busy_thread(run).start()
|
||||
|
||||
def start_job(label, work, progress=False):
|
||||
"""Run work() in the background. It returns a dict with a "message"."""
|
||||
with _jobs_lock:
|
||||
job = _new_job_locked(label)
|
||||
busy_thread(_run_job, job, label, work, progress).start()
|
||||
return {"message": f"{label}…", "job": job}
|
||||
|
||||
|
||||
# ---- The install queue: Android games and apps, one after another ----------
|
||||
#
|
||||
# Installs from the Android store, Import from my Quest and My Meta library each copy
|
||||
# a game into a Lepton instance, and each refuses to start while another is running.
|
||||
# Queued, the page can line up several: one worker runs them in order, each as a
|
||||
# normal job (/api/job) that reads "queued" until its turn. A queued install can be
|
||||
# removed until it starts; each runs on the headset it was queued for, or not at all.
|
||||
|
||||
_queue = [] # [job, label, work, progress, headset id], in order; the running one is not here
|
||||
_queue_state = {"worker": False, "running": None}
|
||||
|
||||
|
||||
def _restage_locked():
|
||||
ahead = 1 if _queue_state["running"] else 0
|
||||
for i, (job, *_) in enumerate(_queue):
|
||||
n = ahead + i
|
||||
_jobs[job]["stage"] = f"Queued: {n} install{'' if n == 1 else 's'} before it" if n else "Queued: next"
|
||||
|
||||
|
||||
def queue_job(label, work, progress=False):
|
||||
"""Like start_job, but runs after every install queued before it."""
|
||||
device = LINK.active_device()["id"] if LINK else None
|
||||
with _jobs_lock:
|
||||
job = _new_job_locked(label, queued=True, stage=None, percent=None)
|
||||
_queue.append([job, label, work, progress, device])
|
||||
_restage_locked()
|
||||
ahead = len(_queue) - 1 + (1 if _queue_state["running"] else 0)
|
||||
start, _queue_state["worker"] = not _queue_state["worker"], True
|
||||
if start:
|
||||
threading.Thread(target=_drain_queue, daemon=True).start()
|
||||
return {"message": f"{label}: queued, {ahead} before it" if ahead else f"{label}…", "job": job, "queued": ahead}
|
||||
|
||||
|
||||
def _drain_queue():
|
||||
while True:
|
||||
with _jobs_lock:
|
||||
if not _queue:
|
||||
_queue_state.update(worker=False, running=None)
|
||||
return
|
||||
job, label, work, progress, device = _queue.pop(0)
|
||||
_queue_state["running"] = job
|
||||
_jobs[job].update(queued=False, stage=None)
|
||||
_restage_locked()
|
||||
|
||||
def guarded(*report, work=work, device=device):
|
||||
with working(device): # refused if the app switched headsets since it was queued
|
||||
return work(*report)
|
||||
_run_job(job, label, guarded, progress)
|
||||
|
||||
|
||||
def install_queue():
|
||||
"""The running install and those waiting, in order."""
|
||||
with _jobs_lock:
|
||||
ids = ([_queue_state["running"]] if _queue_state["running"] else []) + [q[0] for q in _queue]
|
||||
return {"jobs": [{"job": j, "label": _jobs[j]["label"], "queued": bool(_jobs[j].get("queued")),
|
||||
"stage": _jobs[j].get("stage"), "percent": _jobs[j].get("percent")}
|
||||
for j in ids if j in _jobs]}
|
||||
|
||||
|
||||
def queue_action(body):
|
||||
"""{"action": "remove", "job"}: take a waiting install out of the queue."""
|
||||
if body.get("action") != "remove":
|
||||
raise Failure("unknown action", 400)
|
||||
job = body.get("job")
|
||||
with _jobs_lock:
|
||||
for i, q in enumerate(_queue):
|
||||
if q[0] == job:
|
||||
del _queue[i]
|
||||
_jobs[job].update(done=True, queued=False, cancelled=True, stage=None, time=time.time(),
|
||||
error="Removed from the install queue")
|
||||
_restage_locked()
|
||||
return {"message": f"Removed {_jobs[job]['label']} from the install queue"}
|
||||
raise Failure("that install isn't waiting in the queue (it may have started or finished)", 409)
|
||||
|
||||
|
||||
def job_status(query):
|
||||
with _jobs_lock:
|
||||
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
|
||||
@@ -701,9 +781,14 @@ def set_volume(body):
|
||||
# Validate everything before touching the headset.
|
||||
level = None
|
||||
if "level" in body:
|
||||
level = float(body["level"])
|
||||
if not 0 <= level <= 1:
|
||||
value = body["level"]
|
||||
if type(value) not in (int, float) or not 0 <= value <= 1:
|
||||
raise Failure("level must be 0..1", 400)
|
||||
level = float(value)
|
||||
if not body or set(body) - {"level", "muted"}:
|
||||
raise Failure("Provide level or muted", 400)
|
||||
if "muted" in body and type(body["muted"]) is not bool:
|
||||
raise Failure("muted must be true or false", 400)
|
||||
if "muted" in body:
|
||||
ssh(f"wpctl set-mute @DEFAULT_AUDIO_SINK@ {1 if body['muted'] else 0}")
|
||||
if level is not None:
|
||||
@@ -1293,7 +1378,7 @@ def android(body):
|
||||
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
|
||||
message = f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel."
|
||||
return {"message": f"{message} {frame_android.layer_note(m)}".strip(), "app": m}
|
||||
return start_job(f"Install {pkg}", work)
|
||||
return queue_job(f"Install {pkg}", work)
|
||||
if action == "refresh-art":
|
||||
if not pkg and not body.get("all"):
|
||||
raise Failure('choose a package or all apps', 400)
|
||||
@@ -1364,7 +1449,7 @@ def quest(body):
|
||||
if type(data) is not bool or type(older) is not bool:
|
||||
raise Failure("data and allowDowngrade must be true or false", 400)
|
||||
ensure_master()
|
||||
return start_job(f"Import {pkg} from Quest",
|
||||
return queue_job(f"Import {pkg} from Quest",
|
||||
lambda report: frame_quest.import_game(pkg, serial, data=data, progress=report,
|
||||
allow_downgrade=older), progress=True)
|
||||
|
||||
@@ -1445,7 +1530,7 @@ def meta(body):
|
||||
raise frame_meta.MetaError(frame_meta.redact(e, 1200)) from None
|
||||
except Exception as e: # never let an unexpected error's text carry the token
|
||||
raise frame_meta.MetaError(frame_meta.redact(f"{type(e).__name__}: {e}", 600)) from None
|
||||
return start_job(f"{'Install and test' if test else 'Install'} {app_id} from Meta", work, progress=True)
|
||||
return queue_job(f"{'Install and test' if test else 'Install'} {app_id} from Meta", work, progress=True)
|
||||
if action == "test":
|
||||
pkg = str(body.get("package", ""))
|
||||
if not frame_android.PKG_RE.match(pkg):
|
||||
@@ -2339,7 +2424,7 @@ def source_install(body):
|
||||
raise SourceError('This source is disabled')
|
||||
except SourceError as e:
|
||||
raise Failure(str(e), 400)
|
||||
return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
|
||||
return queue_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
|
||||
|
||||
|
||||
def source_manage(body):
|
||||
@@ -2382,7 +2467,7 @@ POST = {
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
@@ -2559,6 +2644,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(frame_steamgriddb.settings())
|
||||
elif path == "/artwork-settings.js":
|
||||
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
|
||||
elif path == "/control-center.js":
|
||||
self.send_bytes((HERE / 'control-center.js').read_bytes(), 'text/javascript; charset=utf-8')
|
||||
elif path == "/control-center.css":
|
||||
self.send_bytes((HERE / 'control-center.css').read_bytes(), 'text/css; charset=utf-8')
|
||||
elif path == "/assistant":
|
||||
page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
|
||||
self.send_bytes(page.encode(), "text/html; charset=utf-8")
|
||||
@@ -2622,6 +2711,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
|
||||
elif path == "/api/job":
|
||||
self.send_json(job_status(url.query))
|
||||
elif path == "/api/queue":
|
||||
self.send_json(install_queue())
|
||||
elif path == "/api/android/displays":
|
||||
self.send_json(android_displays())
|
||||
elif path == "/api/android/reports":
|
||||
|
||||
Reference in new issue
Block a user