mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-20 21:01:28 +02:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce1800dcf | ||
|
|
3f377d8f56 | ||
|
|
9060142f5d | ||
|
|
172851971c | ||
|
|
48e9ce58ee | ||
|
|
9a5d697579 | ||
|
|
71ef6a57c7 | ||
|
|
be9e7b5cda | ||
|
|
5c44d56f53 | ||
|
|
fed2249776 | ||
|
|
1e44a74165 | ||
|
|
2a3e52f708 | ||
|
|
b2485500e1 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
@@ -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),
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "'\\''") + "'"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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; },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
@@ -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',
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+286
-58
@@ -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
@@ -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(/ /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);
|
||||
|
||||
Reference in new issue
Block a user