mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Merge pull request #39 from saphid/family-comfort
Family and comfort: safe timers, casting, alerts and breaks
This commit is contained in:
18 files changed
+1028
-32
No files matched your search
@@ -93,7 +93,11 @@ SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
Nothing is installed on the Frame for any of this: the app uses what SteamOS
|
The optional [Family and comfort](docs/family-comfort.md) card adds session
|
||||||
|
limits, breaks, local alerts and one-click casting. A session copies a small
|
||||||
|
Frame Control worker into your headset user account.
|
||||||
|
|
||||||
|
For the other features, nothing is installed on the Frame: the app uses what SteamOS
|
||||||
already ships (sideloading a game copies Valve's own devkit scripts to
|
already ships (sideloading a game copies Valve's own devkit scripts to
|
||||||
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
|
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -1,7 +1,7 @@
|
|||||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||||
// a free loopback port and shows it in a native window. The server does all the
|
// a free loopback port and shows it in a native window. The server does all the
|
||||||
// work over the `frame` SSH alias; this file only hosts it.
|
// work over the `frame` SSH alias; this file only hosts it.
|
||||||
const { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, shell } = require("electron");
|
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
|
||||||
const { execFile, spawn } = require("child_process");
|
const { execFile, spawn } = require("child_process");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -254,6 +254,21 @@ ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
|||||||
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
||||||
|
|
||||||
|
ipcMain.handle("comfort:notify", (e, message) => {
|
||||||
|
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
||||||
|
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const notification = new Notification({title: "Frame Control", body: message});
|
||||||
|
const timer = setTimeout(() => reject(new Error("Notification delivery was not confirmed. Check system notification settings.")), 5000);
|
||||||
|
notification.once("show", () => { clearTimeout(timer); resolve(true); });
|
||||||
|
notification.once("failed", (_event, error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("Notification delivery failed. Check system notification settings: " + error));
|
||||||
|
});
|
||||||
|
notification.show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// frame-control://install links from websites (docs/web-install.md). They can
|
// frame-control://install links from websites (docs/web-install.md). They can
|
||||||
// arrive before the window or server exists (macOS open-url on a cold launch),
|
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||||
// so they wait here until the page asks for them. The page checks the link with
|
// so they wait here until the page asks for them. The page checks the link with
|
||||||
@@ -376,7 +391,7 @@ function createWindow() {
|
|||||||
title: "Frame Control", backgroundColor: BG, show: false,
|
title: "Frame Control", backgroundColor: BG, show: false,
|
||||||
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
|
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
|
||||||
: { icon: path.join(__dirname, "build", "icon.png") }),
|
: { icon: path.join(__dirname, "build", "icon.png") }),
|
||||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
|
||||||
preload: path.join(__dirname, "preload.js") },
|
preload: path.join(__dirname, "preload.js") },
|
||||||
});
|
});
|
||||||
win.once("ready-to-show", () => win.show());
|
win.once("ready-to-show", () => win.show());
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("frameApp", {
|
contextBridge.exposeInMainWorld("frameApp", {
|
||||||
|
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||||
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
|
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Family and comfort
|
||||||
|
|
||||||
|
Frame Control's Home tab has a **Family and comfort** card, on desktop and
|
||||||
|
on iPhone. No third-party notification or parental-control app is needed.
|
||||||
|
This is Frame Control code using Python, Steam and SteamVR already on the Frame.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
Set a limit of 1–240 minutes, optional break and check-in intervals, then
|
||||||
|
**Start session**. Break and check-in intervals of 0 turn those reminders off.
|
||||||
|
**Cancel session** cancels the timer and monitoring without changing the game.
|
||||||
|
Cancel before starting a session with different settings.
|
||||||
|
|
||||||
|
The Frame shows a one-minute warning, then opens Steam Home in its dashboard.
|
||||||
|
**Games stay running**: save and pause before the limit. Some games pause when
|
||||||
|
the dashboard opens; others do not. There is no kill, power-off, Steam restart,
|
||||||
|
account restriction or parental lock. The wearer can return to the game.
|
||||||
|
|
||||||
|
**Documented implementation:** the timer is a single, opt-in Python worker in
|
||||||
|
the Frame user's account. Desktop and iPhone share its state. It keeps going
|
||||||
|
when the companion disconnects, closes or is suspended. It exits after
|
||||||
|
completion or cancellation (normally within five seconds). Cancellation waits
|
||||||
|
for any in-flight SteamVR action to finish within its timeout; it is not a boot
|
||||||
|
service. A Frame reboot invalidates the session. Suspend counts toward the
|
||||||
|
limit, using Linux's boot-time clock. If a warning was delayed by suspend or a
|
||||||
|
SteamVR failure, Home waits until at least a full minute after a successful
|
||||||
|
warning. A failed Home transition remains active and retries, with an error
|
||||||
|
shown in the companion. A stale worker is reported as unverified enforcement.
|
||||||
|
|
||||||
|
## Alerts and breaks
|
||||||
|
|
||||||
|
During a session, battery, overheating and check-in alerts go to connected
|
||||||
|
companions. Break reminders and session warnings also appear on the headset.
|
||||||
|
|
||||||
|
- **Low battery:** 15% or below while discharging. One alert until charging or
|
||||||
|
recovery to 20%, so values around 15% do not produce repeated notifications.
|
||||||
|
- **Overheating:** a thermal zone reaches its own kernel-reported hot/critical
|
||||||
|
trip, or the battery reports `Overheat`. Missing sensors mean unknown, not
|
||||||
|
safe. These are status alerts, not medical advice or an extra thermal governor.
|
||||||
|
- **Check in:** an alert after the chosen number of active minutes.
|
||||||
|
- **Breaks:** a SteamVR reminder and companion notification at the chosen interval.
|
||||||
|
|
||||||
|
**Inferred:** SteamVR activity levels 1 and 2 are a useful proxy for use, not
|
||||||
|
proof someone is wearing the headset. Inactive readings reset continuous use;
|
||||||
|
missing readings add no time. Long gaps count at most 30 seconds. Breaks and
|
||||||
|
check-ins are distinct from the elapsed-time session limit.
|
||||||
|
|
||||||
|
Click **Enable / test notifications** on each companion. iOS asks for permission;
|
||||||
|
macOS, Windows and Linux follow their notification settings. The page also shows
|
||||||
|
recent events and errors. Keep Frame Control open and connected for companion
|
||||||
|
alerts. **Phone alerts are local, not push notifications:** iOS suspension,
|
||||||
|
force-quit or a lost SSH connection prevents live delivery. Old alerts are not
|
||||||
|
replayed as a notification burst on reconnect. Headset warnings and the session
|
||||||
|
limit continue without the phone. A physical iPhone's background delivery has
|
||||||
|
not been verified and is not guaranteed.
|
||||||
|
|
||||||
|
## Casting
|
||||||
|
|
||||||
|
**Cast headset view** starts the existing headset Live view and requests full
|
||||||
|
screen where supported. Show that screen to people in the room, or use the
|
||||||
|
computer/phone's own screen mirroring. It creates no new stream transport,
|
||||||
|
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
||||||
|
available. The image includes private content visible to the wearer.
|
||||||
|
|
||||||
|
## What is installed
|
||||||
|
|
||||||
|
The shared authenticated `/api/comfort` endpoint copies three bundled Python
|
||||||
|
files to `~/.cache/frame-control/comfort/<content-hash>/`. Session state and
|
||||||
|
locks live in `~/.local/state/frame-control/comfort/`, with a private directory
|
||||||
|
and 0600 state file. There is no network listener or system service. Cancel a
|
||||||
|
session before removing these directories. The iPhone's normal server still
|
||||||
|
exits on disconnect; the explicitly started comfort worker is the exception.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
**Verified 2026-09-28**, SteamOS 0.4.1, build `20260925.6191901`: shipped
|
||||||
|
`/opt/steamvr/bin/linuxarm64/vrcmd --notify TEXT` reported success for a custom
|
||||||
|
reminder. Steam's CDP `SteamUIStore.Navigate('/library/home')` and
|
||||||
|
`SteamClient.OpenVR.VROverlay.ShowDashboard('valve.steam.gamepadui.main')`
|
||||||
|
opened Home while the running app ID stayed unchanged. Prior page and dashboard
|
||||||
|
visibility were restored. Kernel hot/critical trips and SteamVR activity were
|
||||||
|
read from the real device. No temperature or battery fault was induced.
|
||||||
|
|
||||||
|
**Verified locally:** deterministic fake-Frame tests cover late warnings,
|
||||||
|
failed warnings/Home actions, cancellation, activity gaps, thresholds, duplicate
|
||||||
|
suppression, reboot invalidation, shared session state and the exact Home
|
||||||
|
JavaScript. `python3 -m unittest discover -s tests` runs them. The iOS Simulator
|
||||||
|
build tests notification content and bounds. Physical iPhone delivery and
|
||||||
|
wearer-perceived headset notification visibility remain unverified.
|
||||||
|
|
||||||
|
**Verified end to end on the same Frame:** a two-minute session with no companion
|
||||||
|
connection for 135 seconds emitted its warning, break and check-in, then opened
|
||||||
|
Home. The running app ID was unchanged; the test restored the previous page and
|
||||||
|
dashboard visibility and confirmed the worker exited. Casting through the Home
|
||||||
|
shortcut decoded the existing headset stream at 30 fps.
|
||||||
|
|
||||||
|
**Verified on the iOS 26.5 Simulator:** connected to the real Frame, approved the
|
||||||
|
notification prompt, and saw the native Frame Control test banner. Seven iOS
|
||||||
|
tests passed.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Desktop and 390-pixel phone layouts had no horizontal overflow.
|
||||||
|
On macOS the development Electron app's real notification attempt was denied
|
||||||
|
(`UNErrorDomain` 1); the bridge now returns that failure instead of reporting
|
||||||
|
success. Successful macOS/Windows/Linux notification display remains unverified.
|
||||||
|
|
||||||
|
**Verified on the real Frame:** its naturally discharging 15% battery produced
|
||||||
|
one low-battery event during a short session; the test then cancelled the
|
||||||
|
session. Overheating alerts use fake sensor samples in tests: the shared
|
||||||
|
headset was not deliberately overheated.
|
||||||
|
|
||||||
|
**Verified 2026-09-29 on the same Frame:** a fresh one-minute session opened
|
||||||
|
Home more than 60 seconds after the successful warning. The test restored the
|
||||||
|
previous page and dashboard visibility. Local regression coverage now includes
|
||||||
|
slow notification delivery, a total Home-action timeout, failed worker startup,
|
||||||
|
unreadable saved state, malformed activity samples and notification UX: 173
|
||||||
|
Python tests passed. Desktop and 390-pixel layouts were checked again; system
|
||||||
|
notification-denial guidance stayed visible across polls. Initial event history
|
||||||
|
did not replay notifications, and only the latest new event was announced.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
+12
-1
@@ -26,7 +26,10 @@ as its transport too), so the desktop and phone share one code path. Android
|
|||||||
display settings use `podman exec` into each Lepton container instead of adb,
|
display settings use `podman exec` into each Lepton container instead of adb,
|
||||||
which the Frame doesn't have.
|
which the Frame doesn't have.
|
||||||
|
|
||||||
Nothing is left running on the Frame after the phone disconnects; the copied
|
The app server stops after the phone disconnects. An explicitly started
|
||||||
|
[comfort session](family-comfort.md) keeps its timer and headset reminders running
|
||||||
|
until the session ends or is cancelled; phone notifications require the app to
|
||||||
|
remain connected and running. The copied
|
||||||
files stay in `~/.cache/frame-control` (delete it any time).
|
files stay in `~/.cache/frame-control` (delete it any time).
|
||||||
|
|
||||||
## Pairing
|
## Pairing
|
||||||
@@ -108,3 +111,11 @@ running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
|||||||
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
|
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
|
||||||
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
|
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
|
||||||
don't.
|
don't.
|
||||||
|
|
||||||
|
## Family and comfort
|
||||||
|
|
||||||
|
The shared Home card sets session limits, breaks and check-ins, and offers
|
||||||
|
**Cast headset view**. **Enable / test notifications** requests iOS notification
|
||||||
|
permission and sends a local test. These are local notifications, not APNs push;
|
||||||
|
iOS background suspension can interrupt phone alerts. The headset timer still
|
||||||
|
runs. See [the behavior and verification limits](family-comfort.md).
|
||||||
@@ -170,3 +170,12 @@ assistant against an in-process HTTP endpoint with canned responses (no keys or
|
|||||||
external calls). `tests/e2e/test_agents.py` runs the MCP/HTTP/SSH path against the
|
external calls). `tests/e2e/test_agents.py` runs the MCP/HTTP/SSH path against the
|
||||||
fake Frame for approved installs, clipboard and file transfer. Headset Chromium
|
fake Frame for approved installs, clipboard and file transfer. Headset Chromium
|
||||||
rendering and real screenshots still need a device; see [agent evidence](agents.md#evidence-and-limits).
|
rendering and real screenshots still need a device; see [agent evidence](agents.md#evidence-and-limits).
|
||||||
|
|
||||||
|
## Family and comfort
|
||||||
|
|
||||||
|
`tests/test_comfort.py` uses an injected clock, fake headset sensor readings and
|
||||||
|
actions, plus a Node fake of Steam's Home API. It covers warnings before Home,
|
||||||
|
late/suspended sessions, cancellation, failed actions, duplicate alerts, reboot
|
||||||
|
invalidation, per-zone thermal trips and shared on-headset state. The server
|
||||||
|
guards reject invalid session settings before SSH. See
|
||||||
|
[real-device evidence and limits](family-comfort.md#verification).
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
|
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
|
||||||
84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; };
|
84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; };
|
||||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
|
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
|
||||||
|
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */; };
|
||||||
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
|
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
|
||||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
|
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
|
||||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
|
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = "<group>"; };
|
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = "<group>"; };
|
||||||
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
|
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
|
||||||
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
||||||
|
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfortNotificationTests.swift; sourceTree = "<group>"; };
|
||||||
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = "<group>"; };
|
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = "<group>"; };
|
||||||
F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -107,6 +109,7 @@
|
|||||||
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = {
|
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
|
||||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
||||||
);
|
);
|
||||||
path = FrameControlTests;
|
path = FrameControlTests;
|
||||||
@@ -274,6 +277,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
|
||||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import WebKit
|
import WebKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
/// The Frame Control page, served by the server on the headset, in a web view.
|
/// The Frame Control page, served by the server on the headset, in a web view.
|
||||||
/// window.frameApp (the same bridge the desktop app's preload.js provides) lets
|
/// window.frameApp (the same bridge the desktop app's preload.js provides) lets
|
||||||
@@ -48,6 +49,7 @@ struct WebShell: UIViewRepresentable {
|
|||||||
let installCb = null;
|
let installCb = null;
|
||||||
window.frameApp = {
|
window.frameApp = {
|
||||||
platform: "ios",
|
platform: "ios",
|
||||||
|
notify: (message, request) => call("notify", { message, request }),
|
||||||
readClipboard: () => call("readClipboard"),
|
readClipboard: () => call("readClipboard"),
|
||||||
setUpConnection: () => call("setUpConnection"),
|
setUpConnection: () => call("setUpConnection"),
|
||||||
open: (what) => call("open", what),
|
open: (what) => call("open", what),
|
||||||
@@ -58,13 +60,31 @@ struct WebShell: UIViewRepresentable {
|
|||||||
})();
|
})();
|
||||||
"""
|
"""
|
||||||
|
|
||||||
final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate {
|
final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate, UNUserNotificationCenterDelegate {
|
||||||
let model: AppModel
|
let model: AppModel
|
||||||
weak var web: WKWebView?
|
weak var web: WKWebView?
|
||||||
var loaded: URL?
|
var loaded: URL?
|
||||||
private var installReady = false
|
private var installReady = false
|
||||||
|
|
||||||
init(model: AppModel) { self.model = model }
|
init(model: AppModel) {
|
||||||
|
self.model = model
|
||||||
|
super.init()
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
|
completionHandler([.banner, .sound, .list])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func notificationContent(_ message: String) -> UNMutableNotificationContent? {
|
||||||
|
guard !message.isEmpty, message.count <= 500 else { return nil }
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Frame Control"
|
||||||
|
content.body = message
|
||||||
|
content.sound = .default
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: bridge
|
// MARK: bridge
|
||||||
|
|
||||||
@@ -76,6 +96,28 @@ struct WebShell: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
let arg = body["arg"]
|
let arg = body["arg"]
|
||||||
switch name {
|
switch name {
|
||||||
|
case "notify":
|
||||||
|
guard message.frameInfo.isMainFrame,
|
||||||
|
message.frameInfo.securityOrigin.host == "127.0.0.1",
|
||||||
|
let args = arg as? [String: Any], let text = args["message"] as? String,
|
||||||
|
let content = Self.notificationContent(text) else {
|
||||||
|
return replyHandler(nil, "Invalid notification")
|
||||||
|
}
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let send: (Bool, Error?) -> Void = { allowed, error in
|
||||||
|
guard allowed else {
|
||||||
|
return replyHandler(nil, error?.localizedDescription ?? "Notifications are off. Enable them in iOS Settings.")
|
||||||
|
}
|
||||||
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
|
center.add(request) { error in replyHandler(error == nil, error?.localizedDescription) }
|
||||||
|
}
|
||||||
|
if args["request"] as? Bool == true {
|
||||||
|
center.requestAuthorization(options: [.alert, .sound], completionHandler: send)
|
||||||
|
} else {
|
||||||
|
center.getNotificationSettings { settings in
|
||||||
|
send(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
case "readClipboard":
|
case "readClipboard":
|
||||||
replyHandler(UIPasteboard.general.string ?? "", nil)
|
replyHandler(UIPasteboard.general.string ?? "", nil)
|
||||||
case "setUpConnection":
|
case "setUpConnection":
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import XCTest
|
||||||
|
import UserNotifications
|
||||||
|
@testable import Frame_Control
|
||||||
|
|
||||||
|
final class ComfortNotificationTests: XCTestCase {
|
||||||
|
func testNotificationContentAndBounds() {
|
||||||
|
let content = WebShell.Coordinator.notificationContent("Time for a break")
|
||||||
|
XCTAssertEqual(content?.title, "Frame Control")
|
||||||
|
XCTAssertEqual(content?.body, "Time for a break")
|
||||||
|
XCTAssertNotNil(content?.sound)
|
||||||
|
XCTAssertNil(WebShell.Coordinator.notificationContent(""))
|
||||||
|
XCTAssertNil(WebShell.Coordinator.notificationContent(String(repeating: "x", count: 501)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Fake-Frame session clock/actions plus real helper serialization and sensor probes."""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||||
|
import frame_comfort as comfort
|
||||||
|
import frame_status as status
|
||||||
|
|
||||||
|
OPTIONS = {'action': 'start', 'minutes': 3, 'breakMinutes': 1, 'stillMinutes': 1,
|
||||||
|
'batteryAlert': True, 'heatAlert': True}
|
||||||
|
|
||||||
|
|
||||||
|
class SessionTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.s = comfort.new_session(OPTIONS, 0, 'boot-one')
|
||||||
|
self.warn, self.home = Mock(), Mock()
|
||||||
|
|
||||||
|
def step(self, now, **sample):
|
||||||
|
comfort.tick(self.s, now, sample, self.warn, self.home, read_clock=lambda: now)
|
||||||
|
|
||||||
|
def test_warning_then_home_never_closes_a_game(self):
|
||||||
|
self.step(119)
|
||||||
|
self.warn.assert_not_called()
|
||||||
|
self.step(120)
|
||||||
|
self.warn.assert_called_once()
|
||||||
|
self.step(179)
|
||||||
|
self.home.assert_not_called()
|
||||||
|
self.step(180)
|
||||||
|
self.home.assert_called_once()
|
||||||
|
self.assertFalse(self.s['active'])
|
||||||
|
self.step(181)
|
||||||
|
self.home.assert_called_once()
|
||||||
|
|
||||||
|
def test_late_wakeup_always_gets_a_full_warning_minute(self):
|
||||||
|
self.step(400)
|
||||||
|
self.home.assert_not_called()
|
||||||
|
self.step(459)
|
||||||
|
self.home.assert_not_called()
|
||||||
|
self.step(460)
|
||||||
|
self.home.assert_called_once()
|
||||||
|
|
||||||
|
def test_slow_warning_still_leaves_a_full_minute(self):
|
||||||
|
comfort.tick(self.s, 120, {}, self.warn, self.home, read_clock=lambda: 140)
|
||||||
|
self.assertEqual(self.s['warned'], 140)
|
||||||
|
self.step(180)
|
||||||
|
self.home.assert_not_called()
|
||||||
|
self.step(199)
|
||||||
|
self.home.assert_not_called()
|
||||||
|
self.step(200)
|
||||||
|
self.home.assert_called_once()
|
||||||
|
|
||||||
|
def test_failed_warning_never_stops_session(self):
|
||||||
|
self.warn.side_effect = RuntimeError('offline')
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.step(200)
|
||||||
|
self.assertIsNone(self.s['warned'])
|
||||||
|
self.home.assert_not_called()
|
||||||
|
self.warn.side_effect = None
|
||||||
|
self.step(300)
|
||||||
|
self.step(359)
|
||||||
|
self.home.assert_not_called()
|
||||||
|
self.step(360)
|
||||||
|
self.home.assert_called_once()
|
||||||
|
|
||||||
|
def test_failed_home_stays_active_and_retries(self):
|
||||||
|
self.step(120)
|
||||||
|
self.home.side_effect = RuntimeError('Steam offline')
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.step(180)
|
||||||
|
self.assertTrue(self.s['active'])
|
||||||
|
self.home.side_effect = None
|
||||||
|
self.step(185)
|
||||||
|
self.assertFalse(self.s['active'])
|
||||||
|
|
||||||
|
def test_cancel_prevents_all_actions(self):
|
||||||
|
self.s['active'] = False
|
||||||
|
self.step(999, battery={'percent': 1, 'status': 'Discharging'})
|
||||||
|
self.warn.assert_not_called()
|
||||||
|
self.home.assert_not_called()
|
||||||
|
self.assertEqual(self.s['events'], [])
|
||||||
|
|
||||||
|
def test_breaks_and_checkin_require_measured_activity(self):
|
||||||
|
self.step(20, activity=1)
|
||||||
|
self.step(40, activity=2)
|
||||||
|
self.step(60, activity=1)
|
||||||
|
self.assertEqual([e['kind'] for e in self.s['events']], ['break', 'still'])
|
||||||
|
self.step(70, activity=1)
|
||||||
|
self.assertEqual(len(self.s['events']), 2)
|
||||||
|
self.step(75, activity=3)
|
||||||
|
self.assertEqual(self.s['used'], 0)
|
||||||
|
self.assertFalse(self.s['stillSent'])
|
||||||
|
|
||||||
|
def test_unknown_activity_and_gaps_do_not_count_as_wear(self):
|
||||||
|
self.step(25)
|
||||||
|
self.assertEqual(self.s['used'], 0)
|
||||||
|
self.assertEqual(self.s['unavailable'], ['battery', 'temperature', 'activity'])
|
||||||
|
self.step(100, activity=1)
|
||||||
|
self.assertEqual(self.s['used'], 30)
|
||||||
|
|
||||||
|
def test_alerts_latch_and_rearm_without_battery_chatter(self):
|
||||||
|
low = {'percent': 10, 'status': 'Discharging'}
|
||||||
|
self.step(1, battery=low, thermal=['cpu'])
|
||||||
|
self.step(2, battery=low, thermal=['cpu'])
|
||||||
|
self.step(3)
|
||||||
|
self.assertEqual(len(self.s['events']), 2)
|
||||||
|
self.step(4, battery={'percent': 16, 'status': 'Discharging'}, thermal=[])
|
||||||
|
self.step(5, battery=low, thermal=[])
|
||||||
|
self.assertEqual(len(self.s['events']), 2)
|
||||||
|
self.step(6, battery={'percent': 22, 'status': 'Discharging'}, thermal=[])
|
||||||
|
self.step(7, battery=low, thermal=['cpu'])
|
||||||
|
self.assertEqual([e['kind'] for e in self.s['events']], ['battery', 'heat', 'battery', 'heat'])
|
||||||
|
|
||||||
|
def test_disabled_alerts_and_charging(self):
|
||||||
|
self.s['options']['heatAlert'] = False
|
||||||
|
self.step(1, battery={'percent': 2, 'status': 'Charging'}, thermal=['cpu'])
|
||||||
|
self.assertEqual(self.s['events'], [])
|
||||||
|
|
||||||
|
def test_invalid_options(self):
|
||||||
|
for key, value in [('minutes', 0), ('minutes', 241), ('minutes', True), ('minutes', 2.5),
|
||||||
|
('breakMinutes', -1), ('stillMinutes', '1'), ('heatAlert', 1)]:
|
||||||
|
with self.subTest(key=key, value=value), self.assertRaises(ValueError):
|
||||||
|
comfort.validate({**OPTIONS, key: value})
|
||||||
|
for value in (None, [], {'action': 'shutdown'}):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
comfort.validate(value)
|
||||||
|
|
||||||
|
def test_restart_invalidates_session_and_stale_worker_is_explicit(self):
|
||||||
|
with patch.object(comfort, 'boot', return_value='boot-one'), patch.object(comfort.time, 'time', return_value=999):
|
||||||
|
current = comfort.current(self.s, 100)
|
||||||
|
self.assertIn('not responding', current['error'])
|
||||||
|
self.assertEqual(current['time'], 999)
|
||||||
|
with patch.object(comfort, 'boot', return_value='boot-two'):
|
||||||
|
result = comfort.current(self.s, 100)
|
||||||
|
self.assertFalse(result['active'])
|
||||||
|
self.assertIn('restarted', result['error'])
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
|
||||||
|
def test_real_state_commands_share_one_session_and_cancel(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
|
||||||
|
patch.object(comfort, 'boot', return_value='boot-one'), \
|
||||||
|
patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen') as spawn:
|
||||||
|
started = comfort.command(OPTIONS)
|
||||||
|
self.assertEqual(comfort.command({'action': 'status'})['id'], started['id'])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
comfort.command(OPTIONS)
|
||||||
|
self.assertFalse(comfort.command({'action': 'cancel'})['active'])
|
||||||
|
spawn.assert_called_once()
|
||||||
|
self.assertEqual((Path(tmp) / 'session.json').stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
|
||||||
|
def test_cancel_clears_stale_worker_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
|
||||||
|
patch.object(comfort, 'boot', return_value='boot-one'), \
|
||||||
|
patch.object(comfort, 'clock', return_value=200):
|
||||||
|
with comfort.locked():
|
||||||
|
comfort.save(self.s)
|
||||||
|
self.assertIn('not responding', comfort.command({'action': 'status'})['error'])
|
||||||
|
cancelled = comfort.command({'action': 'cancel'})
|
||||||
|
self.assertFalse(cancelled['active'])
|
||||||
|
self.assertIsNone(cancelled['error'])
|
||||||
|
self.assertIsNone(comfort.command({'action': 'status'})['error'])
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
|
||||||
|
def test_failed_spawn_leaves_session_inactive_and_retryable(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
|
||||||
|
patch.object(comfort, 'boot', return_value='boot-one'), \
|
||||||
|
patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen') as spawn:
|
||||||
|
spawn.side_effect = OSError('process limit')
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
comfort.command(OPTIONS)
|
||||||
|
failed = comfort.command({'action': 'status'})
|
||||||
|
self.assertFalse(failed['active'])
|
||||||
|
self.assertIn('Could not start', failed['error'])
|
||||||
|
spawn.side_effect = None
|
||||||
|
self.assertTrue(comfort.command(OPTIONS)['active'])
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
|
||||||
|
def test_unreadable_state_is_preserved_and_can_be_replaced(self):
|
||||||
|
for contents in (b'{broken', b'\xff', b'null', b'[]', b'42', b'"x"'):
|
||||||
|
with self.subTest(contents=contents):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
|
||||||
|
patch.object(comfort, 'boot', return_value='boot-one'), \
|
||||||
|
patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen'):
|
||||||
|
(Path(tmp) / 'session.json').write_bytes(contents)
|
||||||
|
failed = comfort.command({'action': 'status'})
|
||||||
|
self.assertFalse(failed['active'])
|
||||||
|
self.assertIn('unreadable', failed['error'])
|
||||||
|
backups = list(Path(tmp).glob('session-unreadable-*.json'))
|
||||||
|
self.assertEqual(len(backups), 1)
|
||||||
|
self.assertEqual(backups[0].read_bytes(), contents)
|
||||||
|
self.assertTrue(comfort.command(OPTIONS)['active'])
|
||||||
|
|
||||||
|
def test_home_has_total_process_deadline_and_propagates_timeout(self):
|
||||||
|
with patch.object(comfort.subprocess, 'run', side_effect=subprocess.TimeoutExpired('home', 15)) as run:
|
||||||
|
with self.assertRaises(subprocess.TimeoutExpired):
|
||||||
|
comfort.home()
|
||||||
|
self.assertEqual(run.call_args.kwargs['timeout'], 15)
|
||||||
|
self.assertEqual(run.call_args.args[0][-1], '--home')
|
||||||
|
|
||||||
|
def test_native_warning_reports_failures_and_quotes_as_one_argument(self):
|
||||||
|
with patch.object(comfort.subprocess, 'run') as run:
|
||||||
|
run.return_value = subprocess.CompletedProcess([], 0, 'Notification succeeded', '')
|
||||||
|
comfort.notify('Save "now"; $(nothing)')
|
||||||
|
args = run.call_args.args[0]
|
||||||
|
self.assertEqual(args, [comfort.VRCMD, '--notify', 'Frame Control: Save "now"; $(nothing)'])
|
||||||
|
run.return_value.stdout = 'Notification failed with error 1'
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
comfort.notify('test')
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("node"), "Node exercises the fake Steam JS context")
|
||||||
|
def test_home_javascript_against_fake_steam_preserves_game(self):
|
||||||
|
# Same JS runs in Steam CDP. This fake records navigation and refuses any
|
||||||
|
# unexpected API call; it offers no shutdown or terminate-game primitive.
|
||||||
|
js = '''let running = [123], path = '/routes/library/app/123', visible = false;
|
||||||
|
const location = {get pathname() {return path;}};
|
||||||
|
const SteamUIStore = {Navigate(p) {path = '/routes' + p;}};
|
||||||
|
const SteamClient = {OpenVR: {VROverlay: {
|
||||||
|
async ShowDashboard(key) {if (key !== 'valve.steam.gamepadui.main') throw Error(key); visible = true;},
|
||||||
|
async IsDashboardVisible() {return visible;}
|
||||||
|
}}};
|
||||||
|
'''
|
||||||
|
js += comfort.HOME_JS + '.then(result => console.log(JSON.stringify({result, running, visible})));'
|
||||||
|
r = subprocess.run(['node', '-e', js], capture_output=True, text=True, check=True)
|
||||||
|
result = json.loads(r.stdout)
|
||||||
|
self.assertEqual(result['running'], [123])
|
||||||
|
self.assertTrue(result['visible'])
|
||||||
|
self.assertEqual(result['result']['path'], '/routes/library/home')
|
||||||
|
|
||||||
|
|
||||||
|
class SensorTests(unittest.TestCase):
|
||||||
|
def test_hot_trip_uses_its_own_zone_not_hottest_unrelated_chip(self):
|
||||||
|
values = {'/z/a/temp': '90000', '/z/a/trip_point_0_type': 'hot', '/z/a/trip_point_0_temp': '110000',
|
||||||
|
'/z/b/temp': '45000', '/z/b/trip_point_0_type': 'hot', '/z/b/trip_point_0_temp': '44000', '/z/b/type': 'battery'}
|
||||||
|
def glob(pattern):
|
||||||
|
if pattern.endswith('thermal_zone*'):
|
||||||
|
return ['/z/a', '/z/b']
|
||||||
|
return [pattern.replace('*', '0')]
|
||||||
|
with patch.object(status.glob, 'glob', side_effect=glob), patch.object(status, 'read', side_effect=values.get):
|
||||||
|
self.assertEqual(status.thermal_alerts(), [{'zone': 'battery', 'tempC': 45, 'limitC': 44}])
|
||||||
|
|
||||||
|
def test_missing_thermal_and_activity_are_unknown(self):
|
||||||
|
with patch.object(status.glob, 'glob', return_value=[]):
|
||||||
|
self.assertIsNone(status.thermal_alerts())
|
||||||
|
with patch.object(status, 'run', return_value='unavailable'):
|
||||||
|
self.assertIsNone(status.activity_level())
|
||||||
|
for malformed in ('{}', '[null, 42, "bad"]'):
|
||||||
|
with patch.object(status, 'run', return_value=malformed):
|
||||||
|
self.assertIsNone(status.activity_level())
|
||||||
|
with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'):
|
||||||
|
self.assertEqual(status.activity_level(), 3)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Run the actual shared page's comfort renderer against a minimal DOM/bridge."""
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('node'), 'Node exercises the shared page JS')
|
||||||
|
class ComfortUI(unittest.TestCase):
|
||||||
|
def test_notification_failure_survives_poll_until_success(self):
|
||||||
|
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||||
|
code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
|
||||||
|
setup = r'''
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const elements = new Map();
|
||||||
|
const $ = id => {
|
||||||
|
if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, type: 'number'});
|
||||||
|
return elements.get(id);
|
||||||
|
};
|
||||||
|
let denied = 0;
|
||||||
|
const window = {frameApp:{notify:async()=>{denied++;throw Error('permission denied');}}};
|
||||||
|
const log = ()=>{}, toast = ()=>{};
|
||||||
|
'''
|
||||||
|
checks = r'''
|
||||||
|
(async()=>{
|
||||||
|
const active = {id:'session-one',active:true,time:100,remaining:120,
|
||||||
|
options:{minutes:2,breakMinutes:1,stillMinutes:1,batteryAlert:true,heatAlert:true},
|
||||||
|
events:[{id:'event-one',kind:'battery',time:99,message:'Low battery'}]};
|
||||||
|
renderComfort(active); // initial history must not replay even a fresh event
|
||||||
|
assert.equal(denied,0);
|
||||||
|
assert.equal($('comfortAnnouncement').textContent,'');
|
||||||
|
active.events.push({id:'event-two',kind:'break',time:100,message:'Take a break'});
|
||||||
|
renderComfort(active);
|
||||||
|
await new Promise(resolve=>setImmediate(resolve));
|
||||||
|
assert.equal(denied,1);
|
||||||
|
assert.equal($('comfortAnnouncement').textContent,'Take a break');
|
||||||
|
assert.equal($('comfortNotificationStatus').hidden,false);
|
||||||
|
assert.match($('comfortNotificationStatus').textContent,/notification settings/);
|
||||||
|
renderComfort({...active,time:105}); // the next normal poll must not erase failure
|
||||||
|
assert.equal($('comfortNotificationStatus').hidden,false);
|
||||||
|
assert.equal($('sessionStart').disabled,true);
|
||||||
|
assert.equal($('sessionMinutes').disabled,true);
|
||||||
|
assert.equal($('sessionCancel').disabled,false);
|
||||||
|
let requests=0;
|
||||||
|
window.frameApp.notify=async()=>{requests++;};
|
||||||
|
renderComfort({...active,time:106});
|
||||||
|
assert.equal($('comfortAnnouncement').textContent,'Take a break');
|
||||||
|
assert.equal(requests,0); // polling does not replay an already-seen event
|
||||||
|
await localNotification('test',true);
|
||||||
|
assert.equal($('comfortNotificationStatus').hidden,true);
|
||||||
|
renderComfort({...active,active:false});
|
||||||
|
assert.equal($('sessionStart').disabled,false);
|
||||||
|
assert.equal($('sessionMinutes').disabled,false);
|
||||||
|
assert.equal($('sessionCancel').disabled,true);
|
||||||
|
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||||
|
'''
|
||||||
|
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
@@ -84,6 +84,7 @@ class ServerGuards(unittest.TestCase):
|
|||||||
|
|
||||||
def test_api_needs_custom_header(self):
|
def test_api_needs_custom_header(self):
|
||||||
# <img src> and plain form posts from other sites can't set it.
|
# <img src> and plain form posts from other sites can't set it.
|
||||||
|
self.assertEqual(self.request("POST", "/api/comfort", {"action": "start"})[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/status")[0], 403)
|
self.assertEqual(self.request("GET", "/api/status")[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
|
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
|
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
|
||||||
@@ -99,6 +100,8 @@ class ServerGuards(unittest.TestCase):
|
|||||||
|
|
||||||
def test_input_validation(self):
|
def test_input_validation(self):
|
||||||
cases = [
|
cases = [
|
||||||
|
("/api/comfort", {"action": "poweroff"}),
|
||||||
|
("/api/comfort", {"action": "start", "minutes": 0}),
|
||||||
("/api/launch", {"appid": "620; rm -rf ~"}),
|
("/api/launch", {"appid": "620; rm -rf ~"}),
|
||||||
("/api/launch", {"appid": ""}),
|
("/api/launch", {"appid": ""}),
|
||||||
("/api/flatpak", {"id": "org.example.App;id", "action": "install"}),
|
("/api/flatpak", {"id": "org.example.App;id", "action": "install"}),
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""Opt-in session worker ON the Frame; no root, extra apps, or power actions.
|
||||||
|
|
||||||
|
One worker per user, shared by desktop and phone. State survives companion
|
||||||
|
connections, not headset reboots. See docs/family-comfort.md for guarantees.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from frame_steam import Page
|
||||||
|
from frame_status import battery, thermal_alerts, activity_level
|
||||||
|
|
||||||
|
ROOT = Path.home() / '.local/state/frame-control/comfort'
|
||||||
|
VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
|
||||||
|
HOME_JS = """(async () => {
|
||||||
|
SteamUIStore.Navigate('/library/home');
|
||||||
|
await SteamClient.OpenVR.VROverlay.ShowDashboard('valve.steam.gamepadui.main');
|
||||||
|
if (!await SteamClient.OpenVR.VROverlay.IsDashboardVisible()) throw Error('Steam dashboard did not open');
|
||||||
|
return {path: location.pathname};
|
||||||
|
})()"""
|
||||||
|
|
||||||
|
|
||||||
|
def clock():
|
||||||
|
# CLOCK_BOOTTIME includes headset suspend; wall-clock corrections don't alter limits.
|
||||||
|
return time.clock_gettime(time.CLOCK_BOOTTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def boot():
|
||||||
|
return Path('/proc/sys/kernel/random/boot_id').read_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def validate(body):
|
||||||
|
if not isinstance(body, dict) or body.get('action') not in ('status', 'start', 'cancel'):
|
||||||
|
raise ValueError('Choose status, start or cancel')
|
||||||
|
if body['action'] == 'start':
|
||||||
|
for key, low, high in (('minutes', 1, 240), ('breakMinutes', 0, 120), ('stillMinutes', 0, 240)):
|
||||||
|
n = body.get(key)
|
||||||
|
if type(n) is not int or not low <= n <= high:
|
||||||
|
raise ValueError(f'{key} must be a whole number from {low} to {high}')
|
||||||
|
for key in ('batteryAlert', 'heatAlert'):
|
||||||
|
if type(body.get(key)) is not bool:
|
||||||
|
raise ValueError(f'{key} must be true or false')
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def new_session(body, now, boot_id):
|
||||||
|
return {'id': uuid.uuid4().hex, 'boot': boot_id, 'active': True,
|
||||||
|
'options': {k: body[k] for k in ('minutes', 'breakMinutes', 'stillMinutes', 'batteryAlert', 'heatAlert')},
|
||||||
|
'started': now, 'deadline': now + body['minutes'] * 60, 'lastSample': now,
|
||||||
|
'used': 0, 'nextBreak': body['breakMinutes'] * 60, 'stillSent': False,
|
||||||
|
'warned': None, 'events': [], 'seq': 0, 'latched': [], 'error': None}
|
||||||
|
|
||||||
|
|
||||||
|
def event(s, kind, message):
|
||||||
|
s['seq'] += 1
|
||||||
|
s['events'].append({'id': s['id'] + ':' + str(s['seq']), 'kind': kind,
|
||||||
|
'message': message, 'time': time.time()})
|
||||||
|
s['events'] = s['events'][-40:]
|
||||||
|
|
||||||
|
|
||||||
|
def notify(message):
|
||||||
|
r = subprocess.run([VRCMD, '--notify', 'Frame Control: ' + message],
|
||||||
|
capture_output=True, text=True, timeout=20)
|
||||||
|
if r.returncode or 'succeeded' not in r.stdout:
|
||||||
|
raise RuntimeError('SteamVR could not show the reminder: ' + (r.stderr or r.stdout)[-300:])
|
||||||
|
|
||||||
|
|
||||||
|
def home():
|
||||||
|
# A total process deadline also bounds a CDP peer that keeps sending events
|
||||||
|
# without completing the request. Keep cancellation ordered after this action.
|
||||||
|
r = subprocess.run([sys.executable, str(Path(__file__).resolve()), '--home'],
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
if r.returncode:
|
||||||
|
raise RuntimeError('Steam Home failed: ' + (r.stdout or r.stderr)[-300:])
|
||||||
|
|
||||||
|
|
||||||
|
def open_home():
|
||||||
|
page = Page()
|
||||||
|
try:
|
||||||
|
result = page.eval(HOME_JS)
|
||||||
|
if result.get('path') != '/routes/library/home':
|
||||||
|
raise RuntimeError('Steam did not navigate Home')
|
||||||
|
finally:
|
||||||
|
page.sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def tick(s, now, sample, warn=notify, go_home=home, read_clock=clock):
|
||||||
|
"""One deterministic step; injected actions/samples also exercise a fake Frame."""
|
||||||
|
if not s.get('active'):
|
||||||
|
return
|
||||||
|
o = s['options']
|
||||||
|
s['heartbeat'] = now
|
||||||
|
delta = max(0, min(30, now - s['lastSample']))
|
||||||
|
s['lastSample'] = now
|
||||||
|
level = sample.get('activity')
|
||||||
|
b = sample.get('battery') or {}
|
||||||
|
s['unavailable'] = []
|
||||||
|
if o['batteryAlert'] and b.get('percent') is None:
|
||||||
|
s['unavailable'].append('battery')
|
||||||
|
if o['heatAlert'] and sample.get('thermal') is None:
|
||||||
|
s['unavailable'].append('temperature')
|
||||||
|
if (o['breakMinutes'] or o['stillMinutes']) and level is None:
|
||||||
|
s['unavailable'].append('activity')
|
||||||
|
s['activity'] = level
|
||||||
|
if level in (1, 2):
|
||||||
|
s['used'] += delta
|
||||||
|
elif level is not None:
|
||||||
|
s['used'] = 0
|
||||||
|
s['nextBreak'] = o['breakMinutes'] * 60
|
||||||
|
s['stillSent'] = False
|
||||||
|
# Missing samples never count as time worn. No catch-up burst after a disconnect.
|
||||||
|
if now >= s['deadline'] - 60 and s['warned'] is None:
|
||||||
|
warn('One minute left. Save your progress; Steam Home will open.')
|
||||||
|
s['warned'] = max(now, read_clock())
|
||||||
|
event(s, 'warning', 'One minute left. Save your progress; Steam Home will open.')
|
||||||
|
if s['warned'] is not None and now >= max(s['deadline'], s['warned'] + 60):
|
||||||
|
go_home()
|
||||||
|
s['active'] = False
|
||||||
|
event(s, 'finished', 'Session ended: Steam Home opened. Your game is still running.')
|
||||||
|
return
|
||||||
|
if o['breakMinutes'] and s['used'] >= s['nextBreak']:
|
||||||
|
warn('Time for a break. Take off the headset and rest your eyes.')
|
||||||
|
event(s, 'break', 'Time for a break. Take off the headset and rest your eyes.')
|
||||||
|
s['nextBreak'] = s['used'] + o['breakMinutes'] * 60
|
||||||
|
if o['stillMinutes'] and not s['stillSent'] and s['used'] >= o['stillMinutes'] * 60:
|
||||||
|
event(s, 'still', f"Headset still active after {o['stillMinutes']} active minute(s). Check in with the wearer.")
|
||||||
|
s['stillSent'] = True
|
||||||
|
low = b.get('percent') is not None and b['percent'] <= 15 and b.get('status') == 'Discharging'
|
||||||
|
hot = sample.get('thermal')
|
||||||
|
for kind, enabled, value, message in (
|
||||||
|
('battery', o['batteryAlert'], low if b else None, 'Frame battery is low (15% or less).'),
|
||||||
|
('heat', o['heatAlert'], bool(hot) if hot is not None else None,
|
||||||
|
'Frame reports a hot/critical thermal trip or battery overheat. Ask the wearer to take a break.')):
|
||||||
|
if enabled and value and kind not in s['latched']:
|
||||||
|
event(s, kind, message)
|
||||||
|
s['latched'].append(kind)
|
||||||
|
elif value is False and kind in s['latched']:
|
||||||
|
# Battery hysteresis prevents repeated alerts around 15%.
|
||||||
|
if kind != 'battery' or b.get('status') == 'Charging' or (b.get('percent') or 0) >= 20:
|
||||||
|
s['latched'].remove(kind)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def locked(name='state.lock', nonblocking=False):
|
||||||
|
import fcntl # only needed ON the Linux headset, not by desktop validation/tests
|
||||||
|
ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
with (ROOT / name).open('a') as f:
|
||||||
|
fcntl.flock(f, fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0))
|
||||||
|
yield f
|
||||||
|
|
||||||
|
|
||||||
|
def read_state():
|
||||||
|
try:
|
||||||
|
state = json.loads((ROOT / 'session.json').read_text())
|
||||||
|
if not isinstance(state, dict):
|
||||||
|
raise ValueError('Saved session must be an object')
|
||||||
|
return state
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {'active': False, 'events': []}
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
# Preserve the unreadable state for diagnosis, then allow a new session.
|
||||||
|
(ROOT / 'session.json').replace(ROOT / ('session-unreadable-' + uuid.uuid4().hex + '.json'))
|
||||||
|
return {'active': False, 'events': [],
|
||||||
|
'error': 'Saved session was unreadable. Start a new session.'}
|
||||||
|
|
||||||
|
|
||||||
|
def save(s):
|
||||||
|
p = ROOT / 'session.tmp'
|
||||||
|
p.write_text(json.dumps(s))
|
||||||
|
p.chmod(0o600)
|
||||||
|
p.replace(ROOT / 'session.json')
|
||||||
|
|
||||||
|
|
||||||
|
def current(s, now):
|
||||||
|
if s.get('active') and s.get('boot') != boot():
|
||||||
|
s['active'] = False
|
||||||
|
s['error'] = 'Headset restarted. Start a new session.'
|
||||||
|
out = dict(s)
|
||||||
|
out['time'] = time.time() # event age uses the Frame's clock, not the phone's
|
||||||
|
out['remaining'] = max(0, max(s.get('deadline', now), (s.get('warned') or 0) + 60) - now) if s.get('active') else 0
|
||||||
|
beat = s.get('heartbeat', s.get('started', now)) # a hand-edited state may lack either
|
||||||
|
if s.get('active') and now - beat > 90:
|
||||||
|
out['error'] = 'Session worker is not responding. Timer enforcement is unverified; cancel and start again.'
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def watch():
|
||||||
|
try:
|
||||||
|
with locked('worker.lock', nonblocking=True) as worker:
|
||||||
|
while True:
|
||||||
|
with locked():
|
||||||
|
s = current(read_state(), clock())
|
||||||
|
if not s.get('active'):
|
||||||
|
save(s)
|
||||||
|
# Release ownership before state.lock: a concurrent start
|
||||||
|
# cannot miss the gap between an old worker and its exit.
|
||||||
|
import fcntl
|
||||||
|
fcntl.flock(worker, fcntl.LOCK_UN)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
b = battery()
|
||||||
|
hot = thermal_alerts()
|
||||||
|
if b and b.get('health') == 'Overheat':
|
||||||
|
hot = (hot or []) + ['battery']
|
||||||
|
tick(s, clock(), {'battery': b, 'thermal': hot, 'activity': activity_level()})
|
||||||
|
s['error'] = None
|
||||||
|
except Exception as e:
|
||||||
|
error = str(e)
|
||||||
|
if s.get('error') != error:
|
||||||
|
event(s, 'error', 'Session action failed: ' + error)
|
||||||
|
s['error'] = error
|
||||||
|
save(s)
|
||||||
|
time.sleep(5)
|
||||||
|
except BlockingIOError:
|
||||||
|
pass # another connection already started the single worker
|
||||||
|
|
||||||
|
|
||||||
|
def command(body):
|
||||||
|
validate(body)
|
||||||
|
with locked():
|
||||||
|
s = current(read_state(), clock())
|
||||||
|
if body['action'] == 'start':
|
||||||
|
if s.get('active'):
|
||||||
|
raise ValueError('A session is already running. Cancel it before starting another.')
|
||||||
|
s = new_session(body, clock(), boot())
|
||||||
|
event(s, 'started', 'Session started. Steam Home opens at the limit; games are not closed.')
|
||||||
|
elif body['action'] == 'cancel':
|
||||||
|
s['active'] = False
|
||||||
|
s['error'] = None
|
||||||
|
if s.get('id'):
|
||||||
|
event(s, 'cancelled', 'Session timer and monitoring cancelled.')
|
||||||
|
save(s)
|
||||||
|
if body['action'] == 'start':
|
||||||
|
try:
|
||||||
|
subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--watch'],
|
||||||
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True, close_fds=True)
|
||||||
|
except OSError as e:
|
||||||
|
s['active'] = False
|
||||||
|
s['error'] = 'Could not start session worker: ' + str(e)
|
||||||
|
event(s, 'error', s['error'])
|
||||||
|
save(s)
|
||||||
|
raise
|
||||||
|
return current(s, clock())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if sys.argv[1:] == ['--watch']:
|
||||||
|
watch()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if sys.argv[1:] == ['--home']:
|
||||||
|
open_home()
|
||||||
|
print(json.dumps({'home': True}))
|
||||||
|
else:
|
||||||
|
print(json.dumps(command(json.loads(sys.argv[1]))))
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({'error': str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
+56
-24
@@ -156,27 +156,59 @@ def flatpaks():
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
uptime = read("/proc/uptime")
|
def thermal_alerts():
|
||||||
procs = process_names()
|
"""Use the kernel's per-zone hot/critical trips, never a guessed chip limit."""
|
||||||
print(json.dumps({
|
alerts, known = [], False
|
||||||
"time": time.time(),
|
for z in glob.glob("/sys/class/thermal/thermal_zone*"):
|
||||||
"hostname": socket.gethostname(),
|
t = num(z + "/temp", 0.001)
|
||||||
"os": os_release(),
|
for trip in glob.glob(z + "/trip_point_*_type"):
|
||||||
"uptime": float(uptime.split()[0]) if uptime else None,
|
if read(trip) not in ("hot", "critical"):
|
||||||
"battery": battery(),
|
continue
|
||||||
"power": power_source(),
|
limit = num(trip[:-4] + "temp", 0.001)
|
||||||
"disk": {"root": disk("/"), "home": disk("/home")},
|
if t is not None and limit is not None and limit > 0:
|
||||||
"memory": memory(),
|
known = True
|
||||||
"temp": max_temp(),
|
if t >= limit:
|
||||||
"wifi": wifi(),
|
alerts.append({"zone": read(z + "/type"), "tempC": t, "limitC": limit})
|
||||||
"ip": ip_addr(),
|
return alerts if known else None
|
||||||
"volume": volume(),
|
|
||||||
"services": {
|
|
||||||
"steamvr": "vrserver" in procs,
|
def activity_level():
|
||||||
"desktop": "plasmashell" in procs,
|
try:
|
||||||
"lepton": port_listening(5555),
|
rows = json.loads(run("/opt/steamvr/bin/linuxarm64/vrcmd", "--stats"))
|
||||||
"rdp": "xrdp" in procs,
|
if not isinstance(rows, list):
|
||||||
},
|
return None
|
||||||
"games": games(),
|
return next((r.get("activity_level") for r in rows
|
||||||
"flatpaks": flatpaks(),
|
if isinstance(r, dict) and r.get("operation") == "status"), None)
|
||||||
}))
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
uptime = read("/proc/uptime")
|
||||||
|
procs = process_names()
|
||||||
|
print(json.dumps({
|
||||||
|
"time": time.time(),
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"os": os_release(),
|
||||||
|
"uptime": float(uptime.split()[0]) if uptime else None,
|
||||||
|
"battery": battery(),
|
||||||
|
"power": power_source(),
|
||||||
|
"disk": {"root": disk("/"), "home": disk("/home")},
|
||||||
|
"memory": memory(),
|
||||||
|
"temp": max_temp(),
|
||||||
|
"wifi": wifi(),
|
||||||
|
"ip": ip_addr(),
|
||||||
|
"volume": volume(),
|
||||||
|
"services": {
|
||||||
|
"steamvr": "vrserver" in procs,
|
||||||
|
"desktop": "plasmashell" in procs,
|
||||||
|
"lepton": port_listening(5555),
|
||||||
|
"rdp": "xrdp" in procs,
|
||||||
|
},
|
||||||
|
"games": games(),
|
||||||
|
"flatpaks": flatpaks(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+127
-1
@@ -102,6 +102,7 @@
|
|||||||
.shelf-head .count { color: var(--muted); font-size: 13px; }
|
.shelf-head .count { color: var(--muted); font-size: 13px; }
|
||||||
.shelf-head .spacer { flex: 1; }
|
.shelf-head .spacer { flex: 1; }
|
||||||
.sub { color: var(--muted); font-size: 12.5px; }
|
.sub { color: var(--muted); font-size: 12.5px; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||||
.hint { color: var(--muted); font-size: 12.5px; margin-top: 11px; line-height: 1.5; }
|
.hint { color: var(--muted); font-size: 12.5px; margin-top: 11px; line-height: 1.5; }
|
||||||
|
|
||||||
/* ---- buttons ---- */
|
/* ---- buttons ---- */
|
||||||
@@ -360,7 +361,7 @@
|
|||||||
.disp .k2 { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin: 12px 0 5px; }
|
.disp .k2 { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin: 12px 0 5px; }
|
||||||
.disp .seg { flex-wrap: wrap; }
|
.disp .seg { flex-wrap: wrap; }
|
||||||
.disp .seg button { padding: 0 10px; }
|
.disp .seg button { padding: 0 10px; }
|
||||||
.disp input[type=number] { width: 72px; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
.disp input[type=number], #comfort input[type=number] { width: 72px; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||||
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
|
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
|
||||||
/* Touch screens can't hover: keep the library's name and Play button showing. */
|
/* Touch screens can't hover: keep the library's name and Play button showing. */
|
||||||
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
|
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
|
||||||
@@ -575,6 +576,36 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="panel" id="comfort" style="margin:20px 0;padding:16px">
|
||||||
|
<div class="shelf-head"><h2>Family and comfort</h2><span class="spacer"></span>
|
||||||
|
<button class="action small" id="castBtn">Cast headset view</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Share this screen with people in the room. Casting shows everything the wearer sees, including private content.</p>
|
||||||
|
<form id="comfortForm">
|
||||||
|
<div class="row" style="flex-wrap:wrap;gap:12px">
|
||||||
|
<label>Session limit (minutes) <input id="sessionMinutes" type="number" min="1" max="240" value="30" required style="width:75px"></label>
|
||||||
|
<label>Break every <input id="breakMinutes" type="number" min="0" max="120" value="20" required style="width:75px"> minutes</label>
|
||||||
|
<label>Check in after <input id="stillMinutes" type="number" min="0" max="240" value="30" required style="width:75px"> active minutes</label>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="flex-wrap:wrap;margin:14px 0;gap:14px">
|
||||||
|
<label><input id="batteryAlert" type="checkbox" checked> Alert on low battery</label>
|
||||||
|
<label><input id="heatAlert" type="checkbox" checked> Alert on overheating</label>
|
||||||
|
<button class="action small" id="sessionStart" type="submit">Start session</button>
|
||||||
|
<button class="small" id="sessionCancel" type="button" disabled>Cancel session</button>
|
||||||
|
<button class="small" id="testNotification" type="button">Enable / test notifications</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p id="comfortStatus" role="status">Checking session…</p>
|
||||||
|
<p id="comfortNotificationStatus" class="hint" role="status" hidden></p>
|
||||||
|
<p class="hint">A one-minute warning, then Steam Home. Games stay running: save and pause first. Keep this app open and connected for notifications.</p>
|
||||||
|
<details class="hint"><summary>How sessions and alerts work</summary>
|
||||||
|
<p>This is a reminder, not a parental lock. The timer continues on the Frame if you disconnect; a headset restart cancels it. Cancel before changing settings. Set break/check-in to 0 to turn them off.</p>
|
||||||
|
<p>Battery, heat and check-in alerts appear on connected companions during a session. Headset warnings and break reminders continue without a companion. iOS may suspend phone notifications in the background. Breaks and check-ins count SteamVR activity, not confirmed wear time.</p>
|
||||||
|
</details>
|
||||||
|
<div id="comfortEvents" class="hint"></div>
|
||||||
|
<span id="comfortAnnouncement" class="sr-only" aria-live="polite"></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="pad">
|
<section class="panel" id="pad">
|
||||||
<div class="shelf-head"><h2>Keyboard and trackpad</h2><span class="sub" id="padState"></span><span class="spacer"></span>
|
<div class="shelf-head"><h2>Keyboard and trackpad</h2><span class="sub" id="padState"></span><span class="spacer"></span>
|
||||||
<button class="small" id="padOn">Turn on</button>
|
<button class="small" id="padOn">Turn on</button>
|
||||||
@@ -1135,6 +1166,101 @@ function refresh() {
|
|||||||
return refreshing;
|
return refreshing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Frame owns the clock. Poll independently of status and the selected tab.
|
||||||
|
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
|
||||||
|
async function localNotification(message, request = false) {
|
||||||
|
try {
|
||||||
|
if (window.frameApp?.notify) await window.frameApp.notify(message, request);
|
||||||
|
else {
|
||||||
|
if (!("Notification" in window)) throw new Error("This browser has no notifications; use the Frame Control app.");
|
||||||
|
const permission = request ? await Notification.requestPermission() : Notification.permission;
|
||||||
|
if (permission !== "granted") throw new Error("Notifications are off. Enable them in system settings.");
|
||||||
|
new Notification("Frame Control", {body: message});
|
||||||
|
}
|
||||||
|
$("comfortNotificationStatus").hidden = true;
|
||||||
|
} catch (e) {
|
||||||
|
$("comfortNotificationStatus").hidden = false;
|
||||||
|
$("comfortNotificationStatus").textContent = "Notifications aren't available on this device. Check system notification settings, then use Enable / test notifications. Headset reminders continue during an active session.";
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComfort(s) {
|
||||||
|
const firstSnapshot = !comfortHydrated;
|
||||||
|
comfortHydrated = true;
|
||||||
|
$("sessionStart").disabled = !!s.active;
|
||||||
|
for (const id of ["sessionMinutes", "breakMinutes", "stillMinutes", "batteryAlert", "heatAlert"])
|
||||||
|
$(id).disabled = !!s.active;
|
||||||
|
$("sessionCancel").disabled = !s.active;
|
||||||
|
if (s.id !== comfortSession) {
|
||||||
|
comfortSession = s.id;
|
||||||
|
comfortSeen.clear();
|
||||||
|
if (s.options) for (const [key, value] of Object.entries(s.options)) {
|
||||||
|
const el = $(key === "minutes" ? "sessionMinutes" : key);
|
||||||
|
if (!el) continue;
|
||||||
|
if (el.type === "checkbox") el.checked = value; else el.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let statusText = s.error || (s.active
|
||||||
|
? `${Math.ceil(s.remaining / 60)} min until Steam Home · ${s.activity == null ? "activity unknown" : s.activity === 3 ? "headset in standby" : "monitoring"}`
|
||||||
|
: "No session running.");
|
||||||
|
if (s.active && s.unavailable?.length)
|
||||||
|
statusText += " · No readings: " + s.unavailable.join(", ");
|
||||||
|
if ($("comfortStatus").textContent !== statusText) $("comfortStatus").textContent = statusText;
|
||||||
|
let latest = null;
|
||||||
|
for (const e of s.events || []) {
|
||||||
|
if (comfortSeen.has(e.id)) continue;
|
||||||
|
comfortSeen.add(e.id);
|
||||||
|
// Historical events remain visible but never produce a burst on reconnect.
|
||||||
|
if (!firstSnapshot && s.time - e.time >= 0 && s.time - e.time < 30 && !["started", "cancelled"].includes(e.kind)) {
|
||||||
|
latest = e.message;
|
||||||
|
log(e.message); toast(e.message, e.kind === "error");
|
||||||
|
localNotification(e.message).catch(() => {}); // the notification status keeps the failure visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep only the server's bounded history; a new page seeds it without replay.
|
||||||
|
comfortSeen = new Set((s.events || []).map(e => e.id));
|
||||||
|
if (latest !== null) $("comfortAnnouncement").textContent = latest;
|
||||||
|
const history = (s.events || []).slice(-3).map(e => e.message).join(" · ");
|
||||||
|
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
|
||||||
|
}
|
||||||
|
async function pollComfort() {
|
||||||
|
if (!comfortBusy) {
|
||||||
|
comfortBusy = true;
|
||||||
|
try { renderComfort(await api("/api/comfort", {action: "status"})); }
|
||||||
|
catch (e) {
|
||||||
|
const message = "Session status unavailable: " + e.message;
|
||||||
|
if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message;
|
||||||
|
}
|
||||||
|
finally { comfortBusy = false; }
|
||||||
|
}
|
||||||
|
setTimeout(pollComfort, 5000);
|
||||||
|
}
|
||||||
|
$("comfortForm").onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const result = await act("Start session", () => api("/api/comfort", {
|
||||||
|
action: "start", minutes: Number($("sessionMinutes").value), breakMinutes: Number($("breakMinutes").value),
|
||||||
|
stillMinutes: Number($("stillMinutes").value), batteryAlert: $("batteryAlert").checked, heatAlert: $("heatAlert").checked,
|
||||||
|
}), $("sessionStart"));
|
||||||
|
if (result) renderComfort(result);
|
||||||
|
};
|
||||||
|
$("sessionCancel").onclick = async () => {
|
||||||
|
const result = await act("Cancel session", () => api("/api/comfort", {action: "cancel"}), $("sessionCancel"));
|
||||||
|
if (result) renderComfort(result);
|
||||||
|
};
|
||||||
|
$("testNotification").onclick = () => act("Test notification", async () => {
|
||||||
|
await localNotification("Comfort notifications are enabled on this device.", true);
|
||||||
|
return {message: "Test notification sent. Check your device's notification settings if it didn't appear."};
|
||||||
|
});
|
||||||
|
$("castBtn").onclick = () => {
|
||||||
|
setView("headset");
|
||||||
|
if (!live) toggleLive(true);
|
||||||
|
$("viewer").scrollIntoView({behavior: "smooth", block: "center"});
|
||||||
|
// Fullscreen is a direct user gesture; iPhone falls back to its inline viewer.
|
||||||
|
$("viewer").requestFullscreen?.().catch(() => {});
|
||||||
|
};
|
||||||
|
pollComfort();
|
||||||
|
|
||||||
// ---- battery ----
|
// ---- battery ----
|
||||||
function battery(b, power) {
|
function battery(b, power) {
|
||||||
const pct = b?.percent;
|
const pct = b?.percent;
|
||||||
|
|||||||
+33
-1
@@ -44,6 +44,7 @@ import frame_assistant # noqa: E402
|
|||||||
import frame_android # noqa: E402
|
import frame_android # noqa: E402
|
||||||
import frame_apk_versions # noqa: E402
|
import frame_apk_versions # noqa: E402
|
||||||
import frame_catalog # noqa: E402
|
import frame_catalog # noqa: E402
|
||||||
|
import frame_comfort # noqa: E402
|
||||||
import frame_host # noqa: E402
|
import frame_host # noqa: E402
|
||||||
import frame_macview # noqa: E402
|
import frame_macview # noqa: E402
|
||||||
import frame_media # noqa: E402
|
import frame_media # noqa: E402
|
||||||
@@ -270,6 +271,37 @@ def status(_body):
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def comfort(body):
|
||||||
|
try:
|
||||||
|
frame_comfort.validate(body)
|
||||||
|
except ValueError as e:
|
||||||
|
raise Failure(str(e), 400)
|
||||||
|
# Content-addressed, user-only helper bundle. Desktop and phone use the same
|
||||||
|
# on-headset state/lock; no listener, service registration or third-party app.
|
||||||
|
import hashlib
|
||||||
|
files = {name: (HERE / name).read_text() for name in
|
||||||
|
("frame_comfort.py", "frame_status.py", "frame_steam.py")}
|
||||||
|
version = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()[:16]
|
||||||
|
script = """import json, os, pathlib, subprocess, sys
|
||||||
|
os.umask(0o077)
|
||||||
|
files = %r
|
||||||
|
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name, source in files.items():
|
||||||
|
path = root / name
|
||||||
|
if not path.exists():
|
||||||
|
tmp = root / (name + '.' + str(os.getpid()))
|
||||||
|
tmp.write_text(source)
|
||||||
|
tmp.replace(path)
|
||||||
|
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
|
||||||
|
print(r.stdout, end='')
|
||||||
|
""" % (files, version, json.dumps(body))
|
||||||
|
out = json.loads(ssh("python3 -", stdin=script, timeout=65))
|
||||||
|
if out.get("error") and "active" not in out:
|
||||||
|
raise Failure(out["error"], 409)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def headset_view():
|
def headset_view():
|
||||||
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
|
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
|
||||||
# `timeout`: VR_Init can block if SteamVR is restarting.
|
# `timeout`: VR_Init can block if SteamVR is restarting.
|
||||||
@@ -1867,7 +1899,7 @@ def agent_approval(body):
|
|||||||
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
|
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
|
||||||
|
|
||||||
|
|
||||||
POST = {"/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
POST = {"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
||||||
"/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
"/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||||
|
|||||||
Reference in new issue
Block a user