diff --git a/README.md b/README.md index 6dd32be..79021a7 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,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 bf6ff66..f4de569 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"); @@ -245,6 +245,21 @@ function fromUi(e) { ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); }); +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 @@ -292,7 +307,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 43bc34d..09f801b 100644 --- a/app/preload.js +++ b/app/preload.js @@ -8,6 +8,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"), pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } }, diff --git a/docs/family-comfort.md b/docs/family-comfort.md new file mode 100644 index 0000000..b2e9aa8 --- /dev/null +++ b/docs/family-comfort.md @@ -0,0 +1,109 @@ +# 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); 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: + +- **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. 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 b09920d..332a447 100644 --- a/docs/iphone.md +++ b/docs/iphone.md @@ -25,7 +25,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 @@ -107,3 +110,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 b06577d..7052a06 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -148,3 +148,12 @@ For example, on 2026-09-27 the smoke test found that Steam's `create-shortcut` refuses ids with a hyphen (`missing/invalid arguments`), which the fake had accepted. The fake now refuses them the same way, and Frame Control makes ids Steam accepts. + +## 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 d23f1b8..9ea9586 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/ui/index.html b/ui/index.html index 8ac1265..6ad9328 100644 --- a/ui/index.html +++ b/ui/index.html @@ -318,7 +318,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; } } @@ -471,6 +471,34 @@ +
+

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.

+

Alerts run during a session. iOS may suspend phone notifications in the background. Breaks and check-ins count SteamVR activity, not confirmed wear time.

+
+
+
+

Screenshots

@@ -897,6 +925,78 @@ 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; +async function localNotification(message, request = false) { + if (window.frameApp?.notify) return window.frameApp.notify(message, request); + 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}); +} +function renderComfort(s) { + $("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.type === "checkbox") el.checked = value; else el.value = value; + } + } + $("comfortStatus").textContent = 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) + $("comfortStatus").textContent += " · No readings: " + s.unavailable.join(", "); + 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 (s.time - e.time >= 0 && s.time - e.time < 30 && !["started", "cancelled"].includes(e.kind)) { + log(e.message); toast(e.message, e.kind === "error"); + localNotification(e.message).catch(err => { $("comfortStatus").textContent += " · " + err.message; }); + } + } + $("comfortEvents").textContent = (s.events || []).slice(-3).map(e => e.message).join(" · "); +} +async function pollComfort() { + if (!comfortBusy) { + comfortBusy = true; + try { renderComfort(await api("/api/comfort", {action: "status"})); } + catch (e) { $("comfortStatus").textContent = "Session status unavailable: " + e.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;