Compare commits

...
Author SHA1 Message Date
Alex Southwell fce1800dcf Merge pull request #111 from saphid/feat/audit-ui-structure
Frame Control 0.4.9: Apps / Games / Tools / Settings, one Add / Install flow, whole-app review fixes
2026-10-20 13:08:58 +00:00
saphidandClaude Opus 5.5 3f377d8f56 Cast to this screen shows Capture once too, through the shortcuts' showNow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:54:15 +11:00
saphidandClaude Opus 5.5 9060142f5d Test: a Home shortcut keeps its destination's scroll and loads its tool once, even when hashchange beats the next frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:34:00 +11:00
saphidandClaude Opus 5.5 172851971c Frame Control 0.4.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:25:36 +11:00
saphidandClaude Opus 5.5 48e9ce58ee Home shortcuts show their tool once: pushState instead of a second showPage on hashchange
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:21:39 +11:00
saphidandClaude Opus 5.5 9a5d697579 Merge main (0.4.8: Android game backups) into the four-tab structure; Home's shortcuts focus on the first tap
Main's Import Android game panel joins Quest and Meta under Apps > Browse,
and its Import button (androidBackupOpen, which lived in the old Android
store header and would have thrown at startup here) is now an Add / Install
choice. docs/android-backups.md points to the new places.

