diff --git a/README.md b/README.md index 314664e..2cb0100 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,11 @@ SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down. -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 `~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md). diff --git a/app/main.js b/app/main.js index 3a93318..b943a82 100644 --- a/app/main.js +++ b/app/main.js @@ -1,7 +1,7 @@ // 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 // 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 { promisify } = require("util"); 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: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 // 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 @@ -376,7 +391,7 @@ function createWindow() { title: "Frame Control", backgroundColor: BG, show: false, ...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } } : { 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") }, }); win.once("ready-to-show", () => win.show()); diff --git a/app/preload.js b/app/preload.js index c8579b4..41b2232 100644 --- a/app/preload.js +++ b/app/preload.js @@ -9,6 +9,7 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron"); contextBridge.exposeInMainWorld("frameApp", { + notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request), readClipboard: () => ipcRenderer.invoke("clipboard:read"), setUpConnection: () => ipcRenderer.invoke("connection:setup"), // While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame. diff --git a/docs/family-comfort.md b/docs/family-comfort.md new file mode 100644 index 0000000..e2f1afe --- /dev/null +++ b/docs/family-comfort.md @@ -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. + +![Family and comfort controls in the desktop app](img/comfort-desktop.png) + +## 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//`. 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. + +![Native test notification in the iOS Simulator](img/comfort-notification-ios.png) + +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. diff --git a/docs/img/comfort-desktop.png b/docs/img/comfort-desktop.png new file mode 100644 index 0000000..6625c57 Binary files /dev/null and b/docs/img/comfort-desktop.png differ diff --git a/docs/img/comfort-notification-ios.png b/docs/img/comfort-notification-ios.png new file mode 100644 index 0000000..7ed15d0 Binary files /dev/null and b/docs/img/comfort-notification-ios.png differ diff --git a/docs/iphone.md b/docs/iphone.md index d9f6c93..debc954 100644 --- a/docs/iphone.md +++ b/docs/iphone.md @@ -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, 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). ## 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`, `FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds 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). diff --git a/docs/testing.md b/docs/testing.md index acc7a18..716960b 100644 --- a/docs/testing.md +++ b/docs/testing.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 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). + +## 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). diff --git a/ios/FrameControl.xcodeproj/project.pbxproj b/ios/FrameControl.xcodeproj/project.pbxproj index c1fa8f9..dcc0271 100644 --- a/ios/FrameControl.xcodeproj/project.pbxproj +++ b/ios/FrameControl.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; }; 84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; }; 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 */; }; DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.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 = ""; }; D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = ""; }; DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; + E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfortNotificationTests.swift; sourceTree = ""; }; EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = ""; }; F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -107,6 +109,7 @@ 75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = { isa = PBXGroup; children = ( + E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */, 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */, ); path = FrameControlTests; @@ -274,6 +277,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */, DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/FrameControl/Web/WebShell.swift b/ios/FrameControl/Web/WebShell.swift index fee7df2..3ddbe4d 100644 --- a/ios/FrameControl/Web/WebShell.swift +++ b/ios/FrameControl/Web/WebShell.swift @@ -1,6 +1,7 @@ import SwiftUI import UIKit import WebKit +import UserNotifications /// 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 @@ -48,6 +49,7 @@ struct WebShell: UIViewRepresentable { let installCb = null; window.frameApp = { platform: "ios", + notify: (message, request) => call("notify", { message, request }), readClipboard: () => call("readClipboard"), setUpConnection: () => call("setUpConnection"), 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 weak var web: WKWebView? var loaded: URL? 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 @@ -76,6 +96,28 @@ struct WebShell: UIViewRepresentable { } let arg = body["arg"] 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": replyHandler(UIPasteboard.general.string ?? "", nil) case "setUpConnection": diff --git a/ios/FrameControlTests/ComfortNotificationTests.swift b/ios/FrameControlTests/ComfortNotificationTests.swift new file mode 100644 index 0000000..68fe4c1 --- /dev/null +++ b/ios/FrameControlTests/ComfortNotificationTests.swift @@ -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))) + } +} diff --git a/tests/test_comfort.py b/tests/test_comfort.py new file mode 100644 index 0000000..0b3af36 --- /dev/null +++ b/tests/test_comfort.py @@ -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) diff --git a/tests/test_comfort_ui.py b/tests/test_comfort_ui.py new file mode 100644 index 0000000..2d75b36 --- /dev/null +++ b/tests/test_comfort_ui.py @@ -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) diff --git a/tests/test_server.py b/tests/test_server.py index 1bb565d..2487768 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -84,6 +84,7 @@ class ServerGuards(unittest.TestCase): def test_api_needs_custom_header(self): # 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/screenshot?view=headset")[0], 403) self.assertEqual(self.request("GET", "/api/shots")[0], 403) @@ -99,6 +100,8 @@ class ServerGuards(unittest.TestCase): def test_input_validation(self): cases = [ + ("/api/comfort", {"action": "poweroff"}), + ("/api/comfort", {"action": "start", "minutes": 0}), ("/api/launch", {"appid": "620; rm -rf ~"}), ("/api/launch", {"appid": ""}), ("/api/flatpak", {"id": "org.example.App;id", "action": "install"}), diff --git a/ui/frame_comfort.py b/ui/frame_comfort.py new file mode 100644 index 0000000..cb2002e --- /dev/null +++ b/ui/frame_comfort.py @@ -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) diff --git a/ui/frame_status.py b/ui/frame_status.py index 2047abf..b53bc83 100644 --- a/ui/frame_status.py +++ b/ui/frame_status.py @@ -156,27 +156,59 @@ def flatpaks(): return out -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(), -})) +def thermal_alerts(): + """Use the kernel's per-zone hot/critical trips, never a guessed chip limit.""" + alerts, known = [], False + for z in glob.glob("/sys/class/thermal/thermal_zone*"): + t = num(z + "/temp", 0.001) + for trip in glob.glob(z + "/trip_point_*_type"): + if read(trip) not in ("hot", "critical"): + continue + limit = num(trip[:-4] + "temp", 0.001) + if t is not None and limit is not None and limit > 0: + known = True + if t >= limit: + alerts.append({"zone": read(z + "/type"), "tempC": t, "limitC": limit}) + return alerts if known else None + + +def activity_level(): + try: + rows = json.loads(run("/opt/steamvr/bin/linuxarm64/vrcmd", "--stats")) + if not isinstance(rows, list): + return None + return next((r.get("activity_level") for r in rows + 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() diff --git a/ui/index.html b/ui/index.html index 459909f..eb66888 100644 --- a/ui/index.html +++ b/ui/index.html @@ -102,6 +102,7 @@ .shelf-head .count { color: var(--muted); font-size: 13px; } .shelf-head .spacer { flex: 1; } .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; } /* ---- buttons ---- */ @@ -360,7 +361,7 @@ .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 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; } /* 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; } } @@ -575,6 +576,36 @@ +
+

Family and comfort

+ +
+

Share this screen with people in the room. Casting shows everything the wearer sees, including private content.

+
+
+ + + +
+
+ + + + + +
+
+

Checking session…

+ +

A one-minute warning, then Steam Home. Games stay running: save and pause first. Keep this app open and connected for notifications.

+
How sessions and alerts work +

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.

+

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.

+
+
+ +
+

Keyboard and trackpad

@@ -1135,6 +1166,101 @@ function refresh() { 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 ---- function battery(b, power) { const pct = b?.percent; diff --git a/ui/server.py b/ui/server.py index 710a92f..0f0c461 100755 --- a/ui/server.py +++ b/ui/server.py @@ -44,6 +44,7 @@ import frame_assistant # noqa: E402 import frame_android # noqa: E402 import frame_apk_versions # noqa: E402 import frame_catalog # noqa: E402 +import frame_comfort # noqa: E402 import frame_host # noqa: E402 import frame_macview # noqa: E402 import frame_media # noqa: E402 @@ -270,6 +271,37 @@ def status(_body): 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(): """Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes.""" # `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")) -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/input": remote_input, "/api/touch": remote_touch, "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,