Home's shortcuts (Type on the Frame and the rest) now show their tool before
focusing it, so typing works on the first tap and phones open the keyboard
(Sol review P3). A tool panel already in its workspace isn't moved again, which
also dropped focus on the follow-up hashchange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:12:49 +11:00
saphidandClaude Opus 5.5 71ef6a57c7 Service regression tests: Windows has no execute bit to check after an APK rollback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:04:18 +11:00
saphidandClaude Opus 5.5 be9e7b5cda Merge feat/android-store-redesign (with main) into the Apps / Games / Tools / Settings structure
Keeps the four primary destinations and ports main's newer work into them:
the Home "Right now" panel (volume, cast, quick actions) replaces the old
device block, whose readings now live in the header's headset popover; the
install queue sits above the Apps views; Import from my Quest and My Meta
library sit with Browse; the pad, panel switcher, Mac view and media panels
take main's markup. Scrolling follows main's #scroll container. Settings
stays a tab, so main's header gear is not added. The old .home-status
toggle is gone (main's startup test caught it touching a missing node).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:49:13 +11:00
saphidandClaude Opus 5.5 5c44d56f53 Sentence-case headings and tabs, the headset named once on phones, sideloaded games get Play and a menu
Section headings, the tabs, the brand and the activity bar drop small capitals
for sentence case at one heading size. On a phone with a headset picker, the
connection pill keeps only its status dot so the name shows once. Sideloaded
titles get Play and a ⋯ menu (Add artwork, Remove) like installed apps. Manage
sources moves under Source & compatibility. Library artwork is a Settings
page, so Tools no longer lists it.

Refs #101, #106

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:20:38 +11:00
saphidandClaude Opus 5.5 fed2249776 Apps / Games / Tools / Settings, one Add / Install flow, and the whole-app review fixes
Primary tabs become Apps (Installed / Browse / Linux apps), Games (Installed /
Owned / Store), Tools and Settings, with shortcuts 1-4 and one Add / Install
chooser for catalogs, Steam, Linux apps, files and install links. Installed
rows show identity, one state and Open/Stop; the rest moves to app details.

Review fixes (#106): input, touch, approvals, artwork repair, keyboard helper
delivery and catalogue jobs stay bound to the headset they were meant for;
report preview follows the latest consent; failed Store reads are not an empty
library; keyboard access to device details and the Store sheet; the iOS
bundle ships the artwork renderer and OpenXR resources; typed addresses and
custom SSH ports survive setup; WebKit crashes recover; native preferences
survive relaunch; APK update rollback and repair stay retryable.

Refs #101, #104, #106

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:08:09 +11:00
Alex Southwell 1e44a74165 Merge pull request #110: import Android APK and OBB backups together
Import Android APK and OBB backups together
2026-10-20 04:06:25 +00:00
SAPHID 2a3e52f708 Detect atomic device registry saves with identical timestamps 2026-10-20 03:55:08 +00:00
SAPHID b2485500e1 Import Android APK and OBB backups together 2026-10-20 03:54:17 +00:00
48 changed files with 4448 additions and 653 deletions

No files matched your search

+2 -2
View File
@@ -43,8 +43,8 @@ jobs:
run: python -m unittest discover -s tests -v
- name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
- name: Updater tests
run: node --test app/test/updater.test.js
- name: App tests
run: node --test app/test/*.test.js
- name: Website
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
+4
View File
@@ -11,6 +11,7 @@ const os = require("os");
const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const updater = require("./updater");
const { preferenceStore } = require("./preferences");
const { createMetaSignIn } = require("./meta-signin");
const run = promisify(execFile);
@@ -28,6 +29,7 @@ const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Contr
const LOG = path.join(LOG_DIR, "server.log");
const BG = "#0d1117";
const FRAME = process.env.FRAME_ALIAS || "frame";
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
let server = null;
let url = null;
@@ -280,6 +282,8 @@ ipcMain.handle("server:restart", (e) => {
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
ipcMain.handle("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.7",
"version": "0.4.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.7",
"version": "0.4.9",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.7",
"version": "0.4.9",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -39,6 +39,7 @@
"preload.js",
"install-link.js",
"updater.js",
"preferences.js",
"meta-signin.js",
"package.json",
"build/icon.png"
+27
View File
@@ -0,0 +1,27 @@
// Client choices live outside the local server's changing URL/origin.
const fs = require("node:fs");
const path = require("node:path");
const KEYS = ["mvSeparate", "padUsed"];
const valid = value => value === "0" || value === "1";
function preferenceStore(file) {
function read() {
try {
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
} catch { return {}; }
}
function write(key, value) {
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
const saved = { ...read(), [key]: value };
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = file + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
fs.renameSync(tmp, file);
return true;
}
return { read, write };
}
module.exports = { preferenceStore };
+4
View File
@@ -11,6 +11,10 @@
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
preferences: {
read: () => ipcRenderer.invoke("preferences:read"),
write: (key, value) => ipcRenderer.invoke("preferences:write", key, value),
},
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
+60
View File
@@ -0,0 +1,60 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { preferenceStore } = require("../preferences");
function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return path.join(dir, "client-preferences.json");
}
test("desktop packaging includes every local application module dependency", () => {
const root = path.resolve(__dirname, "..");
const packaged = new Set(require("../package.json").build.files);
for (const file of ["main.js", "preload.js"]) {
const source = fs.readFileSync(path.join(root, file), "utf8");
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
const target = match[1].slice(2) + ".js";
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
}
}
});
test("client choices survive a new native session and local server origin", t => {
const file = fixture(t);
const first = preferenceStore(file);
assert.deepEqual(first.read(), {});
first.write("mvSeparate", "0");
first.write("padUsed", "1");
// A new process/session reads the same client store, regardless of its URL.
const second = preferenceStore(file);
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
second.write("mvSeparate", "1");
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
});
test("only the two non-sensitive binary choices can enter the native store", t => {
const file = fixture(t), store = preferenceStore(file);
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
assert.throws(() => store.write(key, value), /Invalid preference/);
}
assert.equal(fs.existsSync(file), false);
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
assert.deepEqual(store.read(), { mvSeparate: "0" });
store.write("padUsed", "1");
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
});
test("a missing or damaged preference file retains usable defaults", t => {
const file = fixture(t), store = preferenceStore(file);
assert.deepEqual(store.read(), {});
for (const bad of ["{broken", "null", "[]", '"text"']) {
fs.writeFileSync(file, bad);
assert.deepEqual(store.read(), {});
}
store.write("padUsed", "1");
assert.deepEqual(store.read(), { padUsed: "1" });
});
+57
View File
@@ -0,0 +1,57 @@
# Import an Android game backup
A SideQuest backup can contain a small APK and much larger `main.…obb` and
`patch.…obb` files. All of them belong to the same game. Installing only the APK
can leave the game unable to start because its assets are missing.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
You can also drop them anywhere in the window. In the desktop app, dropping the
APK alone finds matching OBB files beside it; dropping a folder containing the
APK and OBBs also works. Browsers can read only the files you explicitly choose.
The import screen shows the game app and its game-data files together. **Import
game & data** installs the APK, starts its own Steam library instance, waits for
Android and the installed package, copies the OBBs, and closes the game. Open it
again from the Steam library to play. The screen explains this before import;
choosing files alone never starts a game.
Files are matched using the package inside the APK and the standard
`main|patch.<version>.<package>.obb` name, not the APK's filename. Both main and
patch files are preserved. OBB version numbers need not equal the APK's current
version code: updates can reuse older expansion files. The copy uses the existing
SHA-256 transfer check inside Android and writes to that game's own
`/sdcard/Android/obb/<package>` folder.
If no data files are selected, the screen reminds you to include any OBBs in the
backup before offering **Install game app**. An APK cannot reliably tell Frame
Control whether separate expansion files are required. Game-data files are never
silently sent to Downloads by this flow.
If setup fails, the import screen keeps the error and retry instructions visible.
The APK may already be installed, but the game is not reported ready. Import the
same backup again, or choose just its OBB files to add data to an existing install.
The importer checks the installed package and version before writing data and
closes the instance after a failed copy. Unrelated or duplicate files, running
games that would be replaced, and downgrades are rejected. Uploaded staging files
are discarded after use or cancellation; abandoned imports expire after an hour
when the importer is next used, and dead-server staging folders are swept at
startup.
This flow handles one ordinary APK and standard OBB files for one game at a time.
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
archives need their own import handling. PC ZIP/folder/EXE imports keep their
existing route. Quest USB and Meta library imports keep their existing workflow.
Validation covers actual APK parsing, the three-file backup layout, package
matching, sibling discovery, browser upload staging and cleanup, instance/version
guards and failure recovery with mocked headset operations. Browser verification
uses the real page and upload/inspection endpoints with simulated installation.
**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`:
F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic
install/start/copy/close sequence. Reopening its persistent instance confirmed
both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app,
shortcut and instance were removed afterward, the shared device lock was
released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json).
The headset was unworn; this checks transfer and persistence, not rendering,
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
flow does not change APK compatibility or remove licence checks.
+34
View File
@@ -0,0 +1,34 @@
{
"date": "2026-10-20T03:53:25Z",
"build_id": "20260925.6191901",
"app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)",
"input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050",
"sibling_discovery": true,
"automatic_import": "passed",
"closed_after_import": true,
"independent_readback": [
"5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb",
"aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb"
],
"cleanup": {
"test_install_removed": true,
"running_instances_before": {},
"running_instances_after": {},
"battery_before": [
"100",
"Full",
"Charging"
],
"battery_after": [
"99",
"Discharging",
"Charging"
],
"shared_lock_released": true
},
"limits": [
"No headset rendering or gameplay assertion; headset unworn.",
"Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.",
"A game that exits before its Lepton instance can accept data was not tested."
]
}
+4 -1
View File
@@ -24,7 +24,7 @@ No SideQuest implementation code was copied.
| Installed-app management (launch, uninstall, backup) | List, launch, stop, remove, probe | Already mostly covered. Backup added here. |
| Update notices / account library | Compatible-version lookup; no source-aware installed update notices | Useful later, medium; needs original version code and source identity recorded on install. |
| Custom repositories | Built-in F-Droid catalogue and compatible-version indexes | Separate user-repos worker. Legacy SideQuest source has a fixed SideQuestRepos index; arbitrary current custom-repo support was not verified. |
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | OBB backend added here; future UI can call it. UI drop wiring is not included. |
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | The [Android backup importer](android-backups.md) now groups the APK and matching OBBs, detects desktop siblings and guides installation and data setup. |
| Tags, price, headset filters, reviews | Text search and Lepton verdicts, not Quest headset metadata | Useful, medium; search worker owns filters. Keep source headset claims distinct from tested Frame compatibility. |
| Screenshot/video capture and streaming | Frame screenshots/VR capture already present | Reuse existing tools; do not port Quest capture commands. |
| Device settings and ADB utilities | Frame/Android display settings, SSH and own-instance tools | Borrow selectively; Quest CPU/GPU presets and wireless-ADB setup do not map directly to Lepton. |
@@ -85,6 +85,9 @@ local SHA-256 alone must not be called publisher verification.
## OBB files
For the app's guided APK and OBB import, see [Import an Android game backup](android-backups.md).
The CLI helper below remains available for a running instance.
```sh
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb patch.42.org.example.game.obb
@@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1234567890ABCDEF0123456 /* NativeSessionTests.swift */; };
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1234567890ABCDEF0123456 /* NativeSessionState.swift */; };
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
@@ -35,6 +37,8 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionTests.swift; sourceTree = "<group>"; };
B1234567890ABCDEF0123456 /* NativeSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionState.swift; sourceTree = "<group>"; };
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
@@ -100,6 +104,7 @@
isa = PBXGroup;
children = (
8A11F3431826A26B247C0695 /* AppModel.swift */,
B1234567890ABCDEF0123456 /* NativeSessionState.swift */,
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
);
@@ -111,6 +116,7 @@
children = (
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */,
);
path = FrameControlTests;
sourceTree = "<group>";
@@ -260,6 +266,7 @@
buildActionMask = 2147483647;
files = (
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */,
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
@@ -279,6 +286,7 @@
files = (
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+16 -12
View File
@@ -19,6 +19,20 @@ final class AppModel: ObservableObject {
/// Install links that arrived before the page was ready for them.
@Published var pendingInstallLinks: [InstallLink] = []
/// Native recovery remains reachable even when WebKit cannot draw a page.
@Published private(set) var webRecoveryMessage: String?
@Published private(set) var webReloadID = 0
func webContentFailed(at url: URL, message: String) {
guard phase == .ready(url) else { return }
webRecoveryMessage = message
}
func retryWebContent() {
webRecoveryMessage = nil
webReloadID += 1
}
private var link: FrameLink?
private var server: HeadsetServer?
private var forwarder: PortForwarder?
@@ -99,18 +113,7 @@ final class AppModel: ObservableObject {
/// "host", "host:port" or "[v6]:port", plus a user name.
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
let rest = target.host[target.host.index(after: close)...]
if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port }
target.host = String(target.host[target.host.index(after: target.host.startIndex)..<close])
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
let port = Int(target.host[target.host.index(after: colon)...]) {
target.port = port
target.host = String(target.host[..<colon])
}
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
return target
FrameSettings.parse(host: host, user: user)
}
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
@@ -145,6 +148,7 @@ final class AppModel: ObservableObject {
private func invalidate() {
attempt += 1
retrying = false
webRecoveryMessage = nil
}
/// quiet: a background retry, which leaves the failure screen up until it works.
@@ -0,0 +1,142 @@
import Foundation
/// The persisted SSH target and its editable, lossless address form.
struct FrameSettings: Codable, Equatable {
var host: String
var port: Int = 22
var user: String = "steamos"
var address: String {
let name = host.contains(":") ? "[\(host)]" : host
return port == 22 ? name : "\(name):\(port)"
}
static func parse(host: String, user: String) -> FrameSettings? {
var host = host.trimmingCharacters(in: .whitespacesAndNewlines)
let user = user.trimmingCharacters(in: .whitespacesAndNewlines)
var port = 22
if host.hasPrefix("[") {
guard let close = host.firstIndex(of: "]") else { return nil }
let rest = host[host.index(after: close)...]
if !rest.isEmpty {
guard rest.hasPrefix(":"), let value = Int(rest.dropFirst()) else { return nil }
port = value
}
host = String(host[host.index(after: host.startIndex)..<close])
} else if host.filter({ $0 == ":" }).count == 1, let colon = host.lastIndex(of: ":") {
guard let value = Int(host[host.index(after: colon)...]) else { return nil }
port = value
host = String(host[..<colon])
}
guard !host.isEmpty, !user.isEmpty, (1...65535).contains(port) else { return nil }
return FrameSettings(host: host, port: port, user: user)
}
}
/// Each search owns its callbacks. Cancelling invalidates even probes that cannot
/// be cancelled while Network.framework finishes opening a connection.
struct FrameSearchState {
private(set) var generation = UUID()
private(set) var manualAddress: String?
mutating func start(manualAddress: String?) -> UUID {
generation = UUID()
let typed = manualAddress?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.manualAddress = typed.isEmpty ? nil : typed
return generation
}
mutating func stop() { generation = UUID() }
func accepts(_ generation: UUID) -> Bool { self.generation == generation }
var discovers: Bool { manualAddress == nil }
static func address(manual: String, discovered: String?) -> String {
let typed = manual.trimmingCharacters(in: .whitespacesAndNewlines)
return typed.isEmpty ? (discovered ?? "frame.local") : typed
}
}
/// One automatic recovery per session; only a deliberate retry or a new session
/// replenishes it. A successful load alone must not create a process-crash loop.
struct WebDocumentState {
struct Delivery: Equatable {
let generation: UUID
let documentID: String
}
private(set) var generation = UUID()
private(set) var documentID: String?
private(set) var delivering = false
private var reloads = 0
mutating func beginDocument() {
generation = UUID()
documentID = nil
delivering = false
}
mutating func resetSession() {
reloads = 0
beginDocument()
}
mutating func shouldReloadAfterFailure() -> Bool {
beginDocument()
guard reloads < 1 else { return false }
reloads += 1
return true
}
mutating func ready(_ id: String, generation: UUID) {
guard self.generation == generation else { return }
documentID = id
}
mutating func beginDelivery() -> Delivery? {
guard !delivering, let documentID else { return nil }
delivering = true
return Delivery(generation: generation, documentID: documentID)
}
/// Only acknowledgement from the same live document may consume a link.
mutating func finishDelivery(_ delivery: Delivery, acknowledged: Bool) -> Bool {
guard delivery.generation == generation, delivering, delivery.documentID == documentID else { return false }
delivering = false
if !acknowledged { documentID = nil }
return acknowledged
}
}
/// Small app-scoped preferences survive the tunnel's intentionally ephemeral port.
struct FramePreferences {
private let defaults: UserDefaults
private static let keys = ["mvSeparate", "padUsed"]
private static let prefix = "frame.control.preference."
init(defaults: UserDefaults = .standard) { self.defaults = defaults }
func read() -> [String: String] {
var result: [String: String] = [:]
for key in Self.keys {
if let value = defaults.object(forKey: Self.prefix + key) as? String, ["0", "1"].contains(value) {
result[key] = value
}
}
return result
}
@discardableResult
func write(key: String, value: String) -> Bool {
guard Self.keys.contains(key), ["0", "1"].contains(value) else { return false }
defaults.set(value, forKey: Self.prefix + key)
return true
}
}
/// True for the first caller only.
final class Once: @unchecked Sendable {
private let lock = NSLock()
private var done = false
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
}
+24 -16
View File
@@ -1,3 +1,4 @@
import Combine
import Foundation
import Network
@@ -18,20 +19,30 @@ final class FrameFinder: ObservableObject {
private var browser: NWBrowser?
private var probeTask: Task<Void, Never>?
private var fallback = "frame.local"
private var search = FrameSearchState()
private let probe: @Sendable (String) async -> Bool
private let discoveryEnabled: Bool
func start(fallback: String?) {
init(probe: @escaping @Sendable (String) async -> Bool = { await FrameFinder.sshAnswers(host: $0) },
discoveryEnabled: Bool = true) {
self.probe = probe
self.discoveryEnabled = discoveryEnabled
}
func start(fallback: String?, manual: Bool = false) {
stop()
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
let mine = search.start(manualAddress: manual ? fallback : nil)
let host = FrameSearchState.address(manual: search.manualAddress ?? "", discovered: fallback)
found = nil
since = Date()
browse()
if search.discovers && discoveryEnabled { browse(generation: mine) }
probeTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let host = self.fallback
if self.found == nil, await Self.sshAnswers(host: host) {
self.found = Found(host: host, name: host)
guard let self, self.search.accepts(mine) else { return }
if self.found == nil {
let answers = await self.probe(host)
guard !Task.isCancelled, self.search.accepts(mine) else { return }
if answers, self.found == nil { self.found = Found(host: host, name: host) }
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
@@ -39,20 +50,21 @@ final class FrameFinder: ObservableObject {
}
func stop() {
search.stop()
browser?.cancel()
browser = nil
probeTask?.cancel()
probeTask = nil
}
private func browse() {
private func browse(generation: UUID) {
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
browser.browseResultsChangedHandler = { [weak self] results, _ in
for result in results {
guard case let .service(name, _, _, _) = result.endpoint else { continue }
Self.resolve(result.endpoint) { host in
Task { @MainActor in
guard let self, let host else { return }
guard let self, self.search.accepts(generation), self.search.discovers, let host else { return }
// A found device is used over the fallback probe.
self.found = Found(host: host, name: name)
}
@@ -91,12 +103,8 @@ final class FrameFinder: ObservableObject {
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
nonisolated static func sshAnswers(host address: String) async -> Bool {
var host = address, port: UInt16 = 22
if let target = AppModel.parse(host: address, user: "steamos") {
host = target.host
port = UInt16(target.port)
}
return await sshAnswers(host: host, port: port)
guard let target = FrameSettings.parse(host: address, user: "steamos") else { return false }
return await sshAnswers(host: target.host, port: UInt16(target.port))
}
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
-13
View File
@@ -4,13 +4,6 @@ import Foundation
import NIOCore
import NIOSSH
/// Where the Frame is and who to log in as.
struct FrameSettings: Codable, Equatable {
var host: String
var port: Int = 22
var user: String = "steamos"
}
struct FrameFailure: LocalizedError {
let message: String
/// Retrying can't help: the Frame's identity changed, or it refused this phone's login.
@@ -140,12 +133,6 @@ final class FrameLink: @unchecked Sendable {
}
}
/// True for the first caller only.
final class Once: @unchecked Sendable {
private let lock = NSLock()
private var done = false
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
}
func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
+85 -47
View File
@@ -10,15 +10,31 @@ struct RootView: View {
case .setup:
SetupView(model: model)
case .connecting(let step):
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
ConnectingView(step: step, host: model.settings?.address) { model.showSetup() }
case .failed(let message) where model.retrying && !model.needsPairing:
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
WaitingView(host: model.settings?.address ?? "", detail: message, deviceName: model.deviceName,
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
case .failed(let message):
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
retry: { Task { await model.connect() } }, change: { model.showSetup() })
case .ready(let url):
WebShell(url: url, model: model).ignoresSafeArea()
.accessibilityHidden(model.webRecoveryMessage != nil)
.allowsHitTesting(model.webRecoveryMessage == nil)
if let message = model.webRecoveryMessage {
RecoveryLayout {
VStack(spacing: 16) {
Image(systemName: "arrow.clockwise.circle").font(.system(size: 44)).foregroundStyle(.orange)
Text("Reload Frame Control").font(.title3.bold())
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
Button("Try again") { model.retryWebContent() }
.buttonStyle(.borderedProminent).controlSize(.large)
Button("Change headset") { model.showSetup() }
}
.padding(32).frame(maxWidth: 480)
}
.background(Color.frameBackground.ignoresSafeArea())
}
}
}
.preferredColorScheme(.dark)
@@ -33,20 +49,37 @@ extension Color {
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
}
/// Recovery controls remain reachable on short screens and with large text.
private struct RecoveryLayout<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
GeometryReader { geometry in
ScrollView {
content
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
}
}
}
struct ConnectingView: View {
let step: String
let host: String?
let cancel: () -> Void
var body: some View {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
ProgressView().controlSize(.large)
Text(step).font(.headline).multilineTextAlignment(.center)
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
Button("Change headset", action: cancel).padding(.top, 8)
RecoveryLayout {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
ProgressView().controlSize(.large)
Text(step).font(.headline).multilineTextAlignment(.center)
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
Button("Change headset", action: cancel).padding(.top, 8)
}
.padding(32)
}
.padding(32)
}
}
@@ -59,21 +92,23 @@ struct FailedView: View {
let change: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
.font(.system(size: 44)).foregroundStyle(.orange)
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
if needsPairing {
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
} else if canRetry {
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
RecoveryLayout {
VStack(spacing: 16) {
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
.font(.system(size: 44)).foregroundStyle(.orange)
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
if needsPairing {
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
} else if canRetry {
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
}
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
}
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
.padding(32)
.frame(maxWidth: 480)
}
.padding(32)
.frame(maxWidth: 480)
}
}
@@ -86,34 +121,37 @@ struct WaitingView: View {
let reachable: () -> Void
let change: () -> Void
@State private var pulse = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76)
.clipShape(RoundedRectangle(cornerRadius: 17))
.opacity(pulse ? 1 : 0.55)
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
Text("Waiting for your Frame").font(.title3.bold())
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 10) {
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
RecoveryLayout {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76)
.clipShape(RoundedRectangle(cornerRadius: 17))
.opacity(reduceMotion || pulse ? 1 : 0.55)
.animation(reduceMotion ? nil : .easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
Text("Waiting for your Frame").font(.title3.bold())
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 10) {
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
}
.padding(14)
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
Button("Connect to a different Frame", action: change).font(.footnote)
}
.padding(14)
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
Button("Connect to a different Frame", action: change).font(.footnote)
}
.padding(28)
.frame(maxWidth: 480)
.onAppear { pulse = true }
.task(id: host) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
reachable()
return
.padding(28)
.frame(maxWidth: 480)
.onAppear { pulse = true }
.task(id: host) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if !Task.isCancelled, !host.isEmpty, await FrameFinder.sshAnswers(host: host), !Task.isCancelled {
reachable()
return
}
}
}
}
+10 -9
View File
@@ -13,10 +13,9 @@ struct SetupView: View {
@State private var showHelp = false
@FocusState private var passwordFocused: Bool
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
/// An explicit target always wins over discovery.
private var host: String {
let typed = manualHost.trimmingCharacters(in: .whitespaces)
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
FrameSearchState.address(manual: manualHost, discovered: finder.found?.host)
}
var body: some View {
@@ -34,19 +33,19 @@ struct SetupView: View {
.scrollDismissesKeyboard(.interactively)
.background(Color.frameBackground)
.onAppear {
manualHost = model.settings?.host ?? ""
manualHost = model.settings?.address ?? ""
user = model.settings?.user ?? "steamos"
var fallback = model.settings?.host
var fallback = model.settings?.address
#if DEBUG
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
#endif
finder.start(fallback: fallback)
finder.start(fallback: fallback, manual: !manualHost.isEmpty)
}
.onDisappear { finder.stop() }
// After a few seconds of not finding it, say exactly what to check.
.task(id: finder.since) {
try? await Task.sleep(nanoseconds: 8_000_000_000)
showHelp = true
if !Task.isCancelled { showHelp = true }
}
}
@@ -120,7 +119,7 @@ struct SetupView: View {
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
.onSubmit { finder.start(fallback: manualHost) }
.onSubmit { finder.start(fallback: manualHost, manual: true) }
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
TextField("User", text: $user)
.textInputAutocapitalization(.never).autocorrectionDisabled()
@@ -149,7 +148,9 @@ struct SetupView: View {
}
.onChange(of: manualHost) { _, value in
// A typed address replaces the search.
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
showHelp = false
let typed = value.trimmingCharacters(in: .whitespacesAndNewlines)
finder.start(fallback: typed, manual: !typed.isEmpty)
}
}
+105 -19
View File
@@ -31,14 +31,13 @@ struct WebShell: UIViewRepresentable {
web.isInspectable = true
#endif
context.coordinator.web = web
web.load(URLRequest(url: url))
context.coordinator.load(url, reloadID: model.webReloadID)
return web
}
func updateUIView(_ web: WKWebView, context: Context) {
if context.coordinator.loaded != url {
context.coordinator.loaded = url
web.load(URLRequest(url: url))
if context.coordinator.loaded != url || context.coordinator.reloadID != model.webReloadID {
context.coordinator.load(url, reloadID: model.webReloadID)
}
context.coordinator.deliverInstallLinks()
}
@@ -47,16 +46,21 @@ struct WebShell: UIViewRepresentable {
(() => {
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
let installCb = null;
window.__frameDocumentID = crypto.randomUUID();
window.frameApp = {
platform: "ios",
preferences: {
read: () => call("preferencesRead"),
write: (key, value) => call("preferencesWrite", { key, value }),
},
notify: (message, request) => call("notify", { message, request }),
readClipboard: () => call("readClipboard"),
setUpConnection: () => call("setUpConnection"),
open: (what) => call("open", what),
saveImages: (images) => call("saveImages", images),
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady"); },
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady", window.__frameDocumentID); },
};
window.__frameInstallLink = (req) => { if (installCb) installCb(req); };
window.__frameInstallLink = (req) => { if (!installCb) return false; installCb(req); return true; };
})();
"""
@@ -64,7 +68,18 @@ struct WebShell: UIViewRepresentable {
let model: AppModel
weak var web: WKWebView?
var loaded: URL?
private var installReady = false
private(set) var document = WebDocumentState()
private(set) var reloadID = 0
private var activeNavigation: WKNavigation?
private let preferences = FramePreferences()
@MainActor
func load(_ url: URL, reloadID: Int) {
loaded = url
self.reloadID = reloadID
document.resetSession()
activeNavigation = web?.load(URLRequest(url: url))
}
init(model: AppModel) {
self.model = model
@@ -118,6 +133,14 @@ struct WebShell: UIViewRepresentable {
send(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, nil)
}
}
case "preferencesRead":
replyHandler(preferences.read(), nil)
case "preferencesWrite":
guard let args = arg as? [String: Any], let key = args["key"] as? String,
let value = args["value"] as? String, preferences.write(key: key, value: value) else {
return replyHandler(nil, "Invalid preference")
}
replyHandler(true, nil)
case "readClipboard":
replyHandler(UIPasteboard.general.string ?? "", nil)
case "setUpConnection":
@@ -135,9 +158,20 @@ struct WebShell: UIViewRepresentable {
share(images)
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
case "installLinkReady":
installReady = true
deliverInstallLinks()
replyHandler(nil, nil)
guard let id = arg as? String, let web,
let data = try? JSONSerialization.data(withJSONObject: id, options: .fragmentsAllowed),
let json = String(data: data, encoding: .utf8) else {
return replyHandler(nil, "Invalid document")
}
let generation = document.generation
web.evaluateJavaScript("window.__frameDocumentID === \(json)") { [weak self] result, error in
guard let self else { return replyHandler(nil, "Page closed") }
if error == nil, result as? Bool == true {
self.document.ready(id, generation: generation)
self.deliverInstallLinks()
}
replyHandler(nil, nil)
}
default:
replyHandler(nil, "unknown request \(name)")
}
@@ -145,13 +179,23 @@ struct WebShell: UIViewRepresentable {
@MainActor
func deliverInstallLinks() {
guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return }
let links = model.pendingInstallLinks
model.pendingInstallLinks = []
for link in links {
let req = ["kind": link.kind.rawValue, "target": link.target]
guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue }
web.evaluateJavaScript("window.__frameInstallLink(\(text))")
guard let web, let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
let link = model.pendingInstallLinks.first, let delivery = document.beginDelivery() else { return }
let req = ["kind": link.kind.rawValue, "target": link.target]
guard let json = try? JSONSerialization.data(withJSONObject: req),
let text = String(data: json, encoding: .utf8),
let idData = try? JSONSerialization.data(withJSONObject: delivery.documentID, options: .fragmentsAllowed),
let id = String(data: idData, encoding: .utf8) else {
_ = document.finishDelivery(delivery, acknowledged: false)
return
}
// Keep the head queued until the live document confirms its callback ran.
web.evaluateJavaScript("window.__frameDocumentID === \(id) && window.__frameInstallLink(\(text)) === true") { [weak self] result, error in
guard let self, let loaded = self.loaded, self.model.phase == .ready(loaded),
self.reloadID == self.model.webReloadID else { return }
guard self.document.finishDelivery(delivery, acknowledged: error == nil && result as? Bool == true) else { return }
if self.model.pendingInstallLinks.first == link { self.model.pendingInstallLinks.removeFirst() }
self.deliverInstallLinks()
}
}
@@ -186,13 +230,55 @@ struct WebShell: UIViewRepresentable {
(root.presentedViewController ?? root).present(sheet, animated: true)
}
#if DEBUG
// A new document cannot receive links using the previous page's callback.
@MainActor
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
activeNavigation = navigation
document.beginDocument()
}
@MainActor
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
recover(webView, message: "The app page stopped responding. Try loading it again, or change headset.")
}
@MainActor
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
navigationFailed(webView, navigation: navigation, error: error)
}
@MainActor
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
navigationFailed(webView, navigation: navigation, error: error)
}
@MainActor
private func navigationFailed(_ webView: WKWebView, navigation: WKNavigation?, error: Error) {
guard navigation === activeNavigation, (error as NSError).code != NSURLErrorCancelled else { return }
recover(webView, message: "Couldn't load the app page. \(error.localizedDescription)")
}
@MainActor
private func recover(_ webView: WKWebView, message: String) {
guard let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
model.webRecoveryMessage == nil else { return }
activeNavigation = nil
if document.shouldReloadAfterFailure() {
// Reload the original authenticated URL, including its session key.
activeNavigation = webView.load(URLRequest(url: loaded))
} else {
model.webContentFailed(at: loaded, message: message)
}
}
/// Simulator test hook: FRAME_TEST_JS runs in the page once it has loaded.
@MainActor
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
#if DEBUG
guard let js = ProcessInfo.processInfo.environment["FRAME_TEST_JS"] else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { webView.evaluateJavaScript(js) }
#endif
}
#endif
// MARK: navigation: the app's page stays here; other sites open in Safari
@@ -0,0 +1,100 @@
import Foundation
import XCTest
#if !NATIVE_CONTRACT_STANDALONE
@testable import Frame_Control
#endif
final class NativeSessionTests: XCTestCase {
func testSavedAddressesRoundTripWithoutLosingPortOrIPv6() throws {
for host in ["frame.local", "100.100.100.2", "2001:db8::42", "fe80::1%en0"] {
for port in [22, 2222, 65535] {
let target = FrameSettings(host: host, port: port, user: "alex")
let saved = try JSONDecoder().decode(FrameSettings.self, from: JSONEncoder().encode(target))
XCTAssertEqual(FrameSettings.parse(host: saved.address, user: saved.user), target)
}
}
XCTAssertEqual(FrameSettings.parse(host: " [2001:db8::42]:2222\n", user: " steamos "),
FrameSettings(host: "2001:db8::42", port: 2222))
for host in ["", "frame.local:0", "frame.local:65536", "frame.local:no", "[::1]:no", "[::1]junk", "[::1"] {
XCTAssertNil(FrameSettings.parse(host: host, user: "steamos"), host)
}
}
func testManualTargetWinsAndSearchCallbacksExpire() {
XCTAssertEqual(FrameSearchState.address(manual: " 100.100.100.2:2222 ", discovered: "192.168.1.237"), "100.100.100.2:2222")
XCTAssertEqual(FrameSearchState.address(manual: " \n", discovered: "192.168.1.237"), "192.168.1.237")
XCTAssertEqual(FrameSearchState.address(manual: "", discovered: nil), "frame.local")
var search = FrameSearchState()
let bonjour = search.start(manualAddress: nil)
XCTAssertTrue(search.discovers)
let manual = search.start(manualAddress: "100.100.100.2:2222")
XCTAssertFalse(search.discovers)
XCTAssertFalse(search.accepts(bonjour))
XCTAssertTrue(search.accepts(manual))
search.stop()
XCTAssertFalse(search.accepts(manual))
_ = search.start(manualAddress: "")
XCTAssertTrue(search.discovers)
}
func testRecoveryIsBoundedUntilAnExplicitRetry() {
var page = WebDocumentState()
XCTAssertTrue(page.shouldReloadAfterFailure())
page.beginDocument()
page.ready("reloaded", generation: page.generation)
XCTAssertFalse(page.shouldReloadAfterFailure())
XCTAssertFalse(page.shouldReloadAfterFailure())
page.resetSession()
XCTAssertTrue(page.shouldReloadAfterFailure())
}
func testReplacementDocumentsCannotConsumeQueuedLinks() throws {
var page = WebDocumentState()
var queue = ["first", "second"]
XCTAssertNil(page.beginDelivery())
let oldGeneration = page.generation
page.ready("first document", generation: oldGeneration)
let oldDelivery = try XCTUnwrap(page.beginDelivery())
XCTAssertNil(page.beginDelivery(), "Serialize delivery while an acknowledgement is pending")
XCTAssertTrue(page.shouldReloadAfterFailure())
page.ready("stale readiness", generation: oldGeneration)
XCTAssertNil(page.beginDelivery())
if page.finishDelivery(oldDelivery, acknowledged: true) { queue.removeFirst() }
XCTAssertEqual(queue, ["first", "second"])
page.ready("replacement", generation: page.generation)
let failedDelivery = try XCTUnwrap(page.beginDelivery())
if page.finishDelivery(failedDelivery, acknowledged: false) { queue.removeFirst() }
XCTAssertEqual(queue, ["first", "second"])
XCTAssertNil(page.beginDelivery(), "An evaluation error needs fresh readiness")
page.ready("replacement", generation: page.generation)
let liveDelivery = try XCTUnwrap(page.beginDelivery())
if page.finishDelivery(liveDelivery, acknowledged: true) { queue.removeFirst() }
XCTAssertEqual(queue, ["second"])
XCTAssertNotNil(page.beginDelivery())
page.beginDocument()
XCTAssertNil(page.beginDelivery(), "Even healthy main-document navigation resets readiness")
}
func testPreferencesPersistAcrossSessionsAndRejectOtherKeysAndValues() throws {
let suite = "frame-control-contract-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let first = FramePreferences(defaults: defaults)
XCTAssertEqual(first.read(), [:])
XCTAssertTrue(first.write(key: "mvSeparate", value: "0"))
XCTAssertTrue(first.write(key: "padUsed", value: "1"))
let second = FramePreferences(defaults: try XCTUnwrap(UserDefaults(suiteName: suite)))
XCTAssertEqual(second.read(), ["mvSeparate": "0", "padUsed": "1"])
for (key, value) in [("other", "1"), ("mvSeparate", "false"), ("padUsed", ""), ("padUsed", "2")] {
XCTAssertFalse(second.write(key: key, value: value))
}
XCTAssertEqual(first.read(), ["mvSeparate": "0", "padUsed": "1"])
XCTAssertNil(defaults.object(forKey: "frame.control.preference.other"))
defaults.set("bad", forKey: "frame.control.preference.mvSeparate")
defaults.set("1", forKey: "frame.control.preference.unknown")
XCTAssertEqual(second.read(), ["padUsed": "1"])
defaults.set(true, forKey: "frame.control.preference.padUsed")
XCTAssertEqual(second.read(), [:], "Only stored strings are supported")
}
}
+2 -1
View File
@@ -18,7 +18,8 @@ import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py", "frame/android/*.js",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json", "frame/openxr-compat/prebuilt/arm64-v8a/*.so.gz",
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
+72
View File
@@ -0,0 +1,72 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync(require.resolve('../ui/index.html'), 'utf8');
async function session(native, initial = {}, duringRead) {
const saved = { ...initial }, element = { checked: true, disabled: false };
const storage = { getItem: key => saved[key] ?? null, setItem: (key, value) => { saved[key] = String(value); } };
for (const key of ['mvSeparate', 'padUsed']) Object.defineProperty(storage, key, {
get: () => saved[key], set: value => { saved[key] = String(value); },
});
const calls = [];
const context = vm.createContext({ window: { frameApp: native ? { preferences: native } : undefined }, localStorage: storage,
$: () => element, log: (...args) => calls.push(args),
api: async () => ({ state: 'off' }), padStart: () => calls.push('start'), padShow: s => calls.push(s.state) });
const marker = html.indexOf('// ---- client preferences');
const start = marker >= 0 ? marker : html.indexOf('$("mvSeparate").checked =');
vm.runInContext(html.slice(start, html.indexOf('$("macview").onclick', start)), context);
if (duringRead) duringRead(context, element);
await vm.runInContext('typeof clientPreferences === "undefined" ? Promise.resolve() : clientPreferences.ready', context);
return { element, saved, calls, context, async change(value) {
element.checked = value; element.onchange({ target: element });
await new Promise(resolve => setImmediate(resolve));
} };
}
test('a layout choice survives moving from one native local origin to another', async () => {
const values = {};
const native = { read: async () => ({ ...values }), write: async (key, value) => { values[key] = value; } };
const first = await session(native);
await first.change(false);
assert.equal(first.element.checked, false);
const second = await session(native); // fresh origin storage
assert.equal(second.element.checked, false);
});
test('native values win over stale origin values and missing values migrate', async () => {
const values = { mvSeparate: '0' };
const native = { read: async () => ({ ...values }), write: async (key, value) => { values[key] = value; } };
const page = await session(native, { mvSeparate: '1', padUsed: '1' });
assert.equal(page.element.checked, false);
assert.equal(values.padUsed, '1');
});
test('ordinary browser storage and a failed native read retain usable choices', async () => {
assert.equal((await session(null, { mvSeparate: '0' })).element.checked, false);
const native = { read: async () => { throw Error('Unavailable'); }, write: async () => true };
const page = await session(native, { mvSeparate: '0' });
assert.equal(page.element.checked, false);
assert.equal(page.element.disabled, false);
});
test('starting input during a native read wins over its stale remembered value', async () => {
let resolveRead;
const writes = [];
const native = { read: () => new Promise(resolve => { resolveRead = resolve; }), write: async (key,value) => writes.push({key,value}) };
const page = await session(native, {}, context => {
vm.runInContext("clientPreferences.set('padUsed','1')",context);
resolveRead({padUsed:'0'});
});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(vm.runInContext("clientPreferences.get('padUsed')",page.context),'1');
assert.deepEqual(writes,[{key:'padUsed',value:'1'}]);
});
test('one failed migration does not hide another native preference', async () => {
const native = {read:async()=>({padUsed:'1'}),write:async()=>{throw Error('disk unavailable');}};
const page=await session(native,{mvSeparate:'0'});
assert.equal(page.element.checked,false);
assert.equal(vm.runInContext("clientPreferences.get('padUsed')",page.context),'1');
});
+12 -33
View File
@@ -14,7 +14,7 @@ function fixture() {
const elements = new Map();
function element(id) {
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
classList:{toggle(){}},querySelectorAll(){return [];},close(){this.open=false;},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
return elements.get(id);
}
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
@@ -42,7 +42,7 @@ function sharedPage({productionApi=false} = {}) {
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
battery(){},setVolume(){},renderHome(){},gb:String,dur:String,esc:String,meter(){},toast(){},
runJob(title,id){context.installing.add(id);return job.promise;},
api:(path,body,device)=>{
if(path==='/api/host')return host.promise;
@@ -64,11 +64,12 @@ function sharedPage({productionApi=false} = {}) {
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
};
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
padReset(){},ctrlReset(){},resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
vm.runInContext(`const UI_KEY = 'test-only';
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
const disp = {}, gm = {seq:0}, cat = {}, wi = {}, sourceState = {jobs:new Map(),detail:null,detailRequest:0};
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], androidSeq = 0, titlesSeq = 0, page = 'control';
const androidPending = new Set();`,context);
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
@@ -438,35 +439,13 @@ test('mounted filters show empty state, counts, reset and host-dependent feature
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
});
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
let activePage,scrolled;
const nodes=['home','games','android','tools','devices','control','settings'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
const sections=new Map();
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
window:{scrollTo(){}},$:id=>{
if(!ids.has(id))return null;
if(!sections.has(id))sections.set(id,{hidden:false,classList:{toggle(){}},scrollIntoView(){scrolled=id;}});
return sections.get(id);
},live:false,toggleLive(){},scrollToY(){},loadMacView(){},loadPanelSwitcher(){}};
const androidStart=html.indexOf('function showAndroidTab(');
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\n'+html.match(/^PAGES\.push\([^\n]+/m)[0],context);
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
test('every Control Center workflow reaches its intentional workspace', () => {
const {workspaceFixture}=require('./workspace_fixture.cjs');
const f=workspaceFixture();
const expected={view:'capture',shots:'capture',vrTools:'tools',comfort:'tools',pad:'tools',library:'games',getgames:'games',sideloaded:'games',android:'apps',display:'settings',androidLibrary:'apps',appStore:'apps',power:'settings',transfer:'tools',media:'tools',linuxApps:'apps',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
for(const item of cc.catalog) for(const [href] of item.links||[]) {
if(!href.startsWith('#')||href==='#connection')continue;
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
assert.equal(activePage,expected[id],href);
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
f.navigate(href);assert.equal(f.activePage,expected[href.slice(1)],href);
}
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
assert.equal(vm.runInContext('PAGES[5]',context),'control');
assert.equal(vm.runInContext('PAGES[6]',context),'settings');
assert.equal(f.run('PAGES.length'),4);
});
+1 -1
View File
@@ -23,7 +23,7 @@ function page({ mode = "abs", rect = { left: 0, top: 0, width: 640, height: 360
const sent = [];
const canvas = { width: 1280, height: 720, getBoundingClientRect: () => rect };
const env = {
ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
devGen: 0, ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
ctrlTarget: () => (mode !== "abs" ? { ok: true } : target.panel ? { ok: true, panel: target.panel } : { why: "gone" }),
api: api || (async (path, body) => { sent.push(...body.events); return { state: "ready", sent: true }; }),
requestAnimationFrame: cb => { setTimeout(cb, 0); return 1; },
+1 -1
View File
@@ -8,7 +8,7 @@ const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
function page(api) {
const pad = { state: "ready", queue: [], sending: false };
const shown = [];
const fns = new Function("pad", "api", "padShow", code + "; return { padSend, padFlush };")(pad, api, s => { shown.push(s); pad.state = s.state; });
const fns = new Function("pad", "api", "padShow", "devGen", code + "; return { padSend, padFlush };")(pad, api, s => { shown.push(s); pad.state = s.state; }, 0);
return { pad, shown, ...fns };
}
+12 -4
View File
@@ -25,7 +25,7 @@ const code = names.map(grab).join("");
// A fake page: elements by id. The install panel's elements exist only while
// #detailBody holds the real app page, as in the browser.
function page(api = async () => ({})) {
function page(api = async () => ({}), androidApps = []) {
const els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } };
const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
@@ -34,10 +34,10 @@ function page(api = async () => ({})) {
const document = { activeElement: null, querySelector: () => null };
const console = { error() {} };
const calls = [];
const fns = new Function("$", "sourceState", "document", "console", "api", "runJob", "loadAndroid", "act", "window", code + "; return { renderAppDetail, openStoreApp };")(
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); },
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp };")(
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps,
async (title, key, work) => work(), () => {}, async (title, work) => work(),
{ open() { fail("an in-app installation opened an external page"); } });
{ open() { fail("an in-app installation opened an external page"); } }, 0);
return { els, sourceState, calls, ...fns };
}
@@ -56,6 +56,14 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
if (html.includes('role="alert"')) fail("a good listing showed the error state");
}
// A catalogue listing that is already installed offers its management page.
{
const p = page(undefined, [{ package: offer.package }]);
p.sourceState.detail = { app: { name: offer.name, offers: [{ ...offer }] }, choice: 0 };
if (p.renderAppDetail() !== true) fail("an installed listing reported a render failure");
if (p.els.detailInstall.textContent !== "View installed app") fail("installed listing offered another install");
}
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
{
const p = page();
+219
View File
@@ -0,0 +1,219 @@
// Production standalone scripts with deterministic DOM/network boundaries. No browser/headset.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = process.env.STANDALONE_SOURCE_ROOT || path.resolve(__dirname, '..');
const read = name => fs.readFileSync(path.join(root, 'ui', name), 'utf8');
const script = html => html.match(/<script>([\s\S]*?)<\/script>/)[1];
const tick = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); };
function fixture(html) {
const elements = new Map(), events = new Map(), requests = [];
const document = {
activeElement: null,
getElementById: id => element(id),
querySelector: selector => selector === 'main' ? element('main') : document.querySelectorAll(selector)[0],
querySelectorAll: selector => [...elements.values()].flatMap(el => el.children).filter(el => el.matches(selector)),
addEventListener: (name, fn) => { if (!events.has(name)) events.set(name, []); events.get(name).push(fn); },
};
class Element {
constructor(id, attrs = '', text = '') {
this.id = id; this.dataset = {}; this.children = []; this.hidden = false; this.open = false;
this.isConnected = true; this.value = ''; this.checked = false; this.disabled = /\bdisabled\b/.test(attrs);
this.textContent = text; this.attrs = {}; this.listeners = new Map();
for (const [, key, value] of attrs.matchAll(/([\w-]+)="([^"]*)"/g)) {
this.attrs[key] = value;
if (key.startsWith('data-')) this.dataset[key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
}
for (const [, key] of attrs.matchAll(/\bdata-([\w-]+)(?=\s|$)/g)) if (!Object.hasOwn(this.dataset, key)) this.dataset[key] = '';
}
matches(s) {
if (s === 'button:not(:disabled)') return this.tag === 'button' && !this.disabled;
if (s.startsWith('#')) return this.id === s.slice(1);
const data = s.match(/\[data-([\w-]+)(?:="([^"]*)")?\]/);
if (data) return Object.hasOwn(this.dataset, data[1]) && (data[2] === undefined || this.dataset[data[1]] === data[2]);
return false;
}
set innerHTML(value) {
this.html = value;
for (const child of this.children) { child.isConnected = false; if (document.activeElement === child) document.activeElement = null; }
this.children = [...value.matchAll(/<(button|h2)\b([^>]*)>([\s\S]*?)<\/\1>/g)].map(([, tag, attrs, text]) => {
const child = new Element((attrs.match(/\bid="([^"]+)"/) || [])[1], attrs, text);
child.tag = tag; child.parent = this; return child;
});
}
get innerHTML() { return this.html || ''; }
querySelectorAll(s) { return this.children.filter(el => el.matches(s)); }
querySelector(s) { return this.querySelectorAll(s)[0] || null; }
contains(el) { return el === this || this.children.includes(el); }
closest(s) { return this.matches(s) ? this : this.parent?.closest(s) || null; }
setAttribute(k, v) { this.attrs[k] = v; }
append(el) { el.parent = this; }
focus() { if (!this.disabled && this.isConnected) document.activeElement = this; }
showModal() { this.open = true; this.modalOpened = true; }
close() { this.open = false; this.onclose?.(); }
addEventListener(name, fn) { this.listeners.set(name, fn); }
reset() {}
}
function element(id) {
const dynamic = [...elements.values()].flatMap(el => el.children).find(el => el.id === id);
if (dynamic) return dynamic;
if (!elements.has(id)) elements.set(id, new Element(id));
return elements.get(id);
}
element('detail').hidden = element('kbd').hidden = true;
document.body = element('body');
// The sheet is stable; its contents are replaced by the production redraw.
element('detail').contains = el => el === element('detail') || element('sheet').contains(el);
const context = {document, location: {search: '', hash: ''}, URLSearchParams,
window: {addEventListener: (name, fn) => events.set(name, [fn])},
setTimeout: () => 1, clearTimeout() {}, Event: class {},
fetch: (url, options) => new Promise((resolve, reject) => requests.push({url, options, resolve, reject})),
};
vm.createContext(context);
vm.runInContext(script(html).replace('__FRAME_KEY__', JSON.stringify('ios +&?key/#')), context);
const run = code => vm.runInContext(code, context);
const answer = async (index, data, ok = true) => { (typeof index === 'number' ? requests[index] : index).resolve({ok, json: async () => data}); await tick(); };
return {element, document, context, requests, run, answer, events};
}
test('Assistant Back encodes the injected key for the same-origin workspace only', () => {
const html = read('assistant.html'), f = fixture(html);
const back = f.element('back');
const fallback = html.match(/<a[^>]*href="([^"]*)"[^>]*>Back to Frame Control/)[1];
const destination = new URL(back.href || fallback, 'http://127.0.0.1:8123/assistant#confirm=private');
assert.equal(destination.origin, 'http://127.0.0.1:8123');
assert.equal(destination.pathname, '/');
assert.equal(destination.hash, '');
assert.equal(destination.searchParams.get('key'), 'ios +&?key/#');
const index = fs.readFileSync(path.join(__dirname, '../ui/index.html'), 'utf8');
const expression = index.match(/const UI_KEY = ([^;]+);/)[1];
assert.equal(vm.runInNewContext(expression, {URLSearchParams, location: {search: destination.search}}), 'ios +&?key/#');
for (const [, href] of html.matchAll(/href="(https?:[^\"]+)"/g)) assert.ok(!href.includes('key='));
assert.match(html, /referrerpolicy="no-referrer"/);
});
test('installed read failure is persistent, retry recovers, and cached entries are marked stale', async () => {
const f = fixture(read('store.html'));
f.run('st.view = "installed"; render()');
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
f.requests[0].reject(Error('offline')); await tick();
assert.equal(f.run('st.installedState'), 'error');
assert.match(f.element('content').innerHTML, /data-retry-installed/);
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
const retry = f.element('content').querySelector('[data-retry-installed]');
for (const listener of f.events.get('click')) listener({target: retry});
assert.equal(f.requests[1].url, '/api/android');
await f.answer(1, {apps: [{package: 'cached.app', label: 'Cached app'}]});
assert.equal(f.run('st.installedState'), 'ready');
const failed = f.run('loadInstalled()');
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
f.requests[2].reject(Error('refresh failed')); await failed;
assert.match(f.element('content').innerHTML, /Cached app/);
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
assert.match(f.element('content').innerHTML, /data-retry-installed/);
const empty = f.run('loadInstalled()'); await f.answer(3, {apps: []}); await empty;
assert.match(f.element('content').innerHTML, /Nothing installed yet/);
assert.doesNotMatch(f.element('content').innerHTML, /data-retry-installed/);
});
test('newest installed read wins over older success and failure without pruning cached data', async () => {
const f = fixture(read('store.html'));
f.run('st.needsData.add("new.app"); st.jobs.set("data:new.app", {done: false})');
const newer = f.run('loadInstalled()'); await f.answer(1, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
await f.answer(0, {apps: []});
assert.equal(f.run('st.installed.has("new.app")'), true);
assert.equal(f.run('st.needsData.has("new.app")'), true);
const old = f.run('loadInstalled()'), newest = f.run('loadInstalled()');
await f.answer(4, {apps: []}); await newest; // bootstrap's catalogue search is request 2
f.requests[3].reject(Error('late failure')); await old;
assert.equal(f.run('st.installedState'), 'ready');
assert.equal(f.run('st.installedError'), '');
assert.equal(f.run('st.installed.size'), 0);
});
test('an old success cannot mask the newest failure, and malformed data is not successful empty', async () => {
const f = fixture(read('store.html'));
f.run('st.view = "installed"');
const newest = f.run('loadInstalled()'); f.requests[1].reject(Error('latest offline')); await newest;
await f.answer(0, {apps: []});
assert.equal(f.run('st.installedState'), 'error');
assert.equal(f.run('st.installedError'), 'latest offline');
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
const malformed = f.run('loadInstalled()'); await f.answer(2, {}); await malformed;
assert.equal(f.run('st.installedState'), 'error');
assert.match(f.element('content').innerHTML, /data-retry-installed/);
});
test('catalogue details distinguish unknown and stale installed state', async () => {
const f = fixture(read('store.html'));
f.context.app = {name: 'Example', package: 'example.app', detailed: true, offers: [{source: 'test', id: 'example'}]};
f.run('openDetail(app)');
assert.doesNotMatch(f.element('sheet').innerHTML, /data-act="install"/);
f.requests[0].reject(Error('offline')); await tick();
assert.doesNotMatch(f.element('sheet').innerHTML, /data-act="install"/);
const loaded = f.run('loadInstalled()'); await f.answer(f.requests.at(-1), {apps: []}); await loaded;
assert.match(f.element('sheet').innerHTML, /data-act="install"/);
});
test('named native Store dialog enters, contains and restores focus across async redraws', async () => {
const html = read('store.html');
assert.match(html.match(/<[^>]*id="detail"[^>]*>/)[0], /<dialog\b[^>]*aria-labelledby="detailTitle"/);
const f = fixture(html);
await f.answer(0, {apps: []});
f.context.app = {name: 'Example', offers: [{source: 'test', id: 'example'}]};
f.run('st.view = "games"; st.apps = [app]; st.loading = false; render()');
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
f.run('openDetail(app)');
assert.equal(f.element('detail').modalOpened, true);
assert.equal(f.element('toast').parent, f.element('detail'));
assert.equal(f.document.activeElement.id, 'detailTitle');
const install = f.element('sheet').querySelector('[data-act="install"]'); install.focus();
await f.answer(2, {description: 'Details arrived'}); // request 1 is bootstrap search
assert.equal(f.document.activeElement.dataset.act, 'install');
assert.notEqual(f.document.activeElement, install);
const close = f.element('sheet').querySelector('[data-act="close"]'); close.focus();
f.run('st.jobs.set("test:example", {done: false, stage: "Downloading", percent: 20}); drawDetail(); render()');
assert.equal(f.document.activeElement.dataset.act, 'close');
const keydown = f.element('detail').onkeydown || f.element('detail').listeners.get('keydown');
const event = {key: 'Tab', shiftKey: false, preventDefault() {this.prevented = true;}};
keydown(event);
assert.equal(event.prevented, true);
assert.equal(f.document.activeElement.dataset.act, 'close'); // only remaining action
f.run('st.jobs.delete("test:example"); drawDetail()');
const first = f.element('sheet').querySelector('[data-act="install"]'); first.focus();
const reverse = {key: 'Tab', shiftKey: true, preventDefault() {this.prevented = true;}}; keydown(reverse);
assert.equal(reverse.prevented, true); assert.equal(f.document.activeElement.dataset.act, 'close');
first.focus(); f.run('st.jobs.set("test:example", {done: false}); drawDetail()');
assert.equal(f.document.activeElement.dataset.act, 'close'); // focused action disappeared
f.run('closeDetail()');
assert.equal(f.element('detail').open, false);
assert.equal(f.element('toast').parent, f.document.body);
assert.equal(f.document.activeElement.dataset.i, '0');
assert.notEqual(f.document.activeElement, opener); // render replaced the background card
});
test('dialog Retry recovers installed knowledge without losing focus, and cancel restores its opener', async () => {
const f = fixture(read('store.html'));
f.context.app = {name: 'Example', detailed: true, offers: [{source: 'test', id: 'example'}]};
f.run('st.view = "games"; st.apps = [app]; render()');
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
f.run('openDetail(app)');
f.requests[0].reject(Error('offline')); await tick();
await f.answer(1, {apps: [f.context.app], sources: []});
const retry = f.element('sheet').querySelector('[data-retry-installed]'); retry.focus();
for (const listener of f.events.get('click')) listener({target: retry});
assert.equal(f.document.activeElement.dataset.act, 'close');
await f.answer(f.requests.at(-1), {apps: []});
assert.equal(f.document.activeElement.dataset.act, 'close');
assert.match(f.element('sheet').innerHTML, /data-act="install"/);
const cancel = {preventDefault() {this.prevented = true;}};
f.element('detail').oncancel(cancel);
assert.equal(cancel.prevented, true); assert.equal(f.element('detail').open, false);
assert.equal(f.document.activeElement.dataset.i, '0');
// Removing the app while details are open falls back to Search on close.
f.run('openDetail(app); st.apps = []; render(); closeDetail()');
assert.equal(f.document.activeElement, f.element('q'));
});
+177
View File
@@ -0,0 +1,177 @@
"""Local APK/OBB backups: inspect as a unit, verify the target, and finish setup."""
import sandbox # noqa: F401
import io
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_android_import as backup
import server
from apk_fixtures import apk, manifest
PKG = 'com.Armature.VR4'
META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210,
'instance': 2800000001, 'shortcut': 123, 'game_id': 456}
class BackupTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.apk = self.root / 'VR4-Android-Shipping-arm64.apk'
self.apk.write_bytes(apk({'AndroidManifest.xml': manifest(PKG, 0x7f010000, 0x7f010001, 26)}))
self.obb = [self.root / f'{kind}.203.{PKG}.obb' for kind in ('main', 'patch')]
for p in self.obb:
p.write_bytes(b'game data')
def test_desktop_apk_finds_matching_siblings_but_browser_requires_selection(self):
(self.root / 'main.1.org.other.game.obb').write_bytes(b'other game')
with patch.object(backup.android, 'ssh') as ssh:
desktop = backup.inspect([str(self.apk)], discover=True)
browser = backup.inspect([str(self.apk)])
selected = backup.inspect([str(self.apk), *map(str, self.obb)])
ssh.assert_not_called()
self.assertEqual(desktop['obb'], self.obb)
self.assertEqual(browser['obb'], [])
self.assertEqual(backup.public(selected)['obb'][0]['name'], self.obb[0].name)
self.assertEqual(backup.public(selected)['package'], PKG)
self.assertNotIn(str(self.root), str(backup.public(selected)))
def test_wrong_data_empty_data_duplicate_names_and_multiple_apks_fail_before_install(self):
other = self.root / 'main.203.org.other.game.obb'
other.write_bytes(b'other game')
with self.assertRaisesRegex(backup.android.FrameError, 'does not belong'):
backup.inspect([str(self.apk), str(other)])
self.obb[0].write_bytes(b'')
with self.assertRaisesRegex(backup.android.FrameError, 'nonempty'):
backup.inspect([str(self.apk), str(self.obb[0])])
second = self.root / 'another.apk'
second.write_bytes(self.apk.read_bytes())
with self.assertRaisesRegex(backup.android.FrameError, 'one game at a time'):
backup.inspect([str(self.apk), str(second)])
self.obb[0].write_bytes(b'data')
folder = self.root / 'copy'
folder.mkdir()
duplicate = folder / self.obb[0].name
duplicate.write_bytes(b'data')
with self.assertRaisesRegex(backup.android.FrameError, 'unique names'):
backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)])
def test_obb_only_repairs_existing_game_and_requests_apk_when_not_installed(self):
with patch.object(backup.android, 'read_meta', return_value=None):
with self.assertRaisesRegex(backup.android.FrameError, 'Add its APK'):
backup.inspect(list(map(str, self.obb)))
with patch.object(backup.android, 'read_meta', return_value=META):
plan = backup.inspect(list(map(str, self.obb)))
self.assertIsNone(plan['apk'])
self.assertEqual(plan['checked']['version_code'], 210)
def test_apk_and_both_data_files_install_launch_verify_copy_close_in_order(self):
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
order = []
def added(pkg, checked, files, *args, **kwargs):
order.append('copy-close')
self.assertEqual(pkg, PKG)
self.assertEqual(files, self.obb)
return {'closed': True, 'message': 'copied', 'app': META, 'needs_game_data': False}
with patch.object(backup.quest, '_running', side_effect=[False, False, True]), \
patch.object(backup.android, 'read_meta', return_value=None), \
patch.object(backup.android, 'install', side_effect=lambda *a, **k: order.append('install') or META), \
patch.object(backup.android, 'launch', side_effect=lambda *a: order.append('launch')), \
patch.object(backup.quest, '_wait_ready', side_effect=lambda *a: order.append('ready')), \
patch.object(backup.quest, '_check_android_package', side_effect=lambda *a: order.append('verify')), \
patch.object(backup.quest, 'add_game_data', side_effect=added):
result = backup.install(plan)
self.assertEqual(order, ['install', 'launch', 'ready', 'verify', 'copy-close'])
self.assertIn('2 game-data files', result['message'])
self.assertIn('Ready to open', result['message'])
def test_copy_failure_closes_game_and_keeps_retry_instruction(self):
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
with patch.object(backup.quest, '_running', side_effect=[False, True]), \
patch.object(backup.android, 'read_meta', return_value=None), \
patch.object(backup.android, 'install', return_value=META), \
patch.object(backup.quest, '_wait_ready'), patch.object(backup.quest, '_check_android_package'), \
patch.object(backup.quest, 'add_game_data', side_effect=backup.android.FrameError('transfer failed')), \
patch.object(backup.quest, '_close', return_value=(True, None)) as close:
with self.assertRaisesRegex(backup.android.FrameError, 'installed, but.*Import the same backup again'):
backup.install(plan)
close.assert_called_once_with(PKG)
def test_changed_files_are_rejected_before_frame_access(self):
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
self.obb[0].write_bytes(b'changed contents')
with patch.object(backup.android, 'ssh') as ssh:
with self.assertRaisesRegex(backup.android.FrameError, 'backup changed'):
backup.install(plan)
ssh.assert_not_called()
def test_running_game_and_newer_installed_version_are_not_replaced(self):
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
newer = {**META, 'version_code': plan['checked']['version_code'] + 1}
for running, existing, message in [(True, None, 'Close'), (False, newer, 'newer')]:
with self.subTest(running=running), \
patch.object(backup.quest, '_running', return_value=running), \
patch.object(backup.android, 'read_meta', return_value=existing), \
patch.object(backup.android, 'install') as install:
with self.assertRaisesRegex(backup.android.FrameError, message):
backup.install(plan)
install.assert_not_called()
def test_apk_without_data_installs_without_launching(self):
plan = backup.inspect([str(self.apk)])
with patch.object(backup.quest, '_running', return_value=False), \
patch.object(backup.android, 'read_meta', return_value=None), \
patch.object(backup.android, 'install', return_value=META), \
patch.object(backup.android, 'launch') as launch:
result = backup.install(plan)
launch.assert_not_called()
self.assertFalse(result['needs_game_data'])
class StagingTests(unittest.TestCase):
setUp = BackupTests.setUp
def tearDown(self):
for entry in server._android_backups.values():
server._discard_android_backup(entry)
server._android_backups.clear()
def test_uploaded_batch_is_inspected_together_and_cancel_removes_all_uploads(self):
token = server.android_backup({'action': 'begin'})['token']
for path in [self.apk, *self.obb]:
class Request:
headers = {'X-Filename': path.name, 'X-Mode': 'android-backup',
'X-Android-Backup': token, 'Content-Length': str(path.stat().st_size)}
rfile = io.BytesIO(path.read_bytes())
server.Handler.upload(Request())
read = server.android_backup({'action': 'inspect', 'token': token})
self.assertEqual(len(read['plan']['obb']), 2)
folders = list(server._android_backups[token]['dirs'])
server.android_backup({'action': 'discard', 'token': token})
self.assertTrue(all(not Path(p).exists() for p in folders))
self.assertTrue(self.apk.exists())
def test_headset_change_refuses_import_and_pc_folder_is_left_to_title_importer(self):
class Link:
device = 'a'
def active_device(self):
return {'id': self.device}
link = Link()
with patch.object(server, 'LINK', link):
read = server.android_backup({'action': 'inspect', 'paths': [str(self.apk)]})
link.device = 'b'
with self.assertRaisesRegex(server.Failure, 'headset changed'):
server.android_backup({'action': 'install', 'token': read['token']})
pc = self.root / 'pc'
pc.mkdir()
(pc / 'game.exe').write_bytes(b'program')
self.assertEqual(server.android_backup({'action': 'inspect', 'paths': [str(pc)], 'probe': True}),
{'android': False})
if __name__ == '__main__':
unittest.main()
+14
View File
@@ -0,0 +1,14 @@
"""Exercise the production preference bootstrap across separate web origins."""
import shutil
import subprocess
import unittest
from pathlib import Path
@unittest.skipUnless(shutil.which('node'), 'Node exercises the shared page behavior')
class ClientPreferences(unittest.TestCase):
def test_behavior(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(['node', '--test', str(root / 'tests/client_preferences.cjs')],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+9
View File
@@ -323,6 +323,15 @@ class Pins(Base):
class Registry(Base):
def test_replaced_registry_is_read_even_when_timestamp_and_size_match(self):
d = self.reg.add_device('frame', name='First')
before = self.reg.path.stat()
other = fd.Registry(self.reg.path)
other.update_device(d['id'], name='Other')
os.utime(self.reg.path, ns=(before.st_atime_ns, before.st_mtime_ns))
self.assertEqual(self.reg.path.stat().st_size, before.st_size)
self.assertEqual(self.reg.get(d['id'])['name'], 'Other')
def test_an_address_added_first_wins_on_its_own_network(self):
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
# worked here before. The LAN address has to go ahead of it to be used at home.
+175
View File
@@ -0,0 +1,175 @@
"""iPhone packaging and Foundation contracts; no headset, downloads or Xcode build."""
import sandbox # noqa: F401
import fnmatch
import importlib.util
import io
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class BundleContracts(unittest.TestCase):
@classmethod
def setUpClass(cls):
spec = importlib.util.spec_from_file_location("ios_bundle_contract", ROOT / "ios/scripts/make_frame_bundle.py")
cls.bundle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cls.bundle)
cls.tmp = tempfile.TemporaryDirectory()
cls.root = Path(cls.tmp.name)
with tarfile.open(fileobj=io.BytesIO(cls.bundle.build()), mode="r:gz") as archive:
archive.extractall(cls.root)
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
def test_required_resources_ship_to_both_clients(self):
resources = ["frame/android/library_artwork.js", "frame/openxr-compat/XrApiLayer_FRAME_compat.json",
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"]
config = json.loads((ROOT / "app/package.json").read_text())
for resource in resources:
with self.subTest(resource=resource):
self.assertTrue((self.root / resource).is_file(), resource)
# Match the desktop's real resource filters as well as the phone archive.
packaged = False
for entry in config["build"]["extraResources"]:
source = entry["from"].removeprefix("../")
if Path(resource).is_relative_to(source):
relative = str(Path(resource).relative_to(source))
packaged |= any(fnmatch.fnmatch(relative, pattern) for pattern in entry.get("filter", ["**/*"]))
self.assertTrue(packaged, "desktop resource contract: " + resource)
def test_extracted_runtime_can_render_and_load_xr_layer(self):
result = subprocess.run([sys.executable, "-I", "-B", "-c", """
import sys, zipfile
from unittest.mock import patch
sys.path.insert(0, 'ui')
import frame_android as android
with patch.object(android, 'ssh', return_value='rendered') as ssh:
assert android.shortcut_tool('render', '/synthetic/app') == 'rendered'
assert 'ART_RENDERER = ' in ssh.call_args.kwargs['input']
assert len(ssh.call_args.kwargs['input']) > 1000
with zipfile.ZipFile('synthetic.apk', 'w') as apk:
apk.writestr('lib/arm64-v8a/libopenxr_loader.so', b'synthetic loader')
files = android.xr_compat_files('synthetic.apk')
assert set(files) == set(android.XR_COMPAT_FILES)
assert files['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\\x7fELF')
"""], cwd=self.root, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class NativeContracts(unittest.TestCase):
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("swiftc"), "macOS Swift compiles actual Foundation production sources")
def test_foundation_contracts(self):
with tempfile.TemporaryDirectory() as tmp:
main = Path(tmp) / "main.swift"
main.write_text("import XCTest\nlet suite = XCTestSuite(forTestCaseClass: NativeSessionTests.self)\nsuite.run()\nexit(suite.testRun!.hasSucceeded ? 0 : 1)\n")
executable = Path(tmp) / "native-contracts"
developer = subprocess.check_output(["xcode-select", "-p"], text=True).strip()
frameworks = str(Path(developer) / "Platforms/MacOSX.platform/Developer/Library/Frameworks")
libraries = str(Path(developer) / "Platforms/MacOSX.platform/Developer/usr/lib")
shared = str(Path(developer).parent / "SharedFrameworks")
result = subprocess.run(["swiftc", "-I", libraries, "-L", libraries, "-F", frameworks, "-Xlinker", "-rpath", "-Xlinker", frameworks,
"-Xlinker", "-rpath", "-Xlinker", libraries,
"-Xlinker", "-rpath", "-Xlinker", shared,
"-D", "NATIVE_CONTRACT_STANDALONE",
str(ROOT / "ios/FrameControl/App/NativeSessionState.swift"),
str(ROOT / "ios/FrameControlTests/NativeSessionTests.swift"),
str(main), "-o", str(executable)], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Executed 5 tests", result.stdout + result.stderr)
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("swiftc"), "macOS exercises the production finder with controlled probes")
def test_finder_ignores_late_probes_after_edit_and_stop(self):
harness = r'''import Foundation
actor Probes {
var pending: [String: CheckedContinuation<Bool, Never>] = [:]
func probe(_ host: String) async -> Bool {
await withCheckedContinuation { pending[host] = $0 }
}
func has(_ host: String) -> Bool { pending[host] != nil }
func answer(_ host: String) { pending.removeValue(forKey: host)!.resume(returning: true) }
}
@main struct FinderContracts {
@MainActor static func wait(_ condition: () async -> Bool) async {
for _ in 0..<100 {
if await condition() { return }
try! await Task.sleep(nanoseconds: 5_000_000)
}
fatalError("controlled callback did not run")
}
@MainActor static func main() async {
let probes = Probes()
let finder = FrameFinder(probe: { await probes.probe($0) }, discoveryEnabled: false)
finder.start(fallback: "old.local")
await wait { await probes.has("old.local") }
finder.start(fallback: "100.100.100.2:2222", manual: true)
await wait { await probes.has("100.100.100.2:2222") }
await probes.answer("old.local")
// Let the cancelled task return its deliberately late successful answer.
try! await Task.sleep(nanoseconds: 20_000_000)
precondition(finder.found == nil, "A stale probe replaced the edited target")
await probes.answer("100.100.100.2:2222")
await wait { finder.found != nil }
precondition(finder.found?.host == "100.100.100.2:2222")
finder.start(fallback: "stopped.local", manual: true)
await wait { await probes.has("stopped.local") }
finder.stop()
await probes.answer("stopped.local")
try! await Task.sleep(nanoseconds: 20_000_000)
precondition(finder.found == nil, "A stopped search published a result")
print("Production FrameFinder: stale and cancelled probes ignored; manual target retained")
}
}
'''
with tempfile.TemporaryDirectory() as tmp:
main = Path(tmp) / "FinderContracts.swift"
main.write_text(harness)
executable = Path(tmp) / "finder-contracts"
result = subprocess.run(["swiftc", "-parse-as-library",
str(ROOT / "ios/FrameControl/App/NativeSessionState.swift"),
str(ROOT / "ios/FrameControl/SSH/FrameFinder.swift"),
str(main), "-o", str(executable)], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
@unittest.skipUnless(shutil.which("node"), "Node executes the unmodified production bridge script")
def test_bridge_preferences_and_document_acknowledgement(self):
source = (ROOT / "ios/FrameControl/Web/WebShell.swift").read_text()
bridge = source.split('static let bridge = """', 1)[1].split('"""', 1)[0]
script = r'''const vm = require('node:vm');
const assert = require('node:assert/strict');
const calls = [];
const window = {webkit: {messageHandlers: {frameApp: {postMessage: body => {calls.push(body); return Promise.resolve(true)}}}}};
vm.runInNewContext(process.argv[1], {window, crypto: {randomUUID: () => 'document-42'}});
assert.equal(window.__frameInstallLink({}), false);
window.frameApp.preferences.read();
window.frameApp.preferences.write('mvSeparate', '0');
assert.equal(JSON.stringify(calls), JSON.stringify([
{name: 'preferencesRead', arg: null}, {name: 'preferencesWrite', arg: {key: 'mvSeparate', value: '0'}}
]));
const received = [];
window.frameApp.onInstallLink(req => received.push(req));
assert.equal(calls.at(-1).name, 'installLinkReady');
assert.equal(calls.at(-1).arg, 'document-42');
assert.equal(window.__frameInstallLink({target: 'a'}), true);
assert.equal(received[0].target, 'a');
window.frameApp.onInstallLink(() => {throw new Error('callback failed')});
assert.throws(() => window.__frameInstallLink({}), /callback failed/);
'''
result = subprocess.run(["node", "-e", script, bridge], capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+7 -4
View File
@@ -9,7 +9,8 @@ import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab']
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab',
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
RUN = r'''
const vm = require('vm');
@@ -31,8 +32,9 @@ class PageStartup(unittest.TestCase):
def test_opening_any_page_or_section_at_startup_runs(self):
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
first, second = re.findall(r'<script>(.*?)</script>', page, re.S)[:2]
tables = first[first.index('const PAGES ='):first.index('let page =')]
# The routing tables and the workspace helpers showPage uses, up to showPage itself.
start = first.index('function showPage(')
tables = first[first.index('const PAGES ='):start]
show = first[start:first.index('\n}\n', start) + 3]
# Everything declared after the startup call is still uninitialised when it runs.
call = re.search(r'^showPage\(\);', first, re.M).end()
@@ -41,10 +43,11 @@ class PageStartup(unittest.TestCase):
self.assertIn('link', later) # the name that broke #games
stubs = ''.join(f'function {n}() {{ return {{ classList: {{ toggle() {{}} }}, scrollIntoView() {{}} }}; }}\n'
for n in STUBS if n != '$')
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + show +
node = '{ classList: { toggle() {} }, scrollIntoView() {}, append() {}, prepend() {}, setAttribute() {}, removeAttribute() {} }'
code = (f'const $ = id => id === "nowhere" ? null : {node};\nconst gm = {{src: "owned"}};\n' + stubs + tables + show +
'showPage();\n' + ''.join(f'let {n};\n' for n in later))
hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#control', '#nowhere']
hashes += ['#' + k for k in re.findall(r'(\w+): "', tables)]
hashes += ['#' + k for k in re.findall(r'(\w+): ?"', tables[:tables.index('const workspace')])]
r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}),
capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
+762
View File
@@ -0,0 +1,762 @@
"""Offline production-seam coverage for SERVICES01–04; no headset or provider calls."""
import sandbox # noqa: F401
import contextlib
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import server
import frame_agent as agent
import frame_android as android
class TargetTests(unittest.TestCase):
def setUp(self):
self.active = {'id': 'a', 'name': 'Headset A'}
self.link = mock.Mock()
self.link.active_device.side_effect = lambda: dict(self.active)
self.stack = contextlib.ExitStack()
self.addCleanup(self.stack.close)
self.stack.enter_context(mock.patch.object(server, 'LINK', self.link))
self.stack.enter_context(mock.patch.object(server, 'PRIVATE', False))
self.stack.enter_context(mock.patch.object(server, '_work', [0]))
self.stack.enter_context(mock.patch.object(server, '_backfill', {'running': False, 'last': 0.0}))
self.stack.enter_context(mock.patch.object(server.macview, 'retarget'))
self.stack.enter_context(mock.patch.object(server._input, 'stop'))
self.stack.enter_context(mock.patch.object(server.frame_link, 'devices_action', self.switch))
def switch(self, link, body, setup, busy):
if busy():
raise server.frame_devices.DeviceError('Wait for work')
self.active.update(id='b', name='Headset B')
return {'message': 'switched'}
def test_route_closes_touch_and_never_sends_to_old_process(self):
touch = server.TouchAgent()
proc = mock.Mock()
proc.poll.return_value = None
touch.proc, touch.status = proc, {'state': 'ready'}
before = (server.FRAME, list(server.HOST_OPTS))
try:
with mock.patch.object(server, '_touch', touch), mock.patch.object(touch, 'start'):
server.route('headset-b', [])
result = server.remote_touch({'events': [{'dx': 17, 'dy': -4}]})
self.assertFalse(result['sent'])
self.assertEqual(touch.status['state'], 'off')
proc.stdin.write.assert_not_called()
self.assertTrue(proc.stdin.close.called or proc.terminate.called)
finally:
server.route(*before)
def test_route_invalidates_touch_start_already_inside_popen(self):
touch = server.TouchAgent()
touch.launching = touch.generation
proc = mock.Mock()
proc.poll.return_value = None
entered, release = threading.Event(), threading.Event()
before = (server.FRAME, list(server.HOST_OPTS))
def popen(*a, **kw):
entered.set()
if not release.wait(2):
raise AssertionError('test did not release launch')
return proc
with mock.patch.object(server, '_touch', touch), \
mock.patch.object(server, 'ensure_master'), \
mock.patch.object(server.subprocess, 'Popen', side_effect=popen), \
mock.patch.object(touch, '_watch', return_value=(False, True)):
thread = threading.Thread(target=touch._launch, args=(0,))
thread.start()
try:
self.assertTrue(entered.wait(2))
server.route('headset-b', [])
release.set()
thread.join(2)
self.assertFalse(thread.is_alive())
self.assertIsNone(touch.proc)
self.assertEqual(touch.status['state'], 'off')
self.assertTrue(proc.terminate.called or proc.stdin.close.called)
finally:
release.set()
thread.join(2)
server._live_tunnels.discard(proc)
server.route(*before)
def test_backfill_counts_work_until_error_cleanup(self):
entered, release = threading.Event(), threading.Event()
writes = []
def check(*a, **kw):
entered.set()
if not release.wait(2):
raise AssertionError('test did not release backfill')
def refresh(*a, **kw):
writes.append(self.active['id'])
raise RuntimeError('art failure')
original = server.threading.Thread
threads = []
def thread_factory(*a, **kw):
thread = original(*a, **kw)
threads.append(thread)
return thread
with mock.patch.object(server.frame_android, 'shortcut_tool', side_effect=check), \
mock.patch.object(server.frame_android, 'refresh_art', side_effect=refresh), \
mock.patch.object(server.threading, 'Thread', side_effect=thread_factory):
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}])
try:
self.assertTrue(entered.wait(2))
self.assertGreater(server._work[0], 0)
with self.assertRaises(server.Failure):
server.devices_post({'action': 'use', 'id': 'b'})
finally:
release.set()
for thread in threads:
thread.join(2)
self.assertEqual(writes, ['a'])
self.assertEqual(server._work[0], 0)
self.assertFalse(server._backfill['running'])
server.devices_post({'action': 'use', 'id': 'b'})
def test_listing_cannot_switch_before_backfill_registration(self):
handler = server.Handler.__new__(server.Handler)
handler.path = '/api/android'
handler.local_request = lambda: True
handler.send_json = mock.Mock()
handler.send_error_json = mock.Mock()
attempts = []
def listing():
try:
server.devices_post({'action': 'use', 'id': 'b'})
except server.Failure:
attempts.append('refused')
return []
with mock.patch.object(server, 'ensure_master'), \
mock.patch.object(android, 'list_apps', side_effect=listing), \
mock.patch.object(server, 'backfill_art') as backfill, \
mock.patch.object(server.apk_search, 'artwork_for', return_value={}):
handler.do_GET()
self.assertEqual(attempts, ['refused'])
backfill.assert_called_once()
self.assertEqual(server._work[0], 0)
def test_failed_worker_start_releases_count_and_throttle(self):
with mock.patch.object(server.threading.Thread, 'start', side_effect=RuntimeError('cannot start')):
with self.assertRaisesRegex(RuntimeError, 'cannot start'):
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}])
self.assertEqual(server._work[0], 0)
self.assertFalse(server._backfill['running'])
self.assertEqual(server._backfill['last'], 0)
def test_backfill_refuses_identifiers_from_an_old_listing_target(self):
self.active.update(id='b', name='Headset B')
with mock.patch.object(android, 'refresh_art') as refresh:
with self.assertRaises(server.Failure):
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}], meant='a')
refresh.assert_not_called()
self.assertEqual(server._work[0], 0)
self.assertFalse(server._backfill['running'])
def test_failed_thread_constructor_releases_count_and_throttle(self):
with mock.patch.object(server.threading, 'Thread', side_effect=RuntimeError('thread allocation')):
with self.assertRaisesRegex(RuntimeError, 'thread allocation'):
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}])
self.assertEqual(server._work[0], 0)
self.assertEqual(server._backfill, {'running': False, 'last': 0})
def test_approval_same_device_runs_once_and_releases_work_on_error(self):
with mock.patch.object(agent, 'approvals', agent.Approvals()), \
mock.patch.object(server, 'launch', side_effect=RuntimeError('launch failed')) as launch:
body = {'name': 'launch', 'arguments': {'appid': '620'}}
approval = agent.call(server, body)
agent.approvals.decide(approval['confirmation'], True)
with self.assertRaisesRegex(RuntimeError, 'launch failed'):
agent.call(server, dict(body, confirmation=approval['confirmation']))
with self.assertRaises(ValueError):
agent.call(server, dict(body, confirmation=approval['confirmation']))
launch.assert_called_once()
self.assertEqual(server._work[0], 0)
def test_approval_records_target_and_rejects_switch(self):
with mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'launch') as launch:
body = {'name': 'launch', 'arguments': {'appid': '620'}}
approval = agent.call(server, body)
self.assertEqual(approval['action']['target'], self.active)
self.assertEqual(agent.approvals.inspect(approval['confirmation'])['action']['target'], self.active)
agent.approvals.decide(approval['confirmation'], True)
server.devices_post({'action': 'use', 'id': 'b'})
with self.assertRaises(ValueError):
agent.call(server, dict(body, confirmation=approval['confirmation']))
launch.assert_not_called()
self.assertEqual(server._work[0], 0)
def test_approval_dispatch_holds_existing_work_guard(self):
attempts = []
def launch(args):
try:
server.devices_post({'action': 'use', 'id': 'b'})
except server.Failure:
attempts.append('refused')
return {'message': 'launched'}
with mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'launch', side_effect=launch):
body = {'name': 'launch', 'arguments': {'appid': '620'}}
approval = agent.call(server, body)
agent.approvals.decide(approval['confirmation'], True)
result = agent.call(server, dict(body, confirmation=approval['confirmation']))
self.assertEqual(result['message'], 'launched')
self.assertEqual(attempts, ['refused'])
self.assertEqual(self.active['id'], 'a')
self.assertEqual(server._work[0], 0)
class FinalLaunchTests(unittest.TestCase):
"""Production delivery/cleanup with separate temporary A/B remote homes."""
def setUp(self):
self.stack = contextlib.ExitStack()
self.addCleanup(self.stack.close)
self.root = Path(self.stack.enter_context(tempfile.TemporaryDirectory()))
self.before = (server.FRAME, list(server.HOST_OPTS))
self.stack.enter_context(mock.patch.object(server, 'LOCAL', False))
self.stack.enter_context(mock.patch.object(server, 'LINK', None))
self.stack.enter_context(mock.patch.object(server, '_work', [0]))
self.stack.enter_context(mock.patch.object(server.macview, 'retarget'))
self.stack.enter_context(mock.patch.object(server, '_touch'))
self.stack.enter_context(mock.patch.object(server, 'ensure_master'))
local = self.root / 'local'
(local / 'packages').mkdir(parents=True)
packages = []
for n in (1, 2):
name, data = 'test%d.pkg.tar.zst' % n, ('offline package %d' % n).encode()
(local / 'packages' / name).write_bytes(data)
packages.append((name, hashlib.sha256(data).hexdigest()))
self.stack.enter_context(mock.patch.object(server, 'KDECONNECT', local))
self.agent = server.InputAgent(packages=packages)
self.stack.enter_context(mock.patch.object(server, '_input', self.agent))
self.stack.callback(lambda: server.route(*self.before))
for alias in ('target-a', 'target-b'):
(self.root / alias).mkdir()
server.route('target-a', ['-o', 'HostName=192.0.2.1'])
self.original_route = tuple([*server.SSH, server.FRAME])
self.calls = []
self.real_popen = subprocess.Popen
def remote(self, argv, **kw):
target, command = argv[-2:]
self.assertIn(target, ('target-a', 'target-b'))
feed = kw.get('input')
if isinstance(feed, str):
feed = feed.encode()
home = self.root / target
proc = self.real_popen(['sh', '-c', command], cwd=home,
env={**os.environ, 'HOME': str(home)},
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = proc.communicate(feed, timeout=3)
self.calls.append({'route': tuple(argv[:-1]), 'command': command, 'input': feed})
return subprocess.CompletedProcess(argv, proc.returncode,
out.decode() if kw.get('text') else out,
err.decode() if kw.get('text') else err)
def delivery_boundary(self, boundary, cancel=False):
entered, release = threading.Event(), threading.Event()
def remote(argv, **kw):
result = self.remote(argv, **kw)
command = argv[-1]
hit = ((boundary == 'stamp' and 'cmp -s' in command)
or (boundary == 'mkdir' and command.startswith('mkdir -p'))
or (boundary == 'copy' and command.startswith('cd ')))
if hit and not entered.is_set():
entered.set()
if not release.wait(3):
raise AssertionError('delivery barrier not released')
return result
self.agent.launching = self.agent.generation
with mock.patch.object(server.frame_host, 'run_ssh', side_effect=remote), \
mock.patch.object(server.subprocess, 'Popen') as launch:
worker = threading.Thread(target=self.agent._launch, args=(self.agent.generation,))
worker.start()
try:
self.assertTrue(entered.wait(3))
if cancel:
self.agent.stop()
else:
server.route('target-b', ['-o', 'HostName=192.0.2.2'])
release.set()
worker.join(3)
self.assertFalse(worker.is_alive())
launch.assert_not_called()
finally:
release.set()
worker.join(3)
self.assertTrue(all(c['route'] == self.original_route for c in self.calls), self.calls)
copies = [c for c in self.calls if c['command'].startswith('cd ')]
self.assertEqual(len(copies), 1 if boundary == 'copy' else 0)
for alias in ('target-a', 'target-b'):
self.assertFalse(list((self.root / alias).rglob('*.part')))
self.assertFalse(list((self.root / alias).glob('**/incoming/*')))
self.assertIsNone(self.agent.proc)
self.assertEqual(self.agent.status['state'], 'off')
self.assertEqual(server._work[0], 0)
def test_switch_after_stamp_cannot_deliver_to_b(self):
self.delivery_boundary('stamp')
def test_switch_after_mkdir_cleans_only_a(self):
self.delivery_boundary('mkdir')
def test_switch_after_first_copy_stops_next_copy_and_cleans_a(self):
self.delivery_boundary('copy')
def test_cancel_after_first_copy_stops_next_copy_and_cleans_a(self):
self.delivery_boundary('copy', cancel=True)
def test_deferred_no_output_cleanup_keeps_the_original_route(self):
watching, release = threading.Event(), threading.Event()
proc = mock.Mock()
proc.poll.return_value = None
proc.wait.return_value = 0
def stdout():
watching.set()
if not release.wait(3):
raise AssertionError('watch barrier not released')
yield from ()
proc.stdout = stdout()
with mock.patch.object(server.frame_host, 'run_ssh', side_effect=self.remote), \
mock.patch.object(server.subprocess, 'Popen', return_value=proc):
self.agent.start()
try:
self.assertTrue(watching.wait(3))
self.assertEqual(server._work[0], 0, 'watch lifetime must not block device switching')
server.route('target-b', ['-o', 'HostName=192.0.2.2'])
release.set()
# The real watch completes and discard runs after route publication.
for _ in range(150):
if any(c['command'].startswith('rm -rf') for c in self.calls):
break
threading.Event().wait(.01)
self.assertTrue(any(c['command'].startswith('rm -rf') for c in self.calls))
finally:
release.set()
self.assertTrue(all(c['route'] == self.original_route for c in self.calls), self.calls)
self.assertFalse(list((self.root / 'target-a').glob('**/incoming/*')))
self.assertIsNone(self.agent.proc)
self.assertEqual(self.agent.status['state'], 'off')
def test_package_retry_keeps_the_original_ssh_route(self):
first, second = mock.Mock(), mock.Mock()
first.stdout = [b'{"state":"need-packages"}\n']
second.stdout = [b'{"state":"ready"}\n']
first.poll.return_value = second.poll.return_value = 0
first.wait.side_effect = lambda: server.route('target-a', ['-o', 'HostName=192.0.2.2'])
second.wait.return_value = 0
with mock.patch.object(server.frame_host, 'run_ssh', side_effect=self.remote), \
mock.patch.object(server.subprocess, 'Popen', side_effect=[first, second]) as popen:
self.agent.launching = self.agent.generation
self.agent._launch(self.agent.generation)
self.assertEqual(popen.call_count, 2)
self.assertTrue(all(tuple(call.args[0][:-1]) == self.original_route for call in popen.call_args_list))
self.assertTrue(all(c['route'] == self.original_route for c in self.calls), self.calls)
self.assertEqual(len([c for c in self.calls if c['command'].startswith('cd ')]), 4)
class FinalGetStartTests(unittest.TestCase):
setUp = TargetTests.setUp
switch = TargetTests.switch
def handler(self, path, meant):
handler = server.Handler.__new__(server.Handler)
handler.command, handler.path = 'GET', path
handler.headers = {'Host': '127.0.0.1:8123', 'X-Frame-UI': server.UI_KEY}
if meant is not None:
handler.headers['X-Frame-Device'] = meant
handler.send_json, handler.send_error_json = mock.Mock(), mock.Mock()
return handler
def test_stale_authenticated_get_start_is_rejected_before_start(self):
self.active.update(id='b', name='Headset B')
for name in ('input', 'touch'):
with self.subTest(name=name):
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
handler = self.handler('/api/%s?start=1' % name, 'a')
with mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
handler.do_GET()
start.assert_not_called()
self.assertEqual(handler.send_error_json.call_args.args[1], 409)
self.assertEqual(server._work[0], 0)
def test_desktop_get_start_requires_an_intended_device(self):
for name in ('input', 'touch'):
with self.subTest(name=name):
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
handler = self.handler('/api/%s?start=1' % name, None)
with mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
handler.do_GET()
start.assert_not_called()
self.assertEqual(handler.send_error_json.call_args.args[1], 400)
self.assertEqual(server._work[0], 0)
def test_get_start_validation_and_thread_registration_are_one_reservation(self):
attempts = []
fresh = server.TouchAgent()
def register():
try:
server.devices_post({'action': 'use', 'id': 'b'})
except server.Failure:
attempts.append('refused')
with mock.patch.object(server, '_touch', fresh), mock.patch.object(fresh, 'start', side_effect=register):
handler = self.handler('/api/touch?start=1', 'a')
handler.do_GET()
self.assertEqual(attempts, ['refused'])
self.assertEqual(self.active['id'], 'a')
self.assertEqual(server._work[0], 0)
def test_delayed_launch_uses_the_synchronously_captured_route(self):
pending = []
class QueuedThread:
def __init__(self, target, args=(), kwargs=None, **ignored):
self.target, self.args, self.kwargs = target, args, kwargs or {}
def start(self):
pending.append(self)
before = (server.FRAME, list(server.HOST_OPTS))
try:
for switched in (False, True):
with self.subTest(switched=switched):
fresh = server.TouchAgent()
proc = mock.Mock()
proc.poll.return_value = None
with mock.patch.object(server, '_touch', fresh), \
mock.patch.object(server.threading, 'Thread', QueuedThread), \
mock.patch.object(server, 'ensure_master'), \
mock.patch.object(fresh, '_watch', return_value=(False, True)), \
mock.patch.object(server.subprocess, 'Popen', return_value=proc) as popen:
server.route('target-a', ['-o', 'HostName=192.0.2.1'])
original = tuple([*server.SSH, server.FRAME])
handler = self.handler('/api/touch?start=1', 'a')
handler.do_GET()
self.assertEqual(len(pending), 1)
# Also change options without a generation change: this
# proves the route was captured before the thread runs.
server.route('target-b' if switched else 'target-a', ['-o', 'HostName=192.0.2.2'])
thread = pending.pop()
thread.target(*thread.args, **thread.kwargs)
if switched:
popen.assert_not_called()
self.assertIsNone(fresh.proc)
else:
self.assertEqual(tuple(popen.call_args.args[0][:-1]), original)
fresh.stop()
server._live_tunnels.discard(proc)
finally:
server.route(*before)
def test_same_target_start_status_and_local_ios_remain_usable(self):
for link, meant, query in ((self.link, 'a', '?start=1'), (self.link, 'stale', ''),
(None, None, '?start=1')):
for name in ('input', 'touch'):
with self.subTest(name=name, query=query, local=link is None):
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
handler = self.handler('/api/' + name + query, meant)
with mock.patch.object(server, 'LINK', link), mock.patch.object(server, 'LOCAL', link is None), \
mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
handler.do_GET()
self.assertEqual(start.call_count, 1 if query else 0)
handler.send_error_json.assert_not_called()
self.assertEqual(server._work[0], 0)
class UpdateTests(unittest.TestCase):
"""Execute generated remote commands in a temporary home; only Steam is synthetic."""
def exercise(self, failure, repair_failure=None):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
d = root / 'Applications/Android/org.test.app'
d.mkdir(parents=True)
old = {'package': 'org.test.app', 'version': '1', 'label': 'Old', 'instance': 2800000001,
'shortcut': 42, 'flatscreen': False, 'artwork': {}}
files = {'app.apk': b'old APK', 'launch.sh': b'old launcher', 'instance.id': b'2800000001',
'meta.json': json.dumps(old).encode(), 'shortcut.id': b'42', 'user-save': b'precious data'}
for name, data in files.items():
(d / name).write_bytes(data)
(d / 'launch.sh').chmod(0o755)
save = root / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
save.parent.mkdir(parents=True)
save.write_bytes(b'Android saved data')
apk = root / 'new.apk'
apk.write_bytes(b'new APK')
info = {'package': 'org.test.app', 'version': '2', 'label': 'New', 'vr': False}
writes = []
repairing = [False]
repair_failure_pending = [False]
cleanup_attempts = []
def ssh(cmd, input=None, **kw):
repair_stage = None
if repairing[0] and repair_failure_pending[0]:
if '/meta.json.tmp' in cmd and cmd.startswith('cat >'):
repair_stage = 'meta'
elif '/update-recovery.json.tmp' in cmd and cmd.startswith('cat >'):
repair_stage = 'checkpoint'
elif cmd.startswith('rm -rf ') and '.frame-update-backup' in cmd:
repair_stage = 'cleanup'
if repair_failure == 'cleanup-partial' and repair_stage == 'cleanup':
(d / '.frame-update-backup/ready').unlink()
repair_failure_pending[0] = False
raise android.FrameError('partial repair cleanup')
if repair_failure == repair_stage:
repair_failure_pending[0] = False
raise android.FrameError('transient repair ' + repair_stage)
if failure == 'cleanup' and cmd == 'rm -rf Applications/Android/org.test.app/.frame-update-backup' and not cleanup_attempts:
cleanup_attempts.append(cmd)
raise android.FrameError('backup cleanup unavailable')
if repairing[0] and cmd == 'python3 -':
return json.dumps({'icon_png': '', 'artwork': {}})
if failure == 'rollback' and 'frame-update-backup' in cmd and ('cp -' in cmd or 'mv ' in cmd) and writes and not repairing[0]:
raise android.FrameError('restore connection lost')
if failure == 'meta' and '/meta.json.tmp' in cmd and not writes:
writes.append('meta failed')
raise android.FrameError('metadata unavailable')
if isinstance(input, str):
input = input.encode()
p = subprocess.run(['sh', '-c', cmd], input=input, cwd=root,
env={**os.environ, 'HOME': str(root)}, capture_output=True)
if p.returncode:
raise android.FrameError(p.stderr.decode())
if repair_stage and repair_failure == repair_stage + '-after':
repair_failure_pending[0] = False
raise android.FrameError('lost repair ' + repair_stage + ' response')
if failure == 'meta-after' and '/meta.json.tmp' in cmd and not writes:
writes.append('meta response lost')
raise android.FrameError('metadata response lost')
return p.stdout.decode()
def copy(src, dest, **kw):
target = root / dest
shutil.copyfile(src, target)
if kw.get('executable'):
target.chmod(0o755)
def shortcut_tool(*args, **kw):
if args[0] == 'add':
return '43'
if args[0] == 'remove':
if not repairing[0]:
raise android.FrameError('shortcut cleanup unavailable')
return '{}'
raise AssertionError(args)
def library(*a, **kw):
if repairing[0]:
self.assertEqual(a[1], 'Old')
self.assertTrue(kw['vr'])
self.assertEqual(kw['fill_only'], not (d / 'update-recovery.json').exists())
(root / 'steam-state').write_text('old library settings')
if repair_failure_pending[0] and repair_failure == 'library':
repair_failure_pending[0] = False
raise android.FrameError('transient repair library failure')
return {'artwork': {}, 'warnings': []}
# A partial library mutation is externally observable even when configure fails.
(root / 'steam-state').write_text('new library settings')
if failure in ('library', 'rollback', 'orphan'):
writes.append('library failed')
raise android.FrameError('Steam unavailable after partial configure')
return {'artwork': {}, 'warnings': []}
with mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
mock.patch.object(android, 'ssh', side_effect=ssh), \
mock.patch.object(android, '_copy', side_effect=copy), \
mock.patch.object(android, '_shortcut_ids', return_value=set() if failure == 'orphan' else {42}), \
mock.patch.object(android, 'shortcut_tool', side_effect=shortcut_tool), \
mock.patch.object(android, 'apply_library', side_effect=library):
with self.assertRaises(android.FrameError) as error:
android._install(str(apk), info, info['package'], True, None, None)
if failure != 'rollback':
for name, data in files.items():
self.assertEqual((d / name).read_bytes(), data, name)
self.assertFalse((d / 'lepton-show-flatscreen').exists())
if os.name != 'nt': # Windows has no execute bit to restore
self.assertTrue((d / 'launch.sh').stat().st_mode & 0o100)
self.assertFalse((d / 'app.apk.part').exists())
self.assertFalse((d / 'meta.json.tmp').exists())
self.assertEqual((d / 'user-save').read_bytes(), b'precious data')
self.assertEqual(save.read_bytes(), b'Android saved data')
# Steam's API cannot guarantee a full rollback. Never hide that residual state.
self.assertIn('repair', str(error.exception).lower())
record = json.loads((d / 'update-recovery.json').read_text())
self.assertIn('error', record)
self.assertIn('repair', record)
if failure == 'rollback':
self.assertIn('restore connection lost', str(error.exception))
self.assertTrue((d / '.frame-update-backup').is_dir())
if failure == 'orphan':
self.assertEqual(record['orphan_shortcut'], 43)
self.assertIn('shortcut cleanup unavailable', str(error.exception))
snapshot = {str(p.relative_to(d)): p.read_bytes() for p in d.rglob('*') if p.is_file()}
with mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
mock.patch.object(android, 'ssh', side_effect=ssh), \
mock.patch.object(android, '_copy', side_effect=AssertionError('pending recovery must refuse before copying')):
with self.assertRaises(android.FrameError):
android._install(str(apk), info, info['package'], True, None, None)
self.assertEqual({str(p.relative_to(d)): p.read_bytes() for p in d.rglob('*') if p.is_file()}, snapshot,
'Retry must preserve all prior recovery evidence byte for byte')
repairing[0] = True
with mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
mock.patch.object(android, 'ssh', side_effect=ssh), \
mock.patch.object(android, '_shortcut_ids', return_value={42}), \
mock.patch.object(android, 'shortcut_tool', side_effect=shortcut_tool) as api, \
mock.patch.object(android, 'apply_library', side_effect=library):
if repair_failure:
repair_failure_pending[0] = True
with self.assertRaises(android.FrameError):
android.refresh_art(info['package'], fill_only=True)
# A later failure must retain either the ready snapshot or a
# durable terminal checkpoint, never an unconfirmed dead end.
if (d / 'update-recovery.json').exists():
retry_record = json.loads((d / 'update-recovery.json').read_text())
for key in ('error', 'rollback_error', 'orphan_shortcut', 'cleanup_error'):
self.assertEqual(retry_record[key], record[key], key)
self.assertTrue((d / '.frame-update-backup/ready').exists() or
retry_record.get('repair_completed') is True)
else:
self.assertFalse((d / '.frame-update-backup').exists())
self.assertEqual((d / 'app.apk').read_bytes(), files['app.apk'])
self.assertEqual((d / 'launch.sh').read_bytes(), files['launch.sh'])
self.assertFalse((d / 'lepton-show-flatscreen').exists())
coherent = json.loads((d / 'meta.json').read_text())
self.assertEqual(coherent['version'], '1')
self.assertFalse(coherent['flatscreen'])
repaired = android.refresh_art(info['package'], fill_only=True)
if failure == 'orphan':
api.assert_called_once_with('remove', '43')
self.assertEqual(repaired['version'], '1')
self.assertFalse(repaired['flatscreen'])
self.assertEqual((root / 'steam-state').read_text(), 'old library settings')
for name in ('app.apk', 'launch.sh', 'instance.id', 'shortcut.id', 'user-save'):
self.assertEqual((d / name).read_bytes(), files[name], name)
self.assertFalse((d / 'update-recovery.json').exists())
self.assertFalse((d / '.frame-update-backup').exists())
self.assertFalse((d / 'lepton-show-flatscreen').exists())
self.assertEqual(save.read_bytes(), b'Android saved data')
return record
def test_late_library_failure_restores_executable_metadata_and_preserves_data(self):
self.exercise('library')
def test_meta_write_failure_restores_executable_metadata_and_preserves_data(self):
self.exercise('meta')
def test_missing_snapshot_cannot_claim_an_unconfirmed_rollback_is_repaired(self):
def ssh(cmd, **kw):
if cmd.startswith('test -f'):
return 'yes'
if cmd.startswith('cat ') and cmd.endswith('update-recovery.json'):
return json.dumps({'files_restored': False, 'library_may_have_changed': True})
if cmd == 'python3 -':
return json.dumps({'icon_png': '', 'artwork': {}})
return ''
meta = {'package': 'org.test.app', 'label': 'Old', 'shortcut': 42, 'flatscreen': False}
with mock.patch.object(android, 'ssh', side_effect=ssh), \
mock.patch.object(android, '_meta_or_fail', return_value=meta), \
mock.patch.object(android, '_shortcut_ids', return_value={42}), \
mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
mock.patch.object(android, '_write_meta'), \
mock.patch.object(android, 'apply_library', return_value={}) as library:
with self.assertRaisesRegex(android.FrameError, 'unconfirmed'):
android.refresh_art('org.test.app')
library.assert_not_called()
def test_retry_preserves_absent_snapshot_uncertainty_and_repair_still_refuses(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
d = root / 'Applications/Android/org.test.app'
d.mkdir(parents=True)
record = {'files_restored': False, 'files_unchanged': False, 'error': 'original lost rollback',
'library_may_have_changed': True, 'orphan_shortcut': 43}
(d / 'update-recovery.json').write_text(json.dumps(record))
evidence = (d / 'update-recovery.json').read_bytes()
def ssh(cmd, input=None, **kw):
result = subprocess.run(['sh', '-c', cmd], cwd=root, capture_output=True)
if result.returncode:
raise android.FrameError(result.stderr.decode())
return result.stdout.decode()
with mock.patch.object(android, 'ssh', side_effect=ssh), \
mock.patch.object(android.frame_artwork, 'prepare', side_effect=AssertionError('must refuse before artwork')), \
mock.patch.object(android, '_copy', side_effect=AssertionError('must refuse before copy')):
with self.assertRaisesRegex(android.FrameError, 'Pending'):
android._install('unused.apk', {'label': 'New'}, 'org.test.app', True, None, None)
self.assertEqual((d / 'update-recovery.json').read_bytes(), evidence)
with self.assertRaisesRegex(android.FrameError, 'unconfirmed'):
android.refresh_art('org.test.app')
self.assertEqual((d / 'update-recovery.json').read_bytes(), evidence)
def test_recovery_appearing_after_preflight_is_not_owned_by_the_new_attempt(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
d = root / 'Applications/Android/org.test.app'
d.mkdir(parents=True)
meta = {'package': 'org.test.app', 'instance': 2800000001, 'shortcut': 42, 'label': 'Old'}
(d / 'meta.json').write_text(json.dumps(meta))
(d / 'app.apk').write_bytes(b'old APK')
record = b'{"orphan_shortcut":43,"files_restored":false,"error":"prior failure"}'
def prepare(*args):
(d / 'update-recovery.json').write_bytes(record)
(d / 'app.apk.part').write_bytes(b'prior partial evidence')
return {}, []
def ssh(cmd, input=None, **kw):
if isinstance(input, str):
input = input.encode()
result = subprocess.run(['sh', '-c', cmd], input=input, cwd=root, capture_output=True)
if result.returncode:
raise android.FrameError(result.stderr.decode())
return result.stdout.decode()
with mock.patch.object(android, 'ssh', side_effect=ssh), \
mock.patch.object(android.frame_artwork, 'prepare', side_effect=prepare), \
mock.patch.object(android, '_copy', side_effect=AssertionError('pending recovery must refuse before copy')):
with self.assertRaisesRegex(android.FrameError, 'Pending'):
android._install('unused.apk', {'label': 'New'}, 'org.test.app', True, None, None)
self.assertEqual((d / 'update-recovery.json').read_bytes(), record)
self.assertEqual((d / 'app.apk.part').read_bytes(), b'prior partial evidence')
self.assertEqual((d / 'app.apk').read_bytes(), b'old APK')
self.assertFalse((d / '.frame-update-backup').exists())
def test_snapshot_without_record_cannot_be_repaired_or_overwritten(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
d = root / 'Applications/Android/org.test.app'
backup = d / '.frame-update-backup'
backup.mkdir(parents=True)
(backup / 'ready').touch()
(backup / 'app.apk').write_bytes(b'old APK evidence')
def ssh(cmd, input=None, **kw):
result = subprocess.run(['sh', '-c', cmd], cwd=root, capture_output=True)
if result.returncode:
raise android.FrameError(result.stderr.decode())
return result.stdout.decode()
with mock.patch.object(android, 'ssh', side_effect=ssh), \
mock.patch.object(android.frame_artwork, 'prepare', side_effect=AssertionError('must refuse before artwork')):
with self.assertRaisesRegex(android.FrameError, 'Pending'):
android._install('unused.apk', {'label': 'New'}, 'org.test.app', True, None, None)
with self.assertRaisesRegex(android.FrameError, 'unconfirmed'):
android.refresh_art('org.test.app')
self.assertFalse((d / 'update-recovery.json').exists())
self.assertEqual((backup / 'app.apk').read_bytes(), b'old APK evidence')
def test_meta_write_response_loss_restores_old_metadata_too(self):
self.exercise('meta-after')
def test_new_shortcut_cleanup_failure_is_recorded_and_repaired(self):
self.exercise('orphan')
def test_backup_cleanup_failure_rolls_back_and_has_a_repair_path(self):
self.exercise('cleanup')
def test_restore_failure_reports_and_keeps_recovery_evidence(self):
self.exercise('rollback')
def test_repair_retains_recovery_until_all_stages_and_cleanup_succeed(self):
for stage in ('library', 'meta', 'meta-after', 'checkpoint',
'checkpoint-after', 'cleanup', 'cleanup-partial', 'cleanup-after'):
with self.subTest(stage=stage):
self.exercise('rollback', repair_failure=stage)
if __name__ == '__main__':
unittest.main()
+16
View File
@@ -0,0 +1,16 @@
"""Standalone page regressions with production JavaScript and no live services."""
import shutil
import subprocess
import unittest
from pathlib import Path
@unittest.skipUnless(shutil.which('node'), 'Node exercises the standalone page scripts')
class StandaloneReview(unittest.TestCase):
def test_behavior(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['node', '--test', str(root / 'tests/standalone_review.cjs')],
capture_output=True, text=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+13
View File
@@ -0,0 +1,13 @@
"""Production client race/error regressions, without any headset or browser writes."""
import shutil
import subprocess
import unittest
from pathlib import Path
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page functions')
class UIReview(unittest.TestCase):
def test_behavior(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(['node', '--test', str(root / 'tests/ui_review.cjs')], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+14
View File
@@ -0,0 +1,14 @@
"""Production navigation and app actions, without a browser or headset."""
import shutil
import subprocess
import unittest
from pathlib import Path
@unittest.skipUnless(shutil.which('node'), 'Node exercises shared page behavior')
class Workspace(unittest.TestCase):
def test_behavior(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(['node', '--test', str(root / 'tests/ui_workspace.cjs')],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+1 -1
View File
@@ -394,7 +394,7 @@ class ServerJobs(unittest.TestCase):
places = s._tmp_places()
self.assertEqual({p for _, p in places}, {s.WEB_TMP_PREFIX, s.frame_titles.TMP_PREFIX,
s.frame_android.TMP_PREFIX, s.frame_agent.TMP_PREFIX,
s.apk_fdroid.TMP_PREFIX})
s.apk_fdroid.TMP_PREFIX, s.ANDROID_BACKUP_TMP})
for folder, prefix in places:
folder.mkdir(parents=True, exist_ok=True)
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-", dir=folder)
+116
View File
@@ -0,0 +1,116 @@
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs'), vm = require('node:vm');
const {workspaceFixture, html} = require('./workspace_fixture.cjs');
const store = fs.readFileSync(require.resolve('../ui/store.html'),'utf8');
const pick = (s,a,b) => s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)));
const tick = async () => { for (let i=0;i<16;i++) await Promise.resolve(); };
function connectionFixture() {
const f=workspaceFixture(), c=f.context;
Object.assign(c,{URLSearchParams,Date,clearTimeout,link:{s:{phase:'connected'},device:'a',phase:'connected',rev:0},disp:{list:[],seq:0},cat:{apps:null},wi:{},devGen:1,androidSeq:0,androidPending:new Set(),titlesSeq:0,volTimer:null,refreshing:null,lastImg:null,lastShot:null,
sourceState:{jobs:new Map(),detail:null,detailRequest:0},touches:new Map(),gesture:null,padType:{blur(){},value:''},
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
c.window.connDevice='a';
f.run('const UI_KEY="test";'+pick(html,'const HEADSET_FREE =','// What a panel shows'));
f.run(pick(html,'function onConnection(s) {','// Read the event stream'));
return f;
}
test('helper starts and status reads name the headset shown when requested',async()=>{
const f=connectionFixture(),c=f.context,requests=[];
c.fetch=async(path,opts)=>{requests.push({path,opts});return {ok:true,json:async()=>({state:'ready'})};};
await f.run("api('/api/input?start=1')");
await f.run("api('/api/touch?start=1')");
c.window.connDevice='b';
await f.run("api('/api/input')");
assert.deepEqual(requests.map(r=>r.opts.headers['X-Frame-Device']),['a','a','b']);
assert.ok(requests.every(r=>r.opts.method===undefined&&r.opts.body===undefined));
delete c.window.connDevice; // A local iOS server has one fixed SSH route.
await f.run("api('/api/touch?start=1')");
assert.equal(requests[3].opts.headers['X-Frame-Device'],undefined);
});
test('a late input failure cannot replay A keystrokes on B',async()=>{
const f=connectionFixture(),c=f.context,writes=[];let rejectA;
Object.assign(c,{pad:{state:'ready',queue:[],sending:false},fetch:(path,opts)=>{writes.push({device:opts.headers['X-Frame-Device'],events:JSON.parse(opts.body).events});return writes.length===1?new Promise((_,reject)=>rejectA=reject):Promise.resolve({ok:true,json:async()=>({state:'ready',sent:true})});}});
f.run(pick(html,'const isMove = e =>','function padKey('));f.run('padSend({key:"for A"})');
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
assert.equal(c.pad.state,'off');assert.equal(c.pad.queue.length,0);
rejectA(Error('lost A'));await tick();c.pad.state='ready';f.run('padSend({key:"for B"})');await tick();
assert.equal(writes.length,2);assert.equal(writes[1].device,'b');assert.equal(writes[1].events[0].key,'for B');
});
test('a device switch discards completed install results and the open detail',()=>{
const f=connectionFixture(),c=f.context;
Object.assign(c,{pad:{state:'off',queue:[]},padReset(){},sourceState:{jobs:new Map([['test:one',{done:true,result:{package:'app.on.a',game_data:true}}]]),detail:{app:{}},detailRequest:0}});
f.el('appDetail').open=true;
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
assert.equal(c.sourceState.jobs.size,0);assert.equal(c.sourceState.detail,null);assert.equal(f.el('appDetail').open,false);
});
test('only a preview matching current diagnostics consent can be sent or copied',async()=>{
const els=new Map(),$=id=>{if(!els.has(id))els.set(id,{checked:false,value:'',textContent:'',children:[]});return els.get(id);};let pending=[],sent;
const c={$ ,api:(path,body)=>path==='/api/report/preview'?new Promise(resolve=>pending.push({body,resolve})):Promise.resolve({}),log(){},renderContact(){}};vm.createContext(c);
vm.runInContext(pick(html,'const bug = {','function openBugReport()'),c);
vm.runInContext(pick(html,'$("bugForm").onsubmit =','if (window.frameApp && window.frameApp.onReportProblem)'),c);
$('bugForm').reportValidity=()=>true;$('bugDiag').checked=true;$('bugLogs').checked=true;
const a=vm.runInContext('loadBugPreview()',c);$('bugLogs').checked=false;const b=vm.runInContext('loadBugPreview()',c);
assert.doesNotMatch(vm.runInContext('bugReportText().body',c),/SECRET/);
pending[1].resolve({text:'safe summary'});await b;pending[0].resolve({text:'SECRET SERVER LOG'});await a;
c.api=(path,body)=>{if(path==='/api/report')sent=body;return Promise.resolve({id:'stub',message:'stub'});};
await $('bugForm').onsubmit({preventDefault(){}});assert.equal(sent.diagnostics,'safe summary');
$('bugDiag').checked=false;await vm.runInContext('loadBugPreview()',c);
assert.doesNotMatch(vm.runInContext('bugReportText().body',c),/safe summary|SECRET/);
$('bugDiag').checked=true;c.api=(path,body)=>new Promise(resolve=>pending.push({body,resolve}));
const late=vm.runInContext('loadBugPreview()',c);$('bugDiag').checked=false;await vm.runInContext('loadBugPreview()',c);
pending[2].resolve({text:'LATE SECRET'});await late;
assert.doesNotMatch(vm.runInContext('bugReportText().body',c),/SECRET/);
});
test('clearing Steam search invalidates a pending query and pending country lookup',async()=>{
const el={value:'Portal'},gm={country:'US',src:'store',store:[],storeQ:null,seq:0,shownBySource:{}};let resolve;
const c={$:()=>el,gm,api:()=>new Promise(r=>resolve=r),renderChips(){},filterGames(){},encodeURIComponent};vm.createContext(c);
vm.runInContext(pick(html,'async function searchStore() {','function setGameSource('),c);
const a=vm.runInContext('searchStore()',c);el.value='';await vm.runInContext('searchStore()',c);resolve({results:[{name:'Portal'}]});await a;
assert.equal(gm.storeQ,null);assert.equal(gm.store.length,0);
});
test('failed installed reads remain unknown or stale with retry, never successful empty',async()=>{
const els=new Map(),$=id=>{if(!els.has(id))els.set(id,{textContent:'',innerHTML:'',setAttribute(){}});return els.get(id);};
const st={installed:new Map(),needsData:new Set(),jobs:new Map(),view:'installed',q:'',loading:false,apps:[],page:0,installedState:'unknown',installedError:'',installedRequest:0};
const c={$ ,st,api:async()=>{throw Error('offline');},toast(){},document:{querySelectorAll:()=>[]},PER_PAGE:8,catLabel:x=>x,esc:x=>x};vm.createContext(c);
vm.runInContext(pick(store,'async function loadInstalled() {','async function search('),c);vm.runInContext(pick(store,'function list() {','function card('),c);vm.runInContext(pick(store,'function render(error) {','function turn('),c);
await vm.runInContext('loadInstalled()',c);vm.runInContext('render()',c);
assert.doesNotMatch($('content').innerHTML,/Nothing installed yet/);assert.match($('content').innerHTML,/data-retry-installed/);
st.installed.set('app',{package:'app',label:'Cached App'});st.installedState='ready';
c.card=()=>'<button>Cached App</button>';await vm.runInContext('loadInstalled()',c);vm.runInContext('render()',c);
assert.match($('content').innerHTML,/Cached App/);assert.match($('content').innerHTML,/data-retry-installed/);
c.api=async()=>({apps:[]});await vm.runInContext('loadInstalled()',c);vm.runInContext('render()',c);
assert.match($('content').innerHTML,/Nothing installed yet/);assert.doesNotMatch($('content').innerHTML,/data-retry-installed/);
});
test('capture control discards old releases, timers and readiness on a target switch',async()=>{
const f=connectionFixture(),c=f.context,writes=[];let rejectA;
Object.assign(c,{ctrl:{on:true,queue:[],sending:false,state:'ready',held:new Set(['left']),keys:new Set([30]),pointers:new Map(),g:null,rel:[1,2]},desk:{panels:[{window:4}],focus:'old',shown:'old',loaded:true},cancelAnimationFrame(){},ctrlShow(){},ctrlAim:e=>e,
pad:{queue:[],state:'off'},padReset(){},fetch:(path,opts)=>{writes.push({device:opts.headers['X-Frame-Device'],events:JSON.parse(opts.body).events});return writes.length===1?new Promise((_,reject)=>rejectA=reject):Promise.resolve({ok:true,json:async()=>({state:'ready',sent:true})});}});
f.run(pick(html,'function ctrlReset() {','// Pointer moves:'));
f.run('ctrlSend([{key:30,down:false}])');
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
assert.equal(c.ctrl.on,false);assert.equal(c.ctrl.state,'off');assert.equal(c.ctrl.held.size,0);assert.equal(c.desk.panels.length,0);
rejectA(Error('lost A'));await tick();
assert.equal(c.ctrl.queue.length,0);assert.equal(c.ctrl.retryAt,0);assert.equal(writes.length,1);
});
test('late installation completion cannot populate the new headset detail',async()=>{
const f=workspaceFixture(),c=f.context,offer={source:'catalog',id:'one',name:'One',package:'app.one',downloadable:true};let finish,progress,refreshes=0;
Object.assign(c,{sourceState:{jobs:new Map()},currentOffer:()=>offer,sourcePage:()=>'',loadAndroid:()=>refreshes++,
runJob:async(_label,_key,start,update)=>{progress=update;await start();return new Promise(resolve=>finish=resolve);},api:async()=>({job:'a'})});
f.run(pick(html,'function updateInstall() {','async function openStoreApp('));f.run(pick(html,'async function installStoreApp() {','async function changeSource('));
const pending=f.run('installStoreApp()');await tick();
c.devGen++;c.sourceState.jobs.clear();f.el('detailInstall').textContent='New headset';
progress({stage:'Installing',percent:90});finish({package:'app.one',game_data:true});await pending;
assert.equal(c.sourceState.jobs.size,0);assert.equal(f.el('detailInstall').textContent,'New headset');assert.equal(refreshes,0);
});
test('late input startup and touch poll responses cannot re-enable the old target',async()=>{
const f=workspaceFixture(),c=f.context;let finish;
Object.assign(c,{pad:{state:'off'},clientPreferences:{set(){}},padShow:s=>{c.pad.state=s.state;},api:()=>new Promise(resolve=>finish=resolve)});
f.run(pick(html,'async function padStart() {','$("padOn").onclick'));
const start=f.run('padStart()');c.devGen++;c.pad.state='off';finish({state:'ready'});await start;assert.equal(c.pad.state,'off');
Object.assign(c,{ctrl:{on:true,state:'off'},ctrlShow(){},ctrlFlush(){throw Error('Must not flush stale input');}});
f.run(pick(html,'async function ctrlPoll(', '$("viewer").tabIndex'));
const poll=f.run('ctrlPoll(0)');c.devGen++;c.ctrl.on=false;finish({state:'ready'});await poll;assert.equal(c.ctrl.state,'off');
});
+335
View File
@@ -0,0 +1,335 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
const deferred=()=>{let resolve,reject;const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject};};
function installFixture() {
const f=workspaceFixture();
Object.assign(f.context,{wi:{queue:[]},wiNext(){f.calls.push(['review']);},sendFiles:files=>f.calls.push(['files',files])});
const start=html.indexOf('function openInstallChooser()'),end=html.indexOf('function renderHome(',start);
f.run(html.slice(start,end));return f;
}
test('Add / Install retains cancellation and hands chosen files to the existing transfer flow',()=>{
const f=installFixture(),input=f.el('installFileInput');let clicks=0;
input.click=()=>clicks++;f.run('openInstallChooser()');f.el('installChooseFile').onclick();assert.equal(clicks,1);
input.files=[];input.onchange();assert.equal(f.el('installChooser').open,true);assert.equal(f.calls.length,0);
const files=[{name:'game.zip'},{name:'player.apk'}];input.files=files;input.value='chosen';input.onchange();
assert.equal(f.el('installChooser').open,false);assert.equal(f.context.location.hash,'#transfer');assert.equal(input.value,'');
assert.deepEqual(Array.from(f.calls[0][1]),files);
});
test('chooser destinations reapply explicit views when their hash is already active',()=>{
const cases=[['#appStore',"selectApps('installed')",f=>assert.equal(f.el('appStore').hidden,false)],
['#linuxApps',"selectApps('browse')",f=>assert.equal(f.el('appsLinux').hidden,false)],
['#getgames',"selectLibrary('all','installed')",f=>assert.equal(f.context.gm.src,'owned')],
['#steamStore',"selectLibrary('all','owned')",f=>assert.equal(f.context.gm.src,'store')]];
for(const [hash,change,verify] of cases){
const f=installFixture();f.navigate(hash);f.run(change);f.run('openInstallChooser()');let prevented=false;
f.el('installChooser').onclick({target:{closest:()=>({getAttribute:()=>hash})},preventDefault(){prevented=true;}});
assert.equal(prevented,true,hash);assert.equal(f.el('installChooser').open,false);verify(f);
}
});
test('download links accept supported direct and manifest forms and reject malformed handoffs',()=>{
const f=installFixture();
for(const [raw,expected] of [
[' https://example.com/game.zip ',{kind:'url',target:'https://example.com/game.zip'}],
['frame-control://install?url=https%3A%2F%2Fexample.com%2Fapp.apk',{kind:'url',target:'https://example.com/app.apk'}],
['frame-control://install?manifest=https%3A%2F%2Fexample.com%2Fgame.json',{kind:'manifest',target:'https://example.com/game.json'}]
]){f.context.raw=raw;assert.deepEqual(JSON.parse(JSON.stringify(f.run('installLinkRequest(raw)'))),expected);}
for(const raw of ['file:///tmp/app.apk','javascript:alert(1)','https://user:secret@example.com/a.apk',
'frame-control://other?url=https://example.com/a.apk','frame-control://install/path?url=https://example.com/a.apk',
'frame-control://install?url=https://example.com/a.apk&manifest=https://example.com/a.json',
'frame-control://install?url=https://example.com/a.apk&url=https://example.com/b.apk',
'frame-control://install?manifest=garbage','not a URL','https://example.com/'+ 'x'.repeat(2050)]){
f.context.raw=raw;assert.throws(()=>f.run('installLinkRequest(raw)'));
}
});
test('link form queues a review only, with inline errors and a bounded waiting queue',()=>{
const f=installFixture(),submit=()=>f.el('installLinkForm').onsubmit({preventDefault(){}});
f.run('openInstallChooser()');f.el('installUrl').value='invalid';submit();
assert.equal(f.el('installChooser').open,true);assert.equal(f.el('installLinkError').hidden,false);assert.equal(f.context.wi.queue.length,0);assert.equal(f.calls.length,0);
f.el('installUrl').value='https://example.com/game.zip';submit();
assert.equal(f.el('installChooser').open,false);assert.equal(f.context.wi.queue.length,1);assert.deepEqual(f.calls,[['review']]);
f.context.wi.queue=Array(5).fill({kind:'url',target:'https://example.com/game.zip'});f.run('openInstallChooser()');submit();
assert.equal(f.el('installChooser').open,true);assert.equal(f.context.wi.queue.length,5);assert.match(f.el('installLinkError').textContent,/Finish or close/);
});
test('link review checks metadata but starts no download before the existing confirmation',async()=>{
const f=installFixture(),writes=[];
Object.assign(f.context,{log(){},gb:n=>String(n),api:async(path,body)=>{writes.push({path,body});return {id:'plan-1',name:'Game',host:'example.com',linkHost:'example.com',file:'game.zip',kindLabel:'PC game',size:100};}});
f.el('wiGo').focus=()=>{};f.el('wiDlg').addEventListener=()=>{};
const start=html.indexOf('const wi ='),end=html.indexOf('async function wiPoll(',start);f.run(html.slice(start,end));
f.run("wi.queue.push({kind:'url',target:'https://example.com/game.zip'})");await f.run('wiNext()');
assert.equal(writes.length,1);assert.equal(writes[0].path,'/api/webinstall/check');assert.equal(f.el('wiDlg').open,true);assert.equal(f.el('wiGo').disabled,false);
const cancel=html.indexOf('$("wiCancel").onclick = async () =>');f.run(html.slice(cancel,html.indexOf('// Escape',cancel)));
await f.el('wiCancel').onclick();assert.equal(f.el('wiDlg').open,false);assert.equal(writes.length,1);
});
test('catalog installs retain progress, failure retry and installed-app handoff',async()=>{
const f=workspaceFixture(),offer={source:'fdroid',id:'org.player',package:'org.player',name:'Player',downloadable:true,version_code:42},writes=[];
let success=false,refreshes=0,opened;
Object.assign(f.context,{sourceState:{jobs:new Map()},currentOffer:()=>offer,sourcePage:()=>'',openInstalledApp:pkg=>opened=pkg,
api:async(path,body)=>{writes.push({path,body});return {job:'job-1'};},loadAndroid:()=>refreshes++,
runJob:async(label,key,start,progress)=>{await start();progress({stage:'Installing',percent:75});assert.equal(f.el('detailProgress').value,75);return success?{package:'org.player'}:undefined;}});
const start=html.indexOf('function updateInstall()'),end=html.indexOf('async function openStoreApp(',start);f.run(html.slice(start,end));
const install=html.indexOf('async function installStoreApp()');f.run(html.slice(install,html.indexOf('async function changeSource(',install)));
await f.run('installStoreApp()');assert.equal(f.el('detailInstall').textContent,'Try again');assert.match(f.el('detailInstallNote').textContent,/couldn't finish/);
success=true;await f.run('installStoreApp()');assert.equal(f.el('detailInstall').textContent,'Open in Steam');assert.equal(refreshes,1);
assert.equal(writes.length,2);assert.deepEqual(JSON.parse(JSON.stringify(writes[0])),{path:'/api/sources/install',body:{source:'fdroid',id:'org.player',version_code:42}});
f.context.sourceState.jobs.clear();f.context.androidApps=[{package:'org.player'}];f.el('appDetail').showModal();f.run('updateInstall()');
assert.equal(f.el('detailInstall').textContent,'View installed app');await f.run('installStoreApp()');assert.equal(opened,'org.player');assert.equal(writes.length,2);assert.equal(f.el('appDetail').open,false);
});
test('Steam actions distinguish owned installation, installed play and non-owned store handoff',()=>{
const f=workspaceFixture();
f.context.gm.byId=new Map([[1,{installed:false}],[2,{installed:true}]]);
const start=html.indexOf('function gameButtons(');f.run(html.slice(start,html.indexOf('function moreGames(',start)));
assert.match(f.run("gameButtons(1,'Owned',false)"),/data-gm="install"/);
assert.match(f.run("gameButtons(2,'Installed',false)"),/data-launch="2"/);
for(const free of [false,true]){const output=f.run(`gameButtons(3,'Store',${free})`);assert.match(output,free?/Get on Steam/:/Buy on Steam/);assert.doesNotMatch(output,/data-gm="install"/);}
f.context.gm.dl={appid:1,percent:30};assert.match(f.run("gameButtons(1,'Owned',false)"),/Downloading 30%/);
});
test('Apps Games Tools Settings expose installed, browse, ownership and intentional legacy routes',()=>{
const f=workspaceFixture();
assert.deepEqual(f.links.map(a=>a.getAttribute()),['#apps','#games','#tools','#settings']);
f.navigate('');assert.equal(f.activePage,'apps');assert.equal(f.el('androidLibrary').hidden,false);assert.equal(f.el('appStore').hidden,true);
f.navigate('#games');assert.equal(f.activePage,'games');assert.equal(f.el('getgames').hidden,true);assert.equal(f.el('library').hidden,false);assert.equal(f.el('sideloaded').hidden,false);
f.navigate('#getgames');assert.equal(f.el('getgames').hidden,false);assert.equal(f.el('library').hidden,true);assert.equal(f.context.gm.src,'owned');
f.navigate('#steamStore');assert.equal(f.context.gm.src,'store');assert.equal(f.el('getgames').hidden,false);
f.navigate('#appStore');assert.equal(f.activePage,'apps');assert.equal(f.el('appStore').hidden,false);assert.equal(f.el('androidLibrary').hidden,true);
f.navigate('#linuxApps');assert.equal(f.activePage,'apps');assert.equal(f.el('appsLinux').hidden,false);
f.navigate('#androidLibrary');assert.equal(f.el('appsLinux').hidden,true);assert.equal(f.el('androidLibrary').hidden,false);
f.navigate('#display');assert.equal(f.activePage,'settings');assert.equal(f.el('androidSettings').hidden,false);assert.equal(f.el('settingsDirectory').hidden,true);
f.navigate('#settings');assert.equal(f.el('settingsDirectory').hidden,false);assert.equal(f.el('settingsWorkspace').hidden,true);
for(const [route,page,primary] of [['#devices','devices','settings'],['#control','control','settings'],['#shots','capture','tools'],['#view','capture','tools'],['#pad','tools','tools'],['#comfort','tools','tools'],['#library','games','games'],['#discover','apps','apps'],['#privacy','settings','settings']]){
f.navigate(route);assert.equal(f.activePage,page,route);assert.equal(f.run('primaryPage(page)'),primary,route);
}
});
test('app views and game scopes restore separate scroll positions, queries and filters',()=>{
const f=workspaceFixture();f.navigate('#appStore');f.el('sourceQ').value='video';f.scroll(480);
f.run("selectApps('installed')");f.el('libQ').value='player';f.scroll(220);
f.navigate('#games');f.scroll(160);f.navigate('#apps');assert.equal(f.context.window.scrollY,220);
f.run("selectApps('browse')");assert.equal(f.context.window.scrollY,480);assert.equal(f.el('sourceQ').value,'video');assert.equal(f.el('libQ').value,'player');
f.navigate('#games');assert.equal(f.context.window.scrollY,160);
f.run("selectLibrary('steam')");f.scroll(600);f.run("selectLibrary('sideloaded')");f.scroll(80);f.run("selectLibrary('steam')");assert.equal(f.context.window.scrollY,600);
f.context.location.hash='#apps';f.scroll(0);f.run('showPage()');f.navigate('#games');assert.equal(f.context.window.scrollY,600);
});
test('Android browse opens Apps Browse after visiting the Steam store',()=>{
const f=workspaceFixture();f.navigate('#steamStore');f.navigate('#androidLibrary');
const start=html.indexOf('function showAndroidTab(tab)'),end=html.indexOf("document.querySelectorAll('[data-store-tab]')",start);
f.run(html.slice(start,end));f.run("showAndroidTab('browse')");f.run('showPage()');
assert.equal(f.activePage,'apps');assert.equal(f.el('appStore').hidden,false);
});
test('empty installed library opens Browse even when its route is already in the address bar',()=>{
const f=workspaceFixture();let click;
f.context.libState={q:'',filter:'all'};f.el('andApps').addEventListener=(name,fn)=>click=fn;
const helper=html.indexOf('function showAndroidTab(tab)');
f.run(html.slice(helper,html.indexOf("document.querySelectorAll('[data-store-tab]')",helper)));
const start=html.indexOf('function renderLibrary()');
f.run(html.slice(start,html.indexOf('document.addEventListener("click",',start)));
f.navigate('#appStore');f.run("selectApps('installed');renderLibrary()");
assert.match(f.el('andApps').innerHTML,/data-store-go="browse"/);assert.equal(f.el('appStore').hidden,true);
click({target:{closest:()=>({dataset:{storeGo:'browse'}})}});
assert.equal(f.context.location.hash,'#appStore');assert.equal(f.el('appStore').hidden,false);assert.equal(f.el('appsInstalled').hidden,true);
});
test('owned scope excludes installed games and keeps unknown compatibility visible',()=>{
const f=workspaceFixture(),gm=f.context.gm;
Object.assign(gm,{owned:[{id:1,installed:true,name:'Installed',frame:3,sort:'installed'},{id:2,installed:false,name:'Owned unknown',frame:0,sort:'owned',lastPlayed:0}],q:'',filters:{owned:new Set(['0','1','2','3'])},shown:0});
f.context.moreGames=()=>{};
const start=html.indexOf('function filterGames('),end=html.indexOf('function price(',start);
f.run(html.slice(start,end));f.run('filterGames()');
assert.deepEqual(Array.from(gm.results,g=>g.id),[2]);
});
test('installed load succeeds while optional catalog is unresolved, and errors keep local retry',async()=>{
const f=workspaceFixture(),pending=deferred();
let result={apps:[{package:'org.example.player',label:'Player',running:false}]},fail=false;
f.context.api=path=>path==='/api/android'?fail?Promise.reject(Error('Temporary network failure')):Promise.resolve(result):pending.promise;
f.context.renderLibrary=()=>f.el('andApps').innerHTML='installed';
f.run('let androidSeq=0; const androidPending=new Set(); let installedPackage=null;');
const start=html.indexOf('async function loadAndroid() {'),end=html.indexOf("$('androidRetry').onclick",start);
f.run(html.slice(start,end));await f.run('loadAndroid()');
assert.equal(f.el('andApps').innerHTML,'installed');assert.equal(f.el('androidLoadError').hidden,true);
fail=true;await f.run('loadAndroid()');assert.equal(f.el('andApps').innerHTML,'installed');assert.equal(f.el('androidLoadError').hidden,false);
assert.match(f.el('androidLoadMessage').textContent,/Temporary network failure/);
fail=false;await f.run('loadAndroid()');assert.equal(f.el('androidLoadError').hidden,true);
assert.ok(html.includes('loadAndroid();\n\n// ---- Android display'),'startup must not wait for catalog completion');
});
test('older installed responses cannot replace a newer read',async()=>{
const f=workspaceFixture(),old=deferred(),next=deferred();let calls=0;
f.context.api=()=>++calls===1?old.promise:next.promise;f.context.renderLibrary=()=>{};
f.run('let androidSeq=0;const androidPending=new Set();let installedPackage=null;');
const start=html.indexOf('async function loadAndroid() {'),end=html.indexOf("$('androidRetry').onclick",start);
f.run(html.slice(start,end));const a=f.run('loadAndroid()'),b=f.run('loadAndroid()');
next.resolve({apps:[{package:'new'}]});await b;old.resolve({apps:[{package:'old'}]});await a;
assert.equal(f.context.androidApps[0].package,'new');
});
test('Android Open dispatch is deduplicated while pending and returns to an enabled row',async()=>{
const f=workspaceFixture(),request=deferred(),writes=[];
Object.assign(f.context,{devGen:1,installing:new Set(),toast(){},act:async(label,work)=>work(),
api:(path,body)=>{writes.push({path,body});return request.promise;},loadAndroid:async()=>{},renderLibrary(){},cat:{apps:null},loadReports(){}});
f.run('const androidPending=new Set();let installedPackage=null;');
const start=html.indexOf('document.body.addEventListener("click", async e => {',html.indexOf('$("catChips").onclick'));
const end=html.indexOf('loadAndroid();\n\n// ---- Android display',start);
f.run(html.slice(start,end));
const button={dataset:{and:'launch',pkg:'org.example.player',name:'Player'},disabled:false,textContent:'Open'};
const event={target:{closest:()=>button}};
const a=f.listeners['body-click'](event);await f.listeners['body-click'](event);
assert.equal(button.disabled,true);assert.equal(button.textContent,'Wait…');assert.equal(button.ariaBusy,'true');assert.equal(writes.length,1);
assert.deepEqual(JSON.parse(JSON.stringify(writes[0])),{path:'/api/android',body:{action:'launch',package:'org.example.player'}});
request.resolve({message:'Opened'});await a;assert.equal(button.disabled,false);assert.equal(button.textContent,'Open');assert.equal(button.ariaBusy,'false');
});
test('entering Apps Installed refreshes data without clearing cached rows',()=>{
const f=workspaceFixture();f.el('andApps').innerHTML='cached installed apps';f.navigate('#games');f.navigate('#apps');
assert.equal(f.calls.filter(c=>c[0]==='android').length,1);
f.run("selectApps('installed')");assert.equal(f.calls.filter(c=>c[0]==='android').length,2);
f.navigate('#settings');f.navigate('#apps');assert.equal(f.calls.filter(c=>c[0]==='android').length,3);
assert.equal(f.el('andApps').innerHTML,'cached installed apps');
});
test('actions from detail synchronize existing rows and stay pending through the follow-up read',async()=>{
for(const action of ['launch','stop']) {
const f=workspaceFixture(),request=deferred(),refresh=deferred();let writes=0,renders=0;
const makeButton=pkg=>({dataset:{and:action,pkg,name:'Player'},disabled:false,textContent:action==='stop'?'Stop':'Open'});
const detail=makeButton('org.example.player'),row=makeButton('org.example.player'),other=makeButton('org.other');
const originalQuery=f.context.document.querySelectorAll;
f.context.document.querySelectorAll=selector=>selector==='[data-and="launch"], [data-and="stop"]'?[row,detail,other]:originalQuery(selector);
Object.assign(f.context,{devGen:1,installing:new Set(),log(){},toast(){},api:()=>{writes++;return request.promise;},
loadAndroid:()=>refresh.promise,renderLibrary(){renders++;row.disabled=false;row.textContent=action==='stop'?'Stop':'Open';row.ariaBusy='false';},cat:{apps:null},loadReports(){}});
f.run('const androidPending=new Set();let installedPackage=null;');
f.run(html.slice(html.indexOf('async function act('),html.indexOf('\nfunction setConn(')));
const start=html.indexOf('document.body.addEventListener("click", async e => {',html.indexOf('$("catChips").onclick'));
f.run(html.slice(start,html.indexOf('loadAndroid();\n\n// ---- Android display',start)));
f.el('installedDetail').showModal();const pending=f.listeners['body-click']({target:{closest:()=>detail}});f.el('installedDetail').close();
for(const button of [row,detail]){assert.equal(button.disabled,true);assert.equal(button.textContent,'Wait…');assert.equal(button.ariaBusy,'true');}
assert.equal(other.disabled,false);await f.listeners['body-click']({target:{closest:()=>row}});assert.equal(writes,1);
request.resolve({message:'Done'});await new Promise(resolve=>setImmediate(resolve));
assert.equal(row.disabled,true);assert.equal(detail.disabled,true);assert.equal(renders,0,'stay pending while refreshed app state is unresolved');
refresh.resolve();await pending;assert.equal(renders,1);assert.equal(row.disabled,false);assert.equal(detail.disabled,false);assert.equal(f.run('androidPending.size'),0);
}
});
test('regenerated Android rows and details keep the compact accessible pending state',()=>{
const f=workspaceFixture(),app={package:'org.example.player',label:'Player',running:false};
Object.assign(f.context,{androidApps:[app],storeIcon:()=>'<span>icon</span>'});
f.run("const androidPending=new Set(['org.example.player']);let installedPackage=null;");
const start=html.indexOf('function libCard(a)'),end=html.indexOf('function renderLibrary()',start);f.run(html.slice(start,end));
for(const running of [false,true]) {
app.running=running;const row=f.run('libCard(androidApps[0])');f.run("openInstalledApp('org.example.player')");
assert.match(row,/disabled aria-busy="true">Wait…<\/button>/);
assert.match(f.el('installedDetailBody').innerHTML,/disabled aria-busy="true">Wait…<\/button>/);
}
f.run('androidPending.clear()');const row=f.run('libCard(androidApps[0])');assert.doesNotMatch(row,/aria-busy="true"/);assert.match(row,/>Stop<\/button>/);
});
test('explicit Capture section links take priority over saved destination scroll',()=>{
const f=workspaceFixture();f.navigate('#capture');f.scroll(700);f.calls.length=0;
f.navigate('#shots');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='shots'));
f.calls.length=0;f.navigate('#view');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='view'));
f.calls.length=0;f.navigate('#view');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='view'));
});
test('viewer keyboard shortcuts act only in the Capture workspace',()=>{
const f=workspaceFixture(),events=[];let handler;
f.context.document.activeElement={tagName:'BODY'};
f.context.document.addEventListener=(name,callback)=>handler=callback;
Object.assign(f.context,{ctrl:{on:false},zoom:1,setZoom:n=>events.push(['zoom',n])});
f.el('ctrlBtn').click=()=>events.push(['control']);f.el('fullBtn').click=()=>events.push(['fullscreen']);
const start=html.indexOf('document.addEventListener("keydown", e => {',html.indexOf('$("canvas").addEventListener("pointerdown"'));
const end=html.indexOf('\n});',start)+4;f.run(html.slice(start,end));
f.navigate('#capture');handler({key:'+'});handler({key:'c'});handler({key:'f'});assert.equal(events.length,3);
f.navigate('#home');handler({key:'+'});handler({key:'c'});handler({key:'f'});assert.equal(events.length,3);
});
test('late Mac streaming responses cannot reveal a different tool panel',async()=>{
for(const rejects of [false,true]) {
const f=workspaceFixture(),response=deferred();
Object.assign(f.context,{api:()=>response.promise,clearTimeout(){},setTimeout(){},failed(){}});
f.run('let mvSeq=0,mvTimer=0;');
const start=html.indexOf('async function loadMacView(restart)'),end=html.indexOf('$("mvRefresh").onclick',start);
f.run(html.slice(start,end));f.context.location.hash='#macview';const pending=f.run('loadMacView()');
f.navigate('#transfer');assert.equal(f.el('macview').hidden,true);
rejects?response.reject(Error('Network failed')):response.resolve({available:true,streams:[],displays:[],windows:[]});
await pending;assert.equal(f.el('macview').hidden,true);assert.equal(f.el('transfer').hidden,false);
}
});
test('Home task count includes the current Steam download',()=>{
const f=workspaceFixture();Object.assign(f.context,{state:{hostname:'Frame',battery:{percent:80}},stateRead:{valid:true},online:true});
f.context.gm.dl={appid:1,percent:42};f.run('renderHome()');
assert.equal(f.el('homeJobs').textContent,'1 task in progress');assert.match(f.el('homeDownload').textContent,/42%/);
});
test('a late screenshot read cannot replace newer navigation or clear a newer viewer request',async()=>{
const f=workspaceFixture(),old=deferred(),newer=deferred();let reads=0;
Object.assign(f.context,{shotBlob:()=>++reads===1?old.promise:newer.promise,URL:{createObjectURL:()=> 'blob:shot',revokeObjectURL(){}},
Image:class{set src(value){this.onload();}},draw(){},shotApp:()=> 'Game',toast(){},desk:{shown:null}});
const start=html.indexOf('async function openShot(s)'),end=html.indexOf('async function saveShots(',start);
f.run(html.slice(start,end));f.navigate('#capture');const pending=f.run("openShot({id:1,file:'one.png',time:1})");
assert.equal(f.el('viewer').classList.contains('busy'),true);
f.navigate('#library');f.scroll(240);assert.equal(f.el('viewer').classList.contains('busy'),false);
old.resolve({});await pending;assert.equal(f.activePage,'games');assert.equal(f.context.location.hash,'#library');assert.equal(f.context.window.scrollY,240);
f.navigate('#capture');const first=f.run("openShot({id:2,file:'two.png',time:1})");
f.navigate('#home');f.navigate('#capture');f.run('viewGen++');f.el('viewer').classList.toggle('busy',true);
newer.resolve({});await first;assert.equal(f.activePage,'capture');assert.equal(f.context.location.hash,'#capture');assert.equal(f.el('viewer').classList.contains('busy'),true);
});
test('Steam discovery preserves an unsubmitted query across a source change',()=>{
const f=workspaceFixture();Object.assign(f.context.gm,{src:'store',q:'owned query',storeDraft:'',storeQ:'submitted query'});
f.context.renderChips=()=>{};f.context.filterGames=()=>{};
const start=html.indexOf('function setGameSource(src)'),end=html.indexOf('$("gmSrc").onclick',start);
f.run(html.slice(start,end));f.el('gmQ').value='unsubmitted search';f.run("setGameSource('owned')");
assert.equal(f.el('gmQ').value,'owned query');f.run("setGameSource('store')");assert.equal(f.el('gmQ').value,'unsubmitted search');
});
test('expanded owned and store results survive source and destination returns',async()=>{
const f=workspaceFixture(),gm=f.context.gm;
const games=Array.from({length:120},(_,i)=>({id:i+1,name:'Game '+String(i+1).padStart(3,'0'),sort:String(i).padStart(3,'0'),frame:3,installed:false,lastPlayed:0}));
Object.assign(gm,{owned:games,store:games,storeQ:'game',storeDraft:'game',country:'US',q:'',results:[],filters:{owned:new Set(['3']),store:new Set(['3'])},seq:0});
Object.assign(f.context,{FLABEL:{3:'Verified'},FCLASS:{3:'verified'},CDN:'https://example.invalid',renderChips(){}});
f.el('gmGrid').insertAdjacentHTML=(position,s)=>f.el('gmGrid').innerHTML+=s;
const start=html.indexOf('function filterGames('),end=html.indexOf('$("gmSrc").onclick',start);
f.run(html.slice(start,end));
f.navigate('#getgames');f.run('filterGames();moreGames()');f.scroll(7000);
assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);
f.navigate('#steamStore');f.run('moreGames()');f.scroll(6500);
assert.equal(gm.shown,96);f.navigate('#getgames');assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);assert.equal(f.context.window.scrollY,7000);
f.navigate('#steamStore');assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);assert.equal(f.context.window.scrollY,6500);
f.run('filterGames()');assert.equal(gm.shown,48,'a deliberate filter change resets expansion');
const response=deferred();f.context.api=()=>response.promise;f.el('gmQ').value='new search';const search=f.run('searchStore()');
f.navigate('#getgames');const ownedMarkup=f.el('gmGrid').innerHTML;
response.resolve({results:games.slice(0,60)});await search;
assert.equal(f.el('gmGrid').innerHTML,ownedMarkup,'a late store response leaves current owned results intact');
f.navigate('#steamStore');assert.equal(gm.shown,48);assert.equal(gm.storeQ,'new search');
});
test('Home shortcuts show their tool before focusing it, so typing works on the first tap',()=>{
const f=workspaceFixture(),pad={state:'on'},buttons=[{dataset:{go:'pad'}}];let focusedVisible=null;
f.context.document.querySelectorAll=(q=>s=>s==='[data-go]'?buttons:q(s))(f.context.document.querySelectorAll);
Object.assign(f.context,{pad,padStart(){},history:{pushState(_,__,url){f.context.location.hash=url;}}});
f.el('padType').focus=()=>{focusedVisible=f.activePage==='tools'&&!f.el('pad').hidden;};
f.navigate('#home');
const start=html.indexOf('function showNow('),end=html.indexOf('$("nowSend").onclick',start);
let shows=0;f.run('const realShowPage=showPage;');f.context.showPage=()=>{shows++;f.run('realShowPage()');};
f.run(html.slice(start,end).replace(/showPage\(\)/g,'globalThis.showPage()'));buttons[0].onclick();
assert.equal(focusedVisible,true);assert.equal(shows,1);assert.equal(f.context.location.hash,'#pad');
});
test('a Home shortcut keeps the destination scroll and loads its tool once, with frames deferred',()=>{
const f=workspaceFixture(),frames=[],events=[],buttons=[{dataset:{go:'pad'}},{dataset:{go:'macview'}}];
// A hash assignment queues hashchange, which a browser may deliver before the next frame.
let hash=f.context.location.hash;
Object.defineProperty(f.context.location,'hash',{get:()=>hash,set(v){const next='#'+String(v).replace(/^#/,'');if(next!==hash){hash=next;events.push(()=>f.run('showPage({type:"hashchange"})'));}}});
const flush=()=>{while(events.length||frames.length)(events.length?events:frames).shift()();};
f.context.requestAnimationFrame=fn=>frames.push(fn);
f.context.document.querySelectorAll=(q=>s=>s==='[data-go]'?buttons:q(s))(f.context.document.querySelectorAll);
Object.assign(f.context,{pad:{state:'on'},padStart(){},history:{pushState(_,__,url){hash=url;}}});
f.el('padType').focus=()=>{};
f.navigate('#pad');flush();f.scroll(320);
f.navigate('#home');flush();f.scroll(150);
const start=html.indexOf('function showNow('),end=html.indexOf('$("nowSend").onclick',start);
f.run(html.slice(start,end));
buttons[0].onclick();flush();
assert.equal(f.context.window.scrollY,320);
f.navigate('#home');flush();f.calls.length=0;
buttons[1].onclick();flush();
assert.equal(f.calls.filter(c=>c[0]==='macview').length,1);
});
test('Cast keeps the Capture scroll and shows Capture once, with frames deferred',()=>{
const f=workspaceFixture(),frames=[],events=[];let shows=0;
let hash=f.context.location.hash;
Object.defineProperty(f.context.location,'hash',{get:()=>hash,set(v){const next='#'+String(v).replace(/^#/,'');if(next!==hash){hash=next;events.push(()=>f.run('showPage({type:"hashchange"})'));}}});
const flush=()=>{while(events.length||frames.length)(events.length?events:frames).shift()();};
f.context.requestAnimationFrame=fn=>frames.push(fn);
Object.assign(f.context,{history:{pushState(_,__,url){hash=url;}},setView(){},toggleLive(){}});
f.el('viewer').requestFullscreen=()=>Promise.resolve();
f.navigate('#capture');flush();f.scroll(320);
f.navigate('#home');flush();f.scroll(150);
const start=html.indexOf('function showNow('),end=html.indexOf('\n};\n',html.indexOf('$("castBtn").onclick'))+4;
f.run(html.slice(start,end).replace(/showPage\(\)/g,'(globalThis.__shows=(globalThis.__shows||0)+1,showPage())'));
f.el('castBtn').onclick();flush();
assert.equal(f.context.window.scrollY,320);assert.equal(f.run('globalThis.__shows'),1);assert.equal(hash,'#capture');
});
+39
View File
@@ -0,0 +1,39 @@
// Execute the production workspace code with only browser/transport boundaries replaced.
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync(require.resolve('../ui/index.html'), 'utf8');
function workspaceFixture() {
const elements = new Map(), listeners = {}, calls = [];
const classes = () => { const set=new Set(); return {add(k){set.add(k);},toggle(k,on){if(on===undefined)on=!set.has(k);on?set.add(k):set.delete(k);return on;},remove(k){set.delete(k);},contains:k=>set.has(k)}; };
function el(id) {
if (!elements.has(id)) elements.set(id,{id,hidden:false,disabled:false,open:false,innerHTML:'',textContent:'',value:'',dataset:{},classList:classes(),
setAttribute(k,v){this[k]=v;},removeAttribute(k){delete this[k];},querySelectorAll(){return [];},
append(node){node.parent=node.parentNode=this;},prepend(node){node.parent=node.parentNode=this;},scrollIntoView(){calls.push(['section',id]);},
showModal(){this.open=true;},close(){this.open=false;},addEventListener(k,fn){listeners[id+'-'+k]=fn;}});
return elements.get(id);
}
const pages=[...html.matchAll(/data-page="([^"]+)"/g)].map((m,i)=>Object.assign(el('page-'+i),{dataset:{page:m[1]}}));
const nav=html.match(/<nav id="nav"[^>]*>([\s\S]*?)<\/nav>/)[1];
const links=[...nav.matchAll(/<a href="([^"]+)"/g)].map((m,i)=>Object.assign(el('nav-'+i),{getAttribute:()=>m[1]}));
const controls={};
for (const [attr,values] of [['scope',['installed','owned','store']],['library-source',['all','steam','sideloaded']],['app-view',['installed','browse','linux']]]) {
const prop=attr.replace(/-([a-z])/g,(_,c)=>c.toUpperCase());
controls[`[data-${attr}]`]=values.map(v=>Object.assign(el(attr+'-'+v),{dataset:{[prop]:v}}));
}
const win={scrollY:0,scrollTo(a,b){this.scrollY=typeof a==='object'?a.top:b;calls.push(['scroll',this.scrollY]);},addEventListener(k,fn){listeners[k]=fn;}};
let hash='#apps';const location={get hash(){return hash;},set hash(value){hash=value&&!String(value).startsWith('#')?'#'+value:String(value);}};
const context={URL,document:{querySelectorAll:s=>s==='.page'?pages:s==='#nav a'?links:controls[s]||[],title:'',body:{addEventListener(k,fn){listeners['body-'+k]=fn;}}},
window:win,location,scroller:()=>({get scrollTop(){return win.scrollY;},scrollTo:o=>win.scrollTo(o)}),scrollToY:y=>win.scrollTo({top:y}),requestAnimationFrame:fn=>fn(),$:el,live:false,toggleLive(on){this.live=on;calls.push(['live',on]);},
loadAndroid(){calls.push(['android']);},closeDevPop(){},metaLib:{connected:false},renderMetaApps(){},
loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
gm:{src:'owned',dl:null,byId:new Map(),shown:0,shownBySource:{owned:0,store:0}},setGameSource(src){context.gm.src=src;},state:null,stateRead:{valid:false},online:null,devGen:0,androidApps:[],jobsRunning:0,viewGen:0,
esc:s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))};
vm.createContext(context);
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
vm.runInContext(html.slice(start,end),context);
return {html,context,elements,el,calls,pages,links,listeners,run:code=>vm.runInContext(code,context),
navigate(hash){context.location.hash=hash;vm.runInContext('showPage()',context);},
get activePage(){return pages.find(p=>p.classList.contains('on'))?.dataset.page;},
scroll(y){win.scrollY=y;listeners.scroll();}};
}
module.exports={workspaceFixture,html};
+3 -1
View File
@@ -14,7 +14,7 @@ button.primary { background:#176b9c } button:disabled { opacity:.5; cursor:wait
summary { overflow-wrap:anywhere; cursor:pointer }
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:inherit; max-height:380px; overflow:auto } [hidden] { display:none!important } #status { min-height:1.5em } small { color:#b8c6d5 }
</style>
<header><h1>Frame Control · Assistant</h1><a href="/">Back to Frame Control</a></header>
<header><h1>Frame Control · Assistant</h1><a id="back" href="/" referrerpolicy="no-referrer">Back to Frame Control</a></header>
<section id="approval" hidden aria-labelledby="approval-title">
<h2 id="approval-title">An agent wants to change your Frame</h2>
<p>Review the exact action below. Approve only if you asked for it. Approval expires after five minutes and works once.</p>
@@ -42,6 +42,8 @@ pre { white-space:pre-wrap; overflow-wrap:anywhere; font:inherit; max-height:380
'use strict';
const $ = id => document.getElementById(id);
const key = __FRAME_KEY__;
const backParams = new URLSearchParams({key});
$('back').href = '/?' + backParams;
let generation = 0;
async function api(path, body) {
const response = await fetch(path, {method:body === undefined ? 'GET' : 'POST',
+2 -2
View File
@@ -20,7 +20,7 @@
keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
{id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#apps','Linux apps'],['#power','SFTP & remote tools']]},
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#linuxApps','Linux apps'],['#power','SFTP & remote tools']]},
{id:'microphone', category:'device', icon:'audio', title:'Microphone & audio routing', kind:'headset', label:'In the headset',
description:'Frame Control controls the default output volume only. Microphone mute, input gain, output selection, balance and per-app mixers have no verified API here.',
keywords:'mic input microphone audio sink output device gain mute eq equalizer spatial audio balance headphones',
@@ -77,7 +77,7 @@
guidance:'Seeking, subtitles, native HEIC/MPO and large splat scenes are not supported yet.'},
{id:'flatpak', category:'apps', icon:'apps', title:'Linux desktop apps', kind:'flow', label:'Existing controls',
description:'Install user Flatpaks by Flathub ID, choose quick picks like VLC and Moonlight, and manage installed apps.',
keywords:'flatpak firefox vlc remmina moonlight flathub install remove', links:[['#apps','Manage Linux apps']]},
keywords:'flatpak firefox vlc remmina moonlight flathub install remove', links:[['#linuxApps','Manage Linux apps']]},
{id:'connection', category:'network', icon:'network', title:'Headsets & addresses', kind:'flow', label:'Existing controls',
description:'Manage headsets, LAN, USB-C, mDNS and Tailscale addresses. Inspect connection attempts and choose the device you want to control.',
keywords:'wifi wi-fi network ip ssh pairing keys discovery alias tailscale usb offline reconnect', links:[['#devices','Manage headsets','desktop'],['#connection','Connection details']],
+8
View File
@@ -90,6 +90,13 @@ def validate(name, args):
def call(server, body):
# Keep target validation, token consumption and dispatch inside one work
# reservation, including direct callers outside the HTTP handler.
with server.working():
return _call(server, body)
def _call(server, body):
name, args = body.get('name'), body.get('arguments', {})
action = validate(name, args)
if name in ('install', 'uninstall', 'panel') and not server.FLATPAK_ID.fullmatch(args['id']):
@@ -98,6 +105,7 @@ def call(server, body):
raise ValueError('Expected a Steam app ID')
if name == 'keep_awake' and args['action'] == 'status':
return keep_awake(server, 'status')
action['target'] = server.agent_target()
token = body.get('confirmation')
if not token:
return approvals.request(action)
+150 -10
View File
@@ -187,6 +187,58 @@ def _write_meta(d, meta):
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
# Only files this installer owns. Lepton compatdata and any other files beside
# the app (including user data) are never part of update rollback.
UPDATE_FILES = ('app.apk', 'launch.sh', 'instance.id', 'lepton-show-flatscreen',
'shortcut.id', 'meta.json', 'artwork')
class UpdateRecoveryPending(FrameError):
"""A new attempt does not own, and must not rewrite, an earlier recovery."""
def _update_guard(d):
return (f'if [ -e {d}/update-recovery.json ] || [ -e {d}/.frame-update-backup ]; '
'then echo FRAME_UPDATE_RECOVERY_PENDING; exit 0; fi; ')
def _check_update_guard(reply, d):
if reply.strip() == 'FRAME_UPDATE_RECOVERY_PENDING':
pkg = d.rsplit('/', 1)[-1]
raise UpdateRecoveryPending(f'Pending APK update recovery at {d}; prior record and backup preserved. '
f'Repair with `python3 ui/frame_android.py refresh-art {pkg}` before retrying')
def _backup_update(d):
backup = f'{d}/.frame-update-backup'
files = ' '.join(UPDATE_FILES[1:])
# The APK is only ever replaced by rename, so a hard link keeps its old bytes
# without a second multi-gigabyte copy. Smaller mutable files need copies.
reply = ssh(_update_guard(d) + f'set -eu; mkdir {backup}; ln {d}/app.apk {backup}/app.apk; '
f'for f in {files}; do if [ -e {d}/"$f" ]; then cp -pR {d}/"$f" {backup}/"$f"; fi; done; '
f'touch {backup}/ready', timeout=120)
_check_update_guard(reply, d)
def _restore_update(d, keep_backup=False):
backup = f'{d}/.frame-update-backup'
# Retain the snapshot if any restore step fails, so refresh-art can retry.
ssh(f'set -eu; test -f {backup}/ready; '
f'for f in {" ".join(UPDATE_FILES)}; do '
f'if [ -e {backup}/"$f" ]; then '
f'rm -rf {d}/"$f"; cp -pR {backup}/"$f" {d}/"$f"; '
f'else rm -rf {d}/"$f"; fi; done; '
f'rm -f {d}/app.apk.part {d}/meta.json.tmp; '
+ ('' if keep_backup else f'rm -rf {backup}'), timeout=120)
def _finish_update_recovery(d):
# The durable repair_completed checkpoint is written before this command.
# A partial cleanup or lost reply can then be retried without another restore.
ssh(f'rm -rf {d}/.frame-update-backup && '
f'rm -f {d}/update-recovery.json {d}/update-recovery.json.tmp', timeout=30)
# Called after every install, worked or not, as fn(info, meta, error, seconds):
# info is None if the APK couldn't be read, meta None and error set if it failed.
install_hooks = []
@@ -541,16 +593,22 @@ def _after_install(info, meta, error, start):
def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
d = f'{APPS_DIR}/{pkg}'
# Outside this attempt's transaction/error handler: a refusal cannot replace
# an earlier orphan ID, rollback uncertainty, snapshot or recovery record.
_check_update_guard(ssh(_update_guard(d)), d)
try:
images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
except (ValueError, OSError) as e:
raise FrameError(f'could not prepare artwork: {e}') from e
iid = instance_id(pkg)
d = f'{APPS_DIR}/{pkg}'
existing = read_meta(pkg)
ok, created = False, None
ok, created, backed_up, library_started = False, None, False, False
try:
ssh(f'mkdir -p {d}')
if existing:
_backup_update(d)
backed_up = True
_copy(apk_path, f'{d}/app.apk.part')
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
@@ -564,6 +622,7 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
if not shortcut:
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
created = shortcut
library_started = True # configure can mutate Steam before raising
presentation = apply_library(shortcut, name or info['label'], d, images,
vr=not flatscreen, home=home,
exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}',
@@ -580,26 +639,72 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
'library_warnings': presentation.get('warnings', []),
'artwork': presentation.get('artwork', {}), 'library_version': 2}
_write_meta(d, meta)
if backed_up:
ssh(f'rm -rf {d}/.frame-update-backup', timeout=30)
ok = True
return meta
except UpdateRecoveryPending:
raise # a recovery appeared after preflight; this attempt still owns nothing
except Exception as error:
if existing:
orphan, cleanup_error = None, None
if created:
try:
shortcut_tool('remove', str(created))
except Exception as e:
orphan, cleanup_error = created, str(e) or type(e).__name__
created = None # any failed cleanup is recorded and retried by repair
rollback_error = None
if backed_up:
try:
_restore_update(d)
except Exception as e:
rollback_error = str(e) or type(e).__name__
if library_started or rollback_error or cleanup_error or not backed_up:
repair = f'python3 ui/frame_android.py refresh-art {pkg}'
record = {'error': str(error) or type(error).__name__,
'files_restored': backed_up and rollback_error is None,
'files_unchanged': not backed_up,
'library_may_have_changed': library_started,
'rollback_error': rollback_error, 'repair': repair,
'orphan_shortcut': orphan, 'cleanup_error': cleanup_error,
'backup': f'{d}/.frame-update-backup'}
evidence_error = None
try:
ssh(f'cat > {d}/update-recovery.json.tmp && '
f'mv {d}/update-recovery.json.tmp {d}/update-recovery.json', input=json.dumps(record))
except Exception as e:
evidence_error = str(e) or type(e).__name__
state = 'Previous app files restored' if record['files_restored'] else 'App file rollback unconfirmed'
message = (f'{error}. {state}; Steam library may need repair. '
f'Repair with `{repair}`. Recovery: {d}/update-recovery.json; '
f'backup: {record["backup"]}')
if rollback_error:
message += f'; rollback failed: {rollback_error}'
if cleanup_error:
message += f'; shortcut {orphan} cleanup failed: {cleanup_error}'
if evidence_error:
message += f'; could not save recovery record: {evidence_error}'
raise FrameError(message) from error
raise
finally:
if not ok and created:
try:
shortcut_tool('remove', str(created))
except FrameError:
except Exception:
pass
if not ok and not existing:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
ssh(f'rm -rf {d}', timeout=30)
except FrameError:
except Exception:
pass
elif not ok:
# An update that failed: the app still works, but a partial copy of the new APK
# (scp leaves one; it can be gigabytes) mustn't stay beside it.
elif not ok and backed_up:
# No executable files or backups are deleted here. Rollback above
# reports its own failure and retains the snapshot for repair.
try:
ssh(f'rm -f {d}/app.apk.part', timeout=30)
except FrameError:
except Exception:
pass
@@ -703,9 +808,36 @@ def refresh_art(pkg=None, artwork=None, fill_only=False):
except Exception as e: # one app's failure must not stop the others
results.append({'package': app['package'], 'label': app.get('label'), 'error': str(e) or type(e).__name__})
return results
if not PKG_RE.fullmatch(pkg or ''):
raise FrameError(f'bad package name {pkg!r}')
with _install_lock: # shared with install and remove: a removed app is never recreated
m = _meta_or_fail(pkg)
d = f'{APPS_DIR}/{pkg}'
recovering = ssh(f'test -f {d}/update-recovery.json && echo yes || true').strip() == 'yes'
if not recovering and ssh(f'test -e {d}/.frame-update-backup && echo yes || true').strip() == 'yes':
raise FrameError(f'Update snapshot at {d}/.frame-update-backup has no recovery record; '
'rollback is unconfirmed. Inspect and preserve the snapshot before repair')
recovery = json.loads(ssh(f'cat {d}/update-recovery.json')) if recovering else {}
if recovery.get('repair_completed') is True:
m = _meta_or_fail(pkg)
_finish_update_recovery(d)
return m
if recovering:
snapshot = ssh(f'if [ -f {d}/.frame-update-backup/ready ]; then echo ready; '
f'elif [ -d {d}/.frame-update-backup ]; then echo incomplete; fi').strip()
if snapshot == 'ready':
_restore_update(d, keep_backup=True)
elif snapshot == 'incomplete':
raise FrameError(f'Incomplete update snapshot at {d}/.frame-update-backup; '
'original app files were not changed. Inspect and remove this '
'incomplete snapshot before retrying refresh-art')
elif not (recovery.get('files_restored') or recovery.get('files_unchanged')):
raise FrameError(f'App rollback is unconfirmed and {d}/.frame-update-backup is absent; '
'inspect the APK, launcher, display marker and metadata before repair. '
f'Keep {d}/update-recovery.json as evidence')
m = _meta_or_fail(pkg)
orphan = _int(recovery.get('orphan_shortcut'))
if orphan and orphan != m['shortcut']:
shortcut_tool('remove', str(orphan))
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
script = _apk_parser_script()
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
@@ -726,7 +858,7 @@ def refresh_art(pkg=None, artwork=None, fill_only=False):
try:
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m,
fill_only=fill_only and not created)
fill_only=fill_only and not created and not recovering)
except Exception:
if created:
try:
@@ -738,6 +870,14 @@ def refresh_art(pkg=None, artwork=None, fill_only=False):
m['library_warnings'] = warnings + result.get('warnings', [])
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
_write_meta(d, m)
if recovering:
# Keep the original failure/orphan evidence and snapshot until the
# whole repair is committed. Persist completion before cleanup so
# any failed/lost cleanup response leaves a safe retry path.
recovery['repair_completed'] = True
ssh(f'cat > {d}/update-recovery.json.tmp && '
f'mv {d}/update-recovery.json.tmp {d}/update-recovery.json', input=json.dumps(recovery))
_finish_update_recovery(d)
return m
def _int(v):
+145
View File
@@ -0,0 +1,145 @@
"""Import a local Android backup: one APK and its matching expansion files."""
import re
import time
from pathlib import Path
import frame_android as android
import frame_quest as quest
OBB = re.compile(r'(main|patch)\.[0-9]+\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)\.obb')
def inspect(paths, discover=False):
"""Inspect selected files before installing anything. Desktop APK drops can find
sibling OBBs; browser uploads include only files explicitly selected by the user."""
if not isinstance(paths, list) or not paths or len(paths) > 100:
raise android.FrameError('Choose one game APK and its .obb game-data files together.')
files = []
for value in paths:
path = Path(value).expanduser().absolute()
if path.is_dir():
files.extend(p for p in sorted(path.iterdir()) if p.suffix.lower() in ('.apk', '.obb'))
else:
files.append(path)
files = list(dict.fromkeys(files))
if not files or len(files) > 100:
raise android.FrameError('No Android backup found. Choose a folder containing one APK and its game data.')
for path in files:
if path.suffix.lower() not in ('.apk', '.obb') or not path.is_file() or path.stat().st_size == 0:
raise android.FrameError(f'{path.name}: choose a nonempty APK or .obb game-data file.')
apks = [p for p in files if p.suffix.lower() == '.apk']
if len(apks) > 1:
raise android.FrameError('Choose one game at a time. Split APK backups are not supported by this importer.')
apk = apks[0] if apks else None
if apk:
info = android.apk_info(str(apk))
try:
android.check_installable(info)
except android.FrameError as error:
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
'blocker': str(error)}
raise
checked = quest.inspect(info['package'], str(apk))
pkg = checked['package']
if discover and len(files) == 1:
files.extend(p for p in sorted(apk.parent.iterdir())
if p not in files and OBB.fullmatch(p.name) and OBB.fullmatch(p.name)[2] == pkg)
else:
packages = {OBB.fullmatch(p.name)[2] for p in files if OBB.fullmatch(p.name)}
if len(packages) != 1:
raise android.FrameError('Choose the APK and game-data files for one game together.')
pkg = packages.pop()
if not android.PKG_RE.fullmatch(pkg):
raise android.FrameError('The game-data filename has an invalid Android package name.')
meta = android.read_meta(pkg)
if not meta:
raise android.FrameError('This game is not installed. Add its APK alongside these game-data files.')
checked = {k: meta.get(k) for k in ('package', 'label', 'version', 'version_code')}
checked['version_code'] = quest._installed_code(pkg, meta)
if checked['version_code'] is None:
raise android.FrameError('Add the matching APK so Frame Control can check which game version this data belongs to.')
checked['warnings'] = []
obb = [p for p in files if p.suffix.lower() == '.obb']
if len(obb) > 99:
raise android.FrameError('Choose one APK and at most 99 game-data files.')
if obb and checked['version_code'] is None:
raise android.FrameError('The APK has no readable version code, so its game-data target cannot be checked.')
names = set()
for path in obb:
match = OBB.fullmatch(path.name)
if not match or match[2] != pkg:
raise android.FrameError(f'{path.name} does not belong to {checked["label"]}. Choose matching game data.')
if not path.is_file() or not path.stat().st_size or path.name in names:
raise android.FrameError(f'{path.name}: game-data files must be nonempty and have unique names.')
names.add(path.name)
return {'apk': apk, 'obb': obb, 'checked': checked,
'signatures': {str(p): (p.stat().st_size, p.stat().st_mtime_ns) for p in [*apks, *obb]}}
def public(plan):
checked = plan['checked']
return {**checked, 'apk': plan['apk'].name if plan['apk'] else None,
'obb': [{'name': p.name, 'size': p.stat().st_size} for p in plan['obb']],
'size': sum(size for size, _ in plan['signatures'].values())}
def install(plan, display='auto', progress=None):
"""The explicit Import button authorizes starting the game to add its data.
Reuse the Quest import's instance/version checks, verified transfer and close."""
report = progress or (lambda *_: None)
if display not in ('auto', 'flat', 'vr'):
raise android.FrameError('Invalid Android display mode.')
for path, signature in plan['signatures'].items():
p = Path(path)
if not p.is_file() or (p.stat().st_size, p.stat().st_mtime_ns) != signature:
raise android.FrameError('The backup changed after it was read. Choose the files again.')
checked = plan['checked']
pkg, label = checked['package'], checked['label']
with android._install_lock:
if plan['apk']:
if quest._running(pkg):
raise android.FrameError(f'Close {label} on the Frame, then try importing again.')
quest._check_downgrade(android.read_meta(pkg), checked, False)
report('Installing the game app', 15)
meta = android.install(str(plan['apk']), source=plan['apk'].name,
flatscreen=None if display == 'auto' else display == 'flat')
else:
meta = android._meta_or_fail(pkg)
if not plan['obb']:
report('Done', 100)
warnings = list(checked['warnings']) + list(meta.get('vr_issues') or [])
return {'message': f'Installed {label}. Open it from your Steam library.', 'app': meta,
'needs_game_data': False, 'warnings': warnings}
try:
if not quest._running(pkg):
report('Starting the game to add its data', 55)
android.launch(pkg)
deadline = time.monotonic() + 90
while not quest._running(pkg):
if time.monotonic() >= deadline:
raise android.FrameError('The game did not start. Check that Steam is open on the Frame.')
time.sleep(2)
report('Preparing Android for game data', 65)
quest._wait_ready(meta)
# Android can be booted before Lepton has installed the APK.
deadline = time.monotonic() + 90
while True:
try:
quest._check_android_package(meta, checked['version_code'])
break
except quest.QuestError:
if time.monotonic() >= deadline:
raise
time.sleep(2)
result = quest.add_game_data(pkg, checked, plan['obb'], None, report,
checked['warnings'], source='your backup')
if result['closed']:
result['message'] = (f'Imported {label} and {len(plan["obb"])} game-data '
f'file{"s" if len(plan["obb"]) != 1 else ""}. '
'Ready to open from your Steam library.')
return result
except Exception as error:
closed, why = quest._close(pkg)
detail = ('The game is closed.' if closed else f'Close the game on the Frame ({why}).')
raise android.FrameError(f'{label} is installed, but its game-data setup did not finish: {error}. '
f'{detail} Import the same backup again to retry.') from error
+10 -4
View File
@@ -558,15 +558,21 @@ class Registry:
self.config = Path(config) if config else None # None: ssh_config() at call time
self.lock = threading.RLock()
self._depth = 0 # nested _changing() calls
self._mtime = None # devices.json as last loaded or saved
self._stamp = None # devices.json as last loaded or saved
self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}}
self.load()
# -- storage --
def _file_stamp(self):
stat = self.path.stat()
# Saves atomically replace the file. Fast consecutive saves can have the
# same timestamp and size; its identity still changes in that case.
return stat.st_dev, stat.st_ino, stat.st_mtime_ns, stat.st_size
def load(self):
with self.lock:
try:
self._mtime = self.path.stat().st_mtime_ns
self._stamp = self._file_stamp()
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return
@@ -607,14 +613,14 @@ class Registry:
tmp = self.path.with_name(self.path.name + ".tmp")
tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
os.replace(tmp, self.path)
self._mtime = self.path.stat().st_mtime_ns
self._stamp = self._file_stamp()
def _refresh(self):
"""Pick up what another Frame Control server saved (the app and a standalone
server can share devices.json)."""
with self.lock:
try:
if self.path.stat().st_mtime_ns != self._mtime:
if self._file_stamp() != self._stamp:
self.load()
except OSError:
pass
+1075 -394
View File
File diff suppressed because it is too large. Load diff
+286 -58
View File
@@ -44,6 +44,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_agent # noqa: E402
import frame_assistant # noqa: E402
import frame_android # noqa: E402
import frame_android_import # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
from apk_sources import fdroid as apk_fdroid # noqa: E402
import frame_apk_versions # noqa: E402
@@ -116,6 +117,12 @@ def route(alias, host_opts):
moved = alias != FRAME
if moved:
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
# Invalidate launches before publishing the new route. send/stop share
# a writer lock, so an old process cannot receive events after this.
for name in ("_input", "_touch"):
agent = globals().get(name)
if agent:
agent.stop()
FRAME = frame_android.FRAME = alias
HOST_OPTS = list(host_opts)
MUX[:] = [*MUX_BASE, *HOST_OPTS]
@@ -125,9 +132,6 @@ def route(alias, host_opts):
mv = globals().get("macview")
if mv:
mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too
agent = globals().get("_input")
if moved and agent:
agent.stop() # typing and pointing mustn't go on reaching the headset switched away from
LINK = None # the connector (frame_link.Link); None on the Frame itself
@@ -161,13 +165,42 @@ def busy_thread(fn, *args):
with _work_lock:
_work[0] += 1
released = False
def release():
nonlocal released
with _work_lock:
if not released:
released = True
_work[0] -= 1
def run():
try:
fn(*args)
finally:
with _work_lock:
_work[0] -= 1
return threading.Thread(target=run, daemon=True)
release()
try:
worker = threading.Thread(target=run, daemon=True)
except Exception:
release()
raise
start = worker.start
def start_counted():
try:
start()
except Exception:
release()
raise
worker.start = start_counted
return worker
def agent_target():
"""Stable identity and reviewable name for a human-approved agent action."""
device = LINK.active_device() if LINK else {"id": "local" if LOCAL else FRAME,
"name": "This headset" if LOCAL else FRAME}
return {"id": device["id"], "name": device["name"]}
APPID = re.compile(r"^\d{1,10}$")
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
@@ -256,18 +289,13 @@ _backfill = {"running": False, "last": 0.0}
_backfill_lock = threading.Lock()
def backfill_art(apps=(), titles=()):
def backfill_art(apps=(), titles=(), meant=None):
"""Apply art Frame Control couldn't at install time (Steam wasn't running), once Steam is up.
Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or
names the user may have customised. Runs in the background, at most once every five minutes."""
pkgs = [a["package"] for a in apps if a.get("art_pending")]
gids = [t["id"] for t in titles if t.get("art_pending")]
with _backfill_lock:
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
return False
_backfill.update(running=True, last=time.time())
def run():
try:
frame_android.shortcut_tool("list") # Steam isn't up: try again on a later listing
@@ -282,7 +310,20 @@ def backfill_art(apps=(), titles=()):
finally:
with _backfill_lock:
_backfill["running"] = False
threading.Thread(target=run, daemon=True).start()
# Listing callers hold working() until this counted worker is registered.
# Other callers may supply the identity captured with their listing.
with working(meant):
with _backfill_lock:
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
return False
previous = dict(_backfill)
_backfill.update(running=True, last=time.time())
try:
busy_thread(run).start()
except Exception:
with _backfill_lock:
_backfill.update(previous)
raise
return True
@@ -415,22 +456,26 @@ def ensure_master():
LINK.ensure()
def ssh(remote, *, stdin=None, timeout=30, text=True):
route_gen = LINK.gen if LINK else None # which headset this command is for
def ssh(remote, *, stdin=None, timeout=30, text=True, route=None):
# Long-lived helper delivery/cleanup owns an immutable command prefix. It
# must not reconnect through the currently selected headset after a switch.
route_gen = LINK.gen if LINK and route is None else None
alias = route[-1] if route is not None else FRAME
try:
ensure_master()
if route is None:
ensure_master()
# Never let ssh inherit our stdin: under the app it's the pipe held open for
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
r = frame_host.run_ssh([*(route if route is not None else [*SSH, FRAME]), remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
except subprocess.TimeoutExpired:
raise frame_host.link_failure(Failure(f"Timed out talking to {FRAME}"))
raise frame_host.link_failure(Failure(f"Timed out talking to {alias}"))
if r.returncode != 0:
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
if r.returncode == 255 and repair_ssh_config(err):
return ssh(remote, stdin=stdin, timeout=timeout, text=text)
if r.returncode == 255 and LINK and unreachable(err):
return ssh(remote, stdin=stdin, timeout=timeout, text=text, route=route)
if r.returncode == 255 and LINK and route is None and unreachable(err):
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
@@ -964,6 +1009,18 @@ class InputAgent:
self.packages = kdeconnect_packages() if packages is None else packages
# generation counts stop()s; launching is the generation a launch is under way for.
self.status, self.launching, self.generation = {"state": "off"}, None, 0
# Each launch thread retains its owner through delivery, retry and late
# cleanup, even when a new generation starts concurrently.
self._launch_local = threading.local()
def delivery_ssh(self, command, **kwargs):
context = getattr(self._launch_local, "context", None)
if context:
with self.lock:
if self.generation != context[0]:
raise Failure("stopped")
return ssh(command, route=context[1], **kwargs)
return ssh(command, **kwargs)
def command(self, folder=""):
code = base64.b64encode(self.source.read_bytes()).decode()
@@ -986,14 +1043,14 @@ class InputAgent:
return "" # nothing to copy (the agent says so if it needed them)
if not force:
# Bytes, so Windows doesn't turn the stamp's line ends into CRLF.
have = ssh(f"{{ test -x /usr/lib/kdeconnectd || cmp -s - {KDECONNECT_HOME}/root/.frame-control-packages; }}"
have = self.delivery_ssh(f"{{ test -x /usr/lib/kdeconnectd || cmp -s - {KDECONNECT_HOME}/root/.frame-control-packages; }}"
" && echo yes || true", stdin=kdeconnect_stamp(self.packages).encode(), text=False, timeout=20)
if have.strip() == b"yes":
return ""
# A folder of its own: a cancelled start's agent may still be cleaning up another.
folder = f"{KDECONNECT_HOME}/incoming/{secrets.token_hex(8)}"
ssh(f"mkdir -p {folder}", timeout=20)
try:
self.delivery_ssh(f"mkdir -p {folder}", timeout=20)
for name, sha in self.packages:
path = KDECONNECT / "packages" / name
if not path.is_file() or file_sha256(path) != sha:
@@ -1001,7 +1058,7 @@ class InputAgent:
" (a build runs app/build/fetch-deps.js to add it)", 500)
report(f"Copying KDE Connect to the Frame ({name.rsplit('-', 3)[0]})")
quoted = shlex.quote(name)
ssh(f"cd {folder} && cat > {quoted}.part && mv {quoted}.part {quoted}",
self.delivery_ssh(f"cd {folder} && cat > {quoted}.part && mv {quoted}.part {quoted}",
stdin=path.read_bytes(), text=False, timeout=600)
except (Failure, OSError):
self.discard(folder) # a partial copy is no use to anyone
@@ -1013,18 +1070,35 @@ class InputAgent:
folder = folder.removeprefix("~/")
if folder.startswith(f"{KDECONNECT_HOME}/incoming/") and not LOCAL:
try:
ssh(f"rm -rf {folder}", timeout=20)
context = getattr(self._launch_local, "context", None)
ssh(f"rm -rf {folder}", timeout=20, **({"route": context[1]} if context else {}))
except (Failure, OSError):
pass # never let tidying up get in the way of reporting and retrying
def start(self):
with self.lock:
with _route_lock, self.lock:
if self.launching == self.generation or (self.proc and self.proc.poll() is None):
return
# (A launch from before a stop() may still be finishing; it ends itself.)
self.launching, self.status = self.generation, {"state": "starting"}
generation = self.generation
threading.Thread(target=self._launch, args=(generation,), daemon=True).start()
route = tuple([*SSH, FRAME])
def run():
self._launch_local.context = (generation, route)
try:
self._launch(generation)
finally:
del self._launch_local.context
try:
threading.Thread(target=run, daemon=True).start()
except Exception:
with self.lock:
if self.launching == generation:
self.launching = None
if self.generation == generation:
self.status = {"state": "error", "message": "Couldn't start the input worker"}
raise
def _launch(self, generation, force=False):
try:
@@ -1037,22 +1111,43 @@ class InputAgent:
self.status = {"state": "error", "message": f"Couldn't start the keyboard and trackpad: {e}"}
def _launch_once(self, generation, force):
previous = getattr(self._launch_local, "context", None)
if previous is None: # direct synchronous callers, including offline probes
with _route_lock, self.lock:
self._launch_local.context = (generation, tuple([*SSH, FRAME]))
try:
self._launch_routed(generation, force, self._launch_local.context[1])
finally:
if previous is None:
del self._launch_local.context
def _launch_routed(self, generation, force, route):
def report(message):
with self.lock:
if self.generation == generation:
self.status = {"state": "installing", "message": message}
folder = ""
errors = None
try:
errors = tempfile.TemporaryFile()
with self.lock:
if self.generation != generation:
raise Failure("stopped")
ensure_master()
with self.lock:
if self.generation != generation:
raise Failure("stopped")
folder = self.deliver(report, force)
with self.lock:
stopped = self.generation != generation
command = [*route, self.command(folder)] if not stopped else None
if stopped: # turned off while copying
raise Failure("stopped")
proc = subprocess.Popen([*SSH, FRAME, self.command(folder)], stdin=subprocess.PIPE,
proc = subprocess.Popen(command, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=errors)
except (Failure, OSError) as e:
if errors is not None:
errors.close()
self.discard(folder) # no agent will take the copy over
message = str(e)
friendly = unreachable(message)
@@ -1071,8 +1166,11 @@ class InputAgent:
if not stale:
self.proc = proc
if stale: # turned off meanwhile
proc.terminate()
wanted, heard = self._watch(proc, errors, retry=not force)
self.close(proc)
try:
wanted, heard = self._watch(proc, errors, retry=not force)
finally:
errors.close()
if not heard:
# The agent never started (or was stopped first), so it can't tidy the copy up.
self.discard(folder)
@@ -1126,29 +1224,35 @@ class InputAgent:
Returns the state, with "sent" saying whether the events went; if not,
the page keeps them and sends them again once the state is "ready".
"""
with self.lock:
proc, ready = self.proc, self.status.get("state") == "ready"
sent = False
if not (proc and proc.poll() is None):
self.start()
elif ready and events:
try:
# One writer at a time: two devices sending at once mustn't tear a line.
with self.write_lock:
with self.write_lock:
with self.lock:
proc, ready = self.proc, self.status.get("state") == "ready"
sent = False
start = not (proc and proc.poll() is None)
if not start and ready and events:
try:
proc.stdin.write((json.dumps(events) + "\n").encode())
proc.stdin.flush()
sent = True
except (BrokenPipeError, OSError, ValueError):
pass # _watch reports how it ended
sent = True
except (BrokenPipeError, OSError, ValueError):
pass # _watch reports how it ended
# start() captures the route under _route_lock. Never acquire that lock
# under write_lock: route() takes them in the opposite order to stop us.
if start:
self.start()
with self.lock:
return {**self.status, "sent": sent}
def stop(self):
with self.lock:
proc, self.proc, self.status = self.proc, None, {"state": "off"}
self.generation += 1
if proc and proc.poll() is None:
proc.terminate()
with self.write_lock:
with self.lock:
proc, self.proc, self.status = self.proc, None, {"state": "off"}
self.generation += 1
if proc and proc.poll() is None:
self.close(proc)
def close(self, proc):
proc.terminate()
_input = InputAgent()
@@ -1265,6 +1369,14 @@ class TouchAgent(InputAgent):
def __init__(self):
super().__init__(source=HERE / "frame_touch.py", packages=[])
def close(self, proc):
# EOF lets the helper release held keys/buttons in finally, including
# when a start was invalidated while Popen was still returning.
try:
proc.stdin.close()
except (BrokenPipeError, OSError, ValueError):
proc.terminate()
def deliver(self, report, force=False):
return ""
@@ -1597,6 +1709,93 @@ def apk_installed(info, meta, error, seconds):
frame_android.install_hooks.append(apk_installed)
# ---- Local Android backups (one APK and its OBB game data) ---------------
_android_backups = {}
_android_backups_lock = threading.Lock()
ANDROID_BACKUP_TMP = 'frame-android-backup-'
def _discard_android_backup(entry):
for folder in entry['dirs']:
shutil.rmtree(folder, ignore_errors=True)
def _android_backup(token=None):
now = time.time()
with _android_backups_lock:
stale = [_android_backups.pop(t) for t, e in list(_android_backups.items())
if now - e['time'] > 3600]
entry = _android_backups.get(token)
for old in stale:
_discard_android_backup(old)
if not entry:
raise Failure('This import expired. Choose the backup files again.', 400)
device = LINK.active_device()['id'] if LINK else None
if entry['device'] != device:
raise Failure('The headset changed. Choose the backup files again for this Frame.', 409)
entry['time'] = now
return entry
def android_backup(body):
action = body.get('action')
if action == 'begin' or (action == 'inspect' and 'paths' in body):
paths = body.get('paths', [])
if not isinstance(paths, list) or len(paths) > 100 or any(
not isinstance(p, str) or not os.path.isabs(p) for p in paths):
raise Failure('Choose local Android backup files.', 400)
# A dropped PC-game folder continues through the existing title importer.
if body.get('probe') and len(paths) == 1 and Path(paths[0]).is_dir():
if not any(p.suffix.lower() in ('.apk', '.obb') for p in Path(paths[0]).iterdir()):
return {'android': False}
token = secrets.token_hex(12)
entry = {'paths': paths, 'dirs': [], 'plan': None, 'time': time.time(),
'device': LINK.active_device()['id'] if LINK else None}
with _android_backups_lock:
_android_backups[token] = entry
_android_backup(token) # also expire abandoned imports
if action == 'begin':
return {'token': token}
else:
token = str(body.get('token') or '')
entry = _android_backup(token)
if action == 'inspect':
try:
entry['plan'] = frame_android_import.inspect(entry['paths'], discover=not entry['dirs'])
except (frame_android.FrameError, OSError) as error:
with _android_backups_lock:
_android_backups.pop(token, None)
_discard_android_backup(entry)
raise Failure(str(error), 400, getattr(error, 'apk', None))
return {'android': True, 'token': token, 'plan': frame_android_import.public(entry['plan'])}
if action not in ('install', 'discard'):
raise Failure('Unknown Android backup action.', 400)
if action == 'install' and not entry['plan']:
raise Failure('Read the backup before importing it.', 400)
display = body.get('display', 'auto')
if display not in ('auto', 'flat', 'vr'):
raise Failure('Invalid Android display mode.', 400)
with _android_backups_lock:
if _android_backups.pop(token, None) is not entry:
raise Failure('This import was already started or cancelled. Choose the files again.', 409)
if action == 'discard':
_discard_android_backup(entry)
return {'message': 'Import cancelled'}
def work(report):
try:
return frame_android_import.install(entry['plan'], display, report)
finally:
_discard_android_backup(entry)
try:
ensure_master()
return start_job('Import ' + entry['plan']['checked']['label'], work, progress=True)
except BaseException:
_discard_android_backup(entry)
raise
# ---- Sideloaded titles (Linux/Windows builds as Steam Devkit Games) --------
#
# Installing is two steps: inspect (a dropped file is uploaded and a zip
@@ -2212,7 +2411,7 @@ def _pid_alive(pid):
def _tmp_places():
"""Where each kind of staging folder goes, and its name before the server's PID."""
tmp = Path(tempfile.gettempdir())
return [(tmp, WEB_TMP_PREFIX), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX),
return [(tmp, WEB_TMP_PREFIX), (tmp, ANDROID_BACKUP_TMP), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX),
(tmp, frame_agent.TMP_PREFIX), (frame_host.cache_dir("apk-sources"), apk_fdroid.TMP_PREFIX)]
@@ -2502,6 +2701,7 @@ def source_manage(body):
POST = {
"/api/android/backup": android_backup,
"/api/vr": vr,
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
@@ -2729,9 +2929,10 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/apk-versions":
self.send_json(apk_versions(url.query))
elif path == "/api/android":
ensure_master()
apps = frame_android.list_apps()
backfill_art(apps=apps)
with working():
ensure_master()
apps = frame_android.list_apps()
backfill_art(apps=apps)
try: # store artwork for the library's cards, from saved indexes only
art = apk_search.artwork_for(apps)
except Exception:
@@ -2743,9 +2944,10 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/meta":
self.send_json(meta_status(url.query))
elif path == "/api/titles":
ensure_master()
titles_list = frame_titles.list_titles()
backfill_art(titles=titles_list)
with working():
ensure_master()
titles_list = frame_titles.list_titles()
backfill_art(titles=titles_list)
self.send_json({"titles": titles_list})
elif path == "/api/titles/job":
self.send_json(title_job(url.query))
@@ -2753,10 +2955,19 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"notices": licenses()})
elif path == "/api/panels":
self.send_json(panels())
elif path == "/api/touch":
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
elif path == "/api/input":
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
elif path in ("/api/touch", "/api/input"):
agent = _touch if path == "/api/touch" else _input
if parse_qs(url.query).get("start") == ["1"]:
# Validate and reserve before start() captures its immutable
# route and schedules a worker. Status-only polls stay free.
meant = self.headers.get("X-Frame-Device")
if LINK and not meant:
raise Failure("Control start needs the intended X-Frame-Device headset", 400)
with working(meant):
result = agent.send([])
else:
result = dict(agent.status)
self.send_json(result)
elif path == "/api/job":
self.send_json(job_status(url.query))
elif path == "/api/queue":
@@ -2948,7 +3159,14 @@ class Handler(BaseHTTPRequestHandler):
raise Failure("empty or too-large upload", 400)
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
raise Failure("APK install needs a .apk file", 400)
tmp = Path(tempfile.mkdtemp(prefix="frame-ui-"))
backup = None
if mode == 'android-backup':
backup = _android_backup(self.headers.get('X-Android-Backup'))
if (not name.lower().endswith(('.apk', '.obb')) or '/' in name or '\\' in name
or len(backup['paths']) >= 100 or any(Path(p).name == name for p in backup['paths'])):
raise Failure('Choose one APK and its game data, with no duplicate filenames.', 400)
prefix = f'{ANDROID_BACKUP_TMP}{os.getpid()}-' if backup is not None else 'frame-ui-'
tmp = Path(tempfile.mkdtemp(prefix=prefix))
keep = False
try:
dest = tmp / name
@@ -2960,6 +3178,16 @@ class Handler(BaseHTTPRequestHandler):
raise Failure("upload interrupted", 400)
f.write(chunk)
remaining -= len(chunk)
if backup is not None:
with _android_backups_lock:
if _android_backups.get(self.headers.get('X-Android-Backup')) is not backup:
raise Failure('This import was cancelled. Choose the backup files again.', 400)
backup['paths'].append(str(dest))
backup['dirs'].append(str(tmp))
backup['plan'] = None
backup['time'] = time.time()
keep = True
return {'message': f'Read {name}', 'token': self.headers.get('X-Android-Backup')}
if mode == "media":
return push_media(dest)
if mode == "apkinfo":
+86 -17
View File
@@ -29,6 +29,7 @@
min-height: 52px; padding: 0 20px; cursor: pointer; }
button:hover { background: #ffffff1c; }
button:disabled { opacity: .45; cursor: default; }
:focus-visible { outline: 3px solid var(--link); outline-offset: 3px; }
button.primary { background: var(--action); border: 0; font-weight: 600; color: #fff; }
button.play { background: var(--play); border: 0; font-weight: 600; color: #fff; }
button.danger { color: var(--bad); }
@@ -59,6 +60,9 @@
.heading span { color: var(--dim); font-size: 16px; }
.heading .note { margin-left: auto; font-size: 14px; color: var(--dim); max-width: 520px; text-align: right; }
.content { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
.installed-status { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 16px;
border: 1px solid var(--line); border-radius: 12px; background: #ffffff0a; font-size: 16px; }
.installed-status button { flex: none; }
.grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 14px; }
.grid.one-row { grid-template-rows: minmax(0, 1fr); }
.card { display: flex; flex-direction: column; padding: 0; overflow: hidden; text-align: left; min-height: 0; border-radius: 14px;
@@ -106,7 +110,9 @@
.skeleton { border-radius: 14px; background: linear-gradient(100deg, #1b2938 30%, #24364a 50%, #1b2938 70%); background-size: 300% 100%; animation: shimmer 1.4s infinite; }
@keyframes shimmer { to { background-position: -150% 0; } }
/* Detail sheet and keyboard sit over everything. */
.overlay { position: fixed; inset: 0; background: #000b; display: grid; place-items: center; z-index: 10; }
.overlay { position: fixed; inset: 0; background: transparent; color: inherit; display: grid; place-items: center; z-index: 10;
margin: 0; border: 0; padding: 0; width: 100%; height: 100%; max-width: none; max-height: none; }
.overlay::backdrop { background: #000b; }
.sheet { background: #16212e; border: 1px solid var(--line); border-radius: 22px; width: 1000px; max-height: 740px; overflow: hidden;
display: flex; flex-direction: column; box-shadow: 0 30px 80px #000c; }
.sheet > .art { flex: 0 0 250px; }
@@ -161,7 +167,7 @@
</main>
</div>
<div class="overlay" id="detail" hidden><div class="sheet" id="sheet"></div></div>
<dialog class="overlay" id="detail" aria-labelledby="detailTitle" hidden><div class="sheet" id="sheet"></div></dialog>
<div class="kbd" id="kbd" hidden></div>
<div class="toast" id="toast" hidden></div>
@@ -189,7 +195,8 @@ const FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Wo
// installedFrom: offer key -> package it installed as (GitHub listings have no package);
// needsData: installed packages still waiting for their game data (any view, while installed)
const st = { view: "", q: "", phone: false, page: 0, apps: [], installed: new Map(), jobs: new Map(), installedFrom: new Map(),
needsData: new Set(), request: 0, loading: false, retry: 0 };
needsData: new Set(), installedState: "unknown", installedError: "", installedRequest: 0,
request: 0, loading: false, retry: 0 };
async function api(path, body) {
const opts = body === undefined ? { headers: { "X-Frame-UI": UI_KEY } } : {
@@ -231,11 +238,23 @@ const text = s => String(s || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, "
// ---- Data ----
async function loadInstalled() {
const request = ++st.installedRequest;
st.installedState = "loading"; st.installedError = "";
$("instN").textContent = "?";
render(); if (typeof drawDetail === "function") drawDetail();
try {
st.installed = new Map((await api("/api/android")).apps.map(a => [a.package, a]));
const result = await api("/api/android");
if (request !== st.installedRequest) return;
if (!Array.isArray(result.apps)) throw new Error("Invalid installed-app response");
st.installed = new Map(result.apps.map(a => [a.package, a]));
st.installedState = "ready";
for (const pkg of st.needsData) if (!st.installed.has(pkg)) { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); } // removed
} catch (e) { toast(`Couldn't list installed apps: ${e.message}`, true); }
$("instN").textContent = st.installed.size || "";
} catch (e) {
if (request !== st.installedRequest) return;
st.installedState = "error"; st.installedError = e.message;
}
$("instN").textContent = st.installedState === "ready" ? st.installed.size || "" : "?";
render(); if (typeof drawDetail === "function") drawDetail();
}
async function search(quiet) {
const request = ++st.request;
@@ -276,7 +295,7 @@ function list() {
}
function card(app, i) {
const o = app.offers[0], m = installedOf(app), job = st.jobs.get(offerKey(app));
const state = m ? `<span class="tag">${m.running ? "Running" : "Installed"}</span>`
const state = m ? `<span class="tag">${st.installedState !== "ready" ? "Installed (last read)" : m.running ? "Running" : "Installed"}</span>`
: job && !job.done ? `<span class="tag">Installing…</span>`
: FIT[o.verdict?.tone] ? `<span class="fit ${o.verdict.tone}">${FIT[o.verdict.tone]}</span>` : "";
const kind = o.phone_only ? "Phone app" : catLabel(app.category || o.category) || (m ? (o.vr ? "VR app" : "Android app") : "Android app");
@@ -289,8 +308,15 @@ function render(error) {
$("title").textContent = st.q ? `Results for “${st.q}”` : st.view === "installed" ? "Installed" : st.view ? catLabel(st.view) : "Home";
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.view === st.view)));
const content = $("content");
const cached = st.installed.size > 0;
const installedNotice = st.installedState === "ready" ? "" : `<div class="installed-status" role="status"><span>${
st.installedState === "error" ? `Couldn't read installed apps: ${esc(st.installedError)}`
: st.installedState === "loading" ? "Reading installed apps…" : "Installed apps haven't been read yet."
}${cached ? " Showing last known apps; their status may be out of date." : " Installed status is unknown."}</span>${
st.installedState === "loading" ? "" : '<button data-retry-installed>Retry installed apps</button>'
}</div>`;
if (st.loading) {
content.innerHTML = `<div class="grid">${Array.from({ length: PER_PAGE }, () => '<div class="skeleton"></div>').join("")}</div>`;
content.innerHTML = installedNotice + `<div class="grid">${Array.from({ length: PER_PAGE }, () => '<div class="skeleton"></div>').join("")}</div>`;
$("count").textContent = ""; $("page").textContent = ""; $("prev").disabled = $("next").disabled = true; return;
}
// Home: a featured VR app and four picks, then pages of one app from each category in turn.
@@ -310,13 +336,15 @@ function render(error) {
st.page = Math.min(st.page, pages - 1);
const start = st.page === 0 ? 0 : first + (st.page - 1) * PER_PAGE;
const shown = items.slice(start, start + (st.page === 0 ? first : PER_PAGE));
$("count").textContent = home ? "" : `${items.length.toLocaleString()} app${items.length === 1 ? "" : "s"}`;
$("count").textContent = home || (st.view === "installed" && !cached && st.installedState !== "ready") ? ""
: `${items.length.toLocaleString()} app${items.length === 1 ? "" : "s"}${st.view === "installed" && st.installedState !== "ready" ? " (last read)" : ""}`;
$("page").textContent = `Page ${st.page + 1} of ${pages}`;
$("prev").disabled = st.page === 0; $("next").disabled = st.page >= pages - 1;
const empty = error ? `<div class="empty"><h2>No connection</h2>${esc(error)}</div>`
: st.view === "installed" && st.installedState !== "ready" && !cached ? `<div class="empty"><h2>${st.installedState === "loading" ? "Reading installed apps…" : "Installed apps unavailable"}</h2>Read the installed list before checking what's on your Frame.</div>`
: st.view === "installed" && !st.q ? `<div class="empty"><h2>Nothing installed yet</h2>Pick an app in the store and press Install.<button class="primary" data-view="">Browse the store</button></div>`
: `<div class="empty"><h2>No apps here</h2>Try another category${st.phone ? "" : ", or show phone-only apps"}.</div>`;
let html = "";
let html = installedNotice;
if (st.page === 0 && st.home?.featured) {
const f = st.home.featured, o = f.offers[0];
html += `<button class="hero" data-feature="1" aria-label="${esc(f.name)}">${art(f.name, o.artwork)}<span class="copy">
@@ -333,10 +361,16 @@ function turn(by) {
// ---- Detail sheet ----
let current = null; // {app, installed}
let detailReturn = null, removeConfirmed = false;
const detailIdentity = app => app.package || app.installed?.package || offerKey(app);
const ver = v => String(v || "").replace(/^v/i, "");
const size = n => n >= 1e9 ? (n / 1e9).toFixed(1) + " GB" : n >= 1e6 ? Math.round(n / 1e6) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
function openDetail(app) {
function openDetail(app, opener = document.activeElement) {
detailReturn = { node: opener, identity: detailIdentity(app) }; removeConfirmed = false;
current = app; drawDetail(); $("detail").hidden = false;
$("detail").append($("toast")); // Notifications stay above the native modal's backdrop.
if (!$("detail").open) $("detail").showModal();
$("detailTitle").focus();
const o = app.offers[0];
if (o.source && o.id && !app.detailed) { // the full entry has all the screenshots
api(`/api/sources/details?source=${encodeURIComponent(o.source)}&id=${encodeURIComponent(o.id)}`)
@@ -345,6 +379,8 @@ function openDetail(app) {
}
function drawDetail() {
const app = current; if (!app) return;
const focused = document.activeElement, hadFocus = $("sheet").contains(focused);
const focusedAction = hadFocus && focused.dataset.act;
const o = app.offers[0], m = installedOf(app), key = offerKey(app), job = st.jobs.get(key), data = m && st.jobs.get("data:" + m.package);
if (app.installed && !m) return closeDetail(); // removed
const shots = (o.artwork?.screenshots || []).filter(valid);
@@ -363,27 +399,57 @@ function drawDetail() {
acts = progress(job);
} else if (m) {
acts = gameData + (m.running ? `<button data-act="stop">■ Stop</button>` : `<button class="play" data-act="launch">▶ Open</button>`)
+ `<button class="danger" data-act="remove">Remove</button>`;
+ `<button class="danger" data-act="remove"${removeConfirmed ? ' data-sure="1"' : ""}>${removeConfirmed ? "Remove it and its data?" : "Remove"}</button>`;
} else if (!o.source) {
acts = "";
} else if (o.downloadable === false) {
acts = `<button disabled>Get it from the developer's page</button>`;
} else if (o.verdict?.tone === "blocked") {
acts = `<button disabled>Won't run on the Frame</button>`;
} else if (st.installedState !== "ready") {
acts = `<button disabled>${st.installedState === "loading" ? "Checking installed apps…" : "Read installed apps before installing"}</button>`;
} else {
acts = `<button class="primary" data-act="install">Install</button>`;
}
const blurb = o.description || o.summary || (app.installed ? "" : app.summary); // an Installed card's summary is its version, already in meta
const failed = data?.error || job?.error;
const note = failed ? `<div class="meta" style="color:var(--bad)">${esc(failed)}</div>` : "";
const note = (failed ? `<div class="meta" style="color:var(--bad)">${esc(failed)}</div>` : "")
+ (st.installedState === "ready" ? "" : `<div class="meta" role="status">${m ? "Showing last known installed status; it may be out of date." : "Installed status is unknown."}${st.installedState === "loading" ? " Reading installed apps…" : ' <button data-retry-installed>Retry installed apps</button>'}</div>`);
$("sheet").innerHTML = `${top}<div class="inner">
<div class="top">${icon(app.name, o.artwork)}<div><h2>${esc(app.name)}</h2><div class="meta">${esc(meta)}</div></div>
<div class="top">${icon(app.name, o.artwork)}<div><h2 id="detailTitle" tabindex="-1">${esc(app.name)}</h2><div class="meta">${esc(meta)}</div></div>
${o.vr ? '<span class="vr" style="margin-left:auto">VR</span>' : ""}</div>
${blurb ? `<div class="text">${esc(text(blurb))}</div>` : ""}
${o.verdict ? `<div class="why"><b>${esc(o.verdict.label)}</b>${reasons.length ? `<ul>${reasons.slice(0, 3).map(r => `<li>${esc(r)}</li>`).join("")}</ul>` : ""}</div>` : ""}
${note}<div class="acts">${acts}<button class="close" data-act="close">Close</button></div></div>`;
if (hadFocus) {
const replacement = focusedAction ? $("sheet").querySelector(`[data-act="${focusedAction}"]`)
: focused.matches("[data-retry-installed]") ? $("sheet").querySelector("[data-retry-installed]") : $("detailTitle");
(replacement && !replacement.disabled ? replacement : $("sheet").querySelector('[data-act="close"]')).focus();
}
}
function closeDetail() { current = null; $("detail").hidden = true; }
function closeDetail() {
if (!current && !$("detail").open) return;
const origin = detailReturn;
current = null; detailReturn = null; removeConfirmed = false;
if ($("detail").open) $("detail").close();
$("detail").hidden = true;
document.body.append($("toast"));
if (origin?.node?.isConnected) return origin.node.focus();
// Polling can replace the background cards too. Find the same app in the current page.
const items = list();
const card = [...document.querySelectorAll(".card[data-i]")].find(b => detailIdentity(items[+b.dataset.i]) === origin?.identity);
const feature = st.home?.featured && detailIdentity(st.home.featured) === origin?.identity && document.querySelector("[data-feature]");
(card || feature || $("q")).focus();
}
$("detail").onclose = () => { if (!$("detail").open) closeDetail(); };
$("detail").oncancel = e => { e.preventDefault(); closeDetail(); };
$("detail").onkeydown = e => {
if (e.key !== "Tab") return;
const buttons = [...$("sheet").querySelectorAll("button:not(:disabled)")];
const first = buttons[0], last = buttons[buttons.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === $("detailTitle"))) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
async function runJob(key, start) {
const job = { stage: "Downloading", percent: null, done: false };
@@ -429,6 +495,7 @@ $("sheet").onclick = async e => {
}
const pkg = m?.package; if (!pkg) return;
if (act === "remove" && b.dataset.sure !== "1") {
removeConfirmed = true;
b.dataset.sure = "1"; b.textContent = "Remove it and its data?"; return; // no confirm(): a separate window the laser has to find
}
b.disabled = true;
@@ -446,15 +513,17 @@ $("detail").onclick = e => { if (e.target === $("detail")) closeDetail(); };
// ---- Navigation ----
$("cats").innerHTML = CATS.map(([id, label, path]) => `<button data-view="${id}" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${path}</svg>${esc(label)}</button>`).join("");
document.addEventListener("click", e => {
if (e.target.closest("[data-retry-installed]")) { loadInstalled(); return; }
const v = e.target.closest("[data-view]");
if (v && !e.target.closest("#sheet")) {
st.view = v.dataset.view; st.q = ""; $("q").value = "";
if (st.view === "installed") loadInstalled().then(() => search()); else search();
return;
}
if (e.target.closest("[data-feature]")) return openDetail(st.home.featured);
const featured = e.target.closest("[data-feature]");
if (featured) return openDetail(st.home.featured, featured);
const c = e.target.closest(".card[data-i]");
if (c) openDetail(list()[+c.dataset.i]);
if (c) openDetail(list()[+c.dataset.i], c);
});
$("phone").onchange = e => { st.phone = e.target.checked; search(); };
$("prev").onclick = () => turn(-1);