Compare commits

...
Author SHA1 Message Date
spoopyghosty0 f5368d73ea FramePort 1.1.1
Config files written by an older FramePort are no longer kept as the user's own (their backup brought them
back when a recipe dropped them).
2026-10-14 11:14:12 -04:00
spoopyghosty0 bd92c25a09 Site: contributor data refreshed (1.1.1) 2026-10-14 11:14:10 -04:00
spoopyghosty0 05024e02bf Docs: games list regenerated with the new catalog entries 2026-10-14 10:58:25 -04:00
spoopyghosty0 56e122d85f Fix release blockers in the update-skip and upload changes (agent v88)
- Check the Steam shortcut on every update, also when nothing else changed: a connection lost during
  "Add to Steam" or Steam putting its old shortcuts.vdf back left games without a library entry. The
  agent restarts Steam only when the entry really changes.
- Artwork updates for installs without a recorded artwork manifest start from a clean folder again
  (an old portrait.jpg next to a new portrait.png was picked at random for the Steam grid).
- OpenSSH uploads only rename .part files that are already complete (sftp's reput refuses them, which
  sent every resume to the Paramiko fallback).
- frame.tbxr_vendor revision 2: GL4ES ports get "Update on Frame" for the #191 draw fix.
- Configuration-only updates send the library's current Steam tags.
- finalize drops fingerprints and the artwork record an older app didn't send.
- set_settings: malformed numbers are refused before anything is written; int settings keep a
  fractional value.
- Display diagnostics use Windows PowerShell 5.1 without pwsh's PSModulePath; window diagnostics and
  the instance owner file can't stop the GUI from starting.
2026-10-14 10:58:24 -04:00
spoopyghosty0 3fcb901f8c Prefer OpenSSH for bulk uploads with resumable Paramiko fallback 2026-10-14 10:28:55 -04:00
spoopyghosty0 faea99e837 Skip unnecessary game update work and add bounded parallel uploads 2026-10-14 10:09:24 -04:00
spoopyghosty0 05c4f5603b Merge pull request #277 from spoopyghosty0/catalog/issue-266
catalog: com.arkio.arkio
2026-10-14 09:43:35 -04:00
spoopyghosty0 60fa213862 Refresh catalog PR #277 against main 2026-10-14 09:43:30 -04:00
spoopyghosty0 b67c403447 Merge pull request #276 from spoopyghosty0/catalog/issue-262
catalog: com.NiVision.RacketPinball
2026-10-14 09:43:17 -04:00
github-actions[bot] f251129c11 catalog: com.arkio.arkio (from #266) 2026-10-13 22:36:56 +00:00
29 changed files with 2448 additions and 220 deletions

No files matched your search

+161 -25
View File
@@ -44,7 +44,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 86
AGENT_VERSION = 88
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -1526,10 +1526,17 @@ def is_overlay(dep):
return bool(isinstance(dep, dict) and dep.get("overlay"))
def deployment_summary(dep):
"""Keep routine connection/monitor samples small; detailed data manifests are fetched only for an update."""
return {key: value for key, value in dep.items()
if key not in ("data_manifest", "configuration", "managed_config_files")}
def cmd_list_installed(args):
games = []
table = mounts()
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, "*/deployment.json"))):
selected = check_pkg(args["package"]) if args.get("package") else "*"
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, selected, "deployment.json"))):
try:
dep = json.load(open(dep_path))
except (OSError, ValueError):
@@ -1566,7 +1573,7 @@ def cmd_list_installed(args):
dep["apk_present"] = os.path.exists(apk)
dep["apk_size"] = os.path.getsize(apk) if dep["apk_present"] else 0
dep["last_play"] = last_play(dep["anchor"]) # agent v70: the PC triages a finished play session's log
games.append(dep)
games.append(dep if args.get("details") else deployment_summary(dep))
return {"games": games}
@@ -3828,7 +3835,7 @@ def cmd_finalize(args):
pkg = check_pkg(args["package"])
title = args["title"]
prep = cmd_prepare({"package": pkg, "title": title, "dest": args.get("dest")})
base, anchor, appid, incoming = prep["base"], prep["anchor"], prep["appid"], prep["incoming"]
base, anchor, incoming = prep["base"], prep["anchor"], prep["incoming"]
lepton = prep["lepton"]
if not lepton:
raise AgentError("Lepton is not installed (Developer Mode → install/launch 'Lepton Development' once)")
@@ -3849,6 +3856,10 @@ def cmd_finalize(args):
bad = [k for k, v in expected.items() if have.get(k) != v]
if bad:
raise AgentError(f"{len(bad)} data file(s) missing or incomplete, e.g. {bad[0]}")
if args.get("existing_only") and os.path.exists(new_apk):
raise AgentError("an interrupted APK upload is pending; finish that install before updating data")
if args.get("existing_only"):
verify_installed_apk(deployment(pkg), args.get("apk_sha256"))
if os.path.exists(new_apk):
if args.get("apk_sha256") and sha256_file(new_apk) != args["apk_sha256"]:
raise AgentError("uploaded APK checksum mismatch (transfer corrupted?)")
@@ -3859,16 +3870,67 @@ def cmd_finalize(args):
elif not os.path.exists(os.path.join(app, "game.apk")):
raise AgentError("no APK uploaded and none installed")
moved = 0
for root, _, files in os.walk(inc_obb):
for name in files:
if name.endswith(".part"):
continue
src = os.path.join(root, name)
dst = os.path.join(app, "obb", os.path.relpath(src, inc_obb))
os.makedirs(os.path.dirname(dst), exist_ok=True)
os.replace(src, dst)
moved += 1
shutil.rmtree(incoming, ignore_errors=True)
if not args.get("preserve_data"):
for root, _, files in os.walk(inc_obb):
for name in files:
if name.endswith(".part"):
continue
src = os.path.join(root, name)
rel = os.path.relpath(src, inc_obb)
if expected is not None and rel not in expected:
continue # an older interrupted upload is not part of this install
dst = os.path.join(app, "obb", rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
os.replace(src, dst)
moved += 1
shutil.rmtree(incoming, ignore_errors=True)
return configure_install({**args, "consume_artwork": args.get("consume_artwork", True)}, prep, moved)
def verify_installed_apk(dep, expected):
apk = os.path.join(dep["base"], "lepton-app", "game.apk")
if not expected or dep.get("sha256") != expected or not os.path.isfile(apk):
raise AgentError("installed APK is missing or changed; refresh and reinstall it")
st = os.stat(apk)
identity = {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
if identity != dep.get("apk_stat") and sha256_file(apk) != expected:
raise AgentError("installed APK content changed; reinstall it")
def cmd_configure(args):
"""Apply runtime configuration to an existing, matching APK without staging/replacing game files."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep or dep.get("kind", "quest") != "quest":
raise AgentError(f"{pkg} is not an installed Quest/Android app")
base, _ = new_base(args, pkg, dep) # validates a removable drive; never silently falls back
verify_installed_apk(dep, args.get("apk_sha256"))
if container_running(dep["appid"]):
raise AgentError("Close the game before updating its configuration")
lepton, _ = lepton_path()
if not lepton:
raise AgentError("Lepton is not installed")
return configure_install({**args, "title": dep["title"]},
{"base": base, "anchor": os.path.join(ANCHORS, pkg),
"appid": dep["appid"], "lepton": lepton}, 0)
def configure_install(args, prep, moved):
pkg, title = check_pkg(args["package"]), args["title"]
base, anchor, appid, lepton = prep["base"], prep["anchor"], prep["appid"], prep["lepton"]
app = os.path.join(base, "lepton-app")
previous = deployment(pkg) or {}
files_dir = data_files_dir(base, pkg)
for rel in set(args.get("files") or {}) | set(previous.get("managed_config_files") or {}):
target = os.path.realpath(os.path.join(files_dir, rel))
if not _inside(target, os.path.realpath(files_dir)) or target == os.path.realpath(files_dir):
raise AgentError(f"refusing to write outside the game's files dir: {rel}")
backup = os.path.join(files_dir, rel) + ".frameport-config-backup"
if os.path.islink(backup):
raise AgentError(f"refusing a linked configuration backup: {rel}")
for key, value in (args.get("settings") or {}).items():
if not re.fullmatch(r"[a-z_]+", key) or any(c in str(value) for c in "\r\n\x00"):
raise AgentError(f"bad setting {key}")
if "flatscreen" in args: # older clients don't send it: leave the marker as it is
set_flatscreen(app, args["flatscreen"])
# settings: settings.conf (read by the adapter via LEPTON_ENV_FRAMEBRIDGE_CONFIG) + framebridge.conf copy
@@ -3885,13 +3947,37 @@ def cmd_finalize(args):
f.write(text)
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
f.write(text)
managed = previous.get("managed_config_files") or {}
wanted = args.get("files") or {}
for rel, record in managed.items():
if rel in wanted:
continue
target = os.path.realpath(os.path.join(files_dir, rel))
if not _inside(target, os.path.realpath(files_dir)):
raise AgentError(f"invalid previously managed path: {rel}")
if os.path.isfile(target) and sha256_file(target) == record["sha256"]:
backup = target + ".frameport-config-backup"
if record.get("backup") and os.path.isfile(backup):
os.replace(backup, target)
else:
os.unlink(target)
new_managed = {}
for rel, content in (args.get("files") or {}).items():
target = os.path.normpath(os.path.join(files_dir, rel))
if not target.startswith(files_dir + os.sep):
raise AgentError(f"refusing to write outside the game's files dir: {rel}")
os.makedirs(os.path.dirname(target), exist_ok=True)
record = managed.get(rel)
backup = target + ".frameport-config-backup"
# no record: an existing file is the user's, unless it already has exactly this content (written by
# FramePort before agent v86 kept records; backing it up would bring it back when the recipe drops it)
had_original = bool(record.get("backup")) if record else os.path.isfile(target) and \
open(target, "rb").read() != content.encode()
if not record and had_original:
shutil.copy2(target, backup)
with open(target, "w") as f:
f.write(content)
new_managed[rel] = {"sha256": sha256_file(target), "backup": had_original}
models = install_controller_models(files_dir, str(settings.get("controller_models", 0)) not in ("0", "0.0"))
recipe = args.get("recipe") if isinstance(args.get("recipe"), dict) else {}
hw_video = HW_VIDEO_PATCH in (recipe.get("patches") or []) # the shared codec (install_video_codec)
@@ -3901,22 +3987,40 @@ def cmd_finalize(args):
config_sync = args.get("config_sync") if isinstance(args.get("config_sync"), dict) else {}
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad, bool(config_sync))
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
art_moved = False
if args.get("consume_artwork") and os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
shutil.move(art_in, os.path.join(anchor, "artwork"))
previous = deployment(pkg) or {}
dep = {"package": pkg, "appid": int(appid), "base": base, "title": title, "tags": args.get("tags") or [],
"apk": args.get("apk_name", "game.apk"),
"sha256": args.get("apk_sha256"), "recipe": args.get("recipe"), "installed_by": "frameport",
art_moved = True
dep = {**previous, "package": pkg, "appid": int(appid), "base": base, "title": title,
"tags": args.get("tags", previous.get("tags")) or [],
"apk": args.get("apk_name", previous.get("apk", "game.apk")),
"sha256": args.get("apk_sha256") or previous.get("sha256"),
"recipe": args.get("recipe"), "installed_by": "frameport",
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "config_sync": config_sync,
"agent_version": AGENT_VERSION, "time": time.time()}
if previous.get("eye_base"): # the runtime's eye size learned from an earlier start (device.config_sync)
dep["eye_base"] = previous["eye_base"]
with open(os.path.join(anchor, "deployment.json"), "w") as f:
json.dump(dep, f, indent=2)
dep["managed_config_files"] = new_managed
# An app before agent v86 records none of these: what it installed is unknown, so a newer PC mustn't skip work
# on the strength of an older record. Art the agent didn't replace keeps its record.
art_replaced = bool(args.get("consume_artwork")) and art_moved
for key in ("configuration", "configuration_fp", "apk_recipe_fp", "source_version", "artwork_manifest"):
if key in args:
dep[key] = args[key]
elif key != "artwork_manifest" or art_replaced:
dep.pop(key, None)
if args.get("obb_manifest") is not None:
dep["data_manifest"] = args["obb_manifest"]
if "data_manifest" in dep:
dep["data_fp"] = hashlib.sha256(json.dumps(dep["data_manifest"], sort_keys=True).encode()).hexdigest()[:32]
st = os.stat(os.path.join(app, "game.apk"))
dep["apk_size"] = st.st_size
dep["apk_stat"] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
synced = sync_game_config(pkg, dep)
write_deployment(pkg, dep)
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models,
"config_sync": synced}
"config_sync": synced, "deployment": deployment_summary(dep)}
# --------------------------------------------------------------- Steam Frame controller models (XR_FB_render_model)
@@ -5175,8 +5279,8 @@ def cmd_set_settings(args):
if k:
current[k] = v
for k, v in (args.get("settings") or {}).items():
if not re.fullmatch(r"[a-z_]+", k) or not re.fullmatch(r"-?[0-9.]+", str(v)):
raise AgentError(f"bad setting {k}={v}")
if not re.fullmatch(r"[a-z_]+", k) or not re.fullmatch(r"-?(?:[0-9]+\.?[0-9]*|\.[0-9]+)", str(v)):
raise AgentError(f"bad setting {k}={v}") # checked before anything is written
current[k] = str(v)
text = "".join(f"{k}={v}\n" for k, v in current.items())
with open(conf, "w") as f:
@@ -5186,7 +5290,19 @@ def cmd_set_settings(args):
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
f.write(text)
models = install_controller_models(files_dir, current.get("controller_models", "0") not in ("0", "0.0"))
return {"settings": current, "controller_models": models, "config_sync": sync_game_config(pkg, dep)}
synced = sync_game_config(pkg, dep)
if isinstance(dep.get("configuration"), dict):
applied = dep["configuration"]
settings = applied.get("settings") or {}
for key, value in (args.get("settings") or {}).items():
if key in settings:
number = float(value)
# keep the recorded type (the PC's fingerprint uses it); an int setting given 1.5 stays 1.5
settings[key] = number if isinstance(settings[key], float) or not number.is_integer() \
else int(number)
dep["configuration_fp"] = hashlib.sha256(json.dumps(applied, sort_keys=True).encode()).hexdigest()[:32]
write_deployment(pkg, dep)
return {"settings": current, "controller_models": models, "config_sync": synced}
TITLE_MAX = 128
@@ -5200,6 +5316,26 @@ def clean_title(title):
return text[:TITLE_MAX]
def cmd_record_artwork(args):
"""Record artwork only after verifying the files actually uploaded to the game's anchor."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
manifest = args.get("manifest") or {}
root = os.path.join(ANCHORS, pkg, "artwork")
for name, digest in manifest.items():
if (not re.fullmatch(r"(?:portrait|landscape|hero|logo|icon)\.[a-zA-Z0-9]+|\.icon-source", name)
or not re.fullmatch(r"[a-f0-9]{64}", str(digest))):
raise AgentError("invalid artwork manifest")
path = os.path.join(root, name)
if not os.path.isfile(path) or sha256_file(path) != digest:
raise AgentError(f"artwork missing or checksum mismatch: {name}")
dep["artwork_manifest"] = manifest
write_deployment(pkg, dep)
return {"verified": True}
def cmd_rename(args):
"""Rename an installed game (agent v77): deployment.json's title is what its Steam shortcut shows. The shortcut
keeps its appid (upsert_shortcut matches by Exe, deployment.json keeps the appid), so Play, its grid art, launch
+25
View File
@@ -0,0 +1,25 @@
package: com.arkio.arkio
title: Arkio
status: works
notes: The quest version allows for passthrough mode
tested_version: 2.3.1
engine: Unity
xr: OpenXR
overport_extra:
- patch_force_passthrough
frame:
- frame.meta_permissions
- frame.unity_text_input
device:
- device.text_input_window
adapter:
scene_emul: 1
verified:
date: '2026-10-13'
known_good_sha256: e17f9b2dc56cb49b4469d81f2e075ca5953f73152804ff1a00fba0931048e103
app: 1.1.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 85
issue: 266
source_hint: com.arkio.arkio
+6 -1
View File
@@ -10,6 +10,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Accounting+ | Quest | ✅ Works | |
| AgeOfJoy | Quest | ✅ Works | |
| AllInOneSports | Quest | ✅ Works | |
| Arkio | Quest | ✅ Works | |
| Asgard's Wrath 2 | Quest | ✅ Works | |
| Audica | Quest | ✅ Works | |
| BAM | Quest | ✅ Works | |
@@ -28,6 +29,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Carve Snowboarding | Quest | ✅ Works | |
| Caves | Quest | ✅ Works | |
| Clockwork | Quest | ✅ Works | |
| COMPOUND | Quest | ✅ Works | |
| Cook-Out | Quest | ✅ Works | |
| Cosmodread | Quest | ✅ Works | |
| Creed | Quest | ✅ Works | |
@@ -40,6 +42,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Down the Rabbit Hole | Quest | ✅ Works | |
| Drunkn Bar Fight | Quest | ✅ Works | |
| Drunkn Bar Fight 2 - Early Access | Quest | ✅ Works | |
| Eleven Table Tennis | Quest | ✅ Works | |
| Eleven: Table Tennis VR | PC VR | ✅ Works | |
| Espire 2 | Quest | ✅ Works | |
| FrenzyVR | Quest | ✅ Works | |
@@ -68,6 +71,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
| Metro Awakening | Quest | ✅ Works | |
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
| Moss: Book II | Quest | ✅ Works | |
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
| Nano | Quest | ✅ Works | |
| NEX Player | Quest | ✅ Works | |
@@ -81,6 +85,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| PowerWash Simulator VR | Quest | ✅ Works | |
| QuestCraft | Quest | ✅ Works | |
| Racket: Nx | Quest | ✅ Works | |
| RacketPinball | Quest | ✅ Works | |
| RC Pilot Trainer | Quest | ✅ Works | |
| Retronika | Quest | ✅ Works | |
| Retropolis | Quest | ✅ Works | |
@@ -89,6 +94,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Riven | Quest | ✅ Works | |
| Robo Recall | Quest | ✅ Works | |
| RUINSMAGUS | Quest | ✅ Works | |
| Shoot The Zombirds VR | Quest | ✅ Works | |
| Shores of Loci | Quest | ✅ Works | |
| SilentSlayer | Quest | ✅ Works | |
| Sniper Elite VR | Quest | ✅ Works | |
@@ -145,7 +151,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
| Vermillion | Quest | ⚠️ Works with issues | Runs. |
| WiiCompiled VR | Quest | ⚠️ Works with issues | To add a game: in FramePort's Files tab, upload your .wcgame file to this game's storage, folder Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR… |
| Eleven Table Tennis | Quest | ❌ Doesn't run | Stops at the splash screen. |
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
+11 -11
View File
@@ -30,7 +30,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Disable application space warp if used](#patch_disable_space_warp) | `patch_disable_space_warp` | Patches from OVRPort | 3 |
| [Fix minimal Android SDK](#patch_fix_min_android_sdk) | `patch_fix_min_android_sdk` | Patches from OVRPort | default |
| [Fix UE4 crash with Unity stub](#patch_fix_unreal_crash) | `patch_fix_unreal_crash` | Patches from OVRPort | default |
| [Force enable passthrough](#patch_force_passthrough) | `patch_force_passthrough` | Patches from OVRPort | 2 |
| [Force enable passthrough](#patch_force_passthrough) | `patch_force_passthrough` | Patches from OVRPort | 3 |
| [Generate OVRPort config](#patch_generate_config) | `patch_generate_config` | Patches from OVRPort | default |
| [Fix launcher icon entry](#patch_launcher_entry) | `patch_launcher_entry` | Patches from OVRPort | default |
| [Mark application to allow backup](#patch_mark_allow_backup) | `patch_mark_allow_backup` | Patches from OVRPort | default |
@@ -59,7 +59,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Launcher category (INFO → LAUNCHER)](#frame-launcher) | `frame.launcher` | Steam Frame patches | default |
| [GL wrapper: OpenGL ES depth formats (Minecraft)](#frame-ltw_depth) | `frame.ltw_depth` | Steam Frame patches | default |
| [Fix Lua startup crashes on Frame](#frame-luajit_pointers) | `frame.luajit_pointers` | Steam Frame patches | |
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 1 |
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 2 |
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 15 |
@@ -80,7 +80,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 10 |
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 24 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 72 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 73 |
| [Unity: share thread keys (games that run out of them)](#frame-unity_tls_key_pool) | `frame.unity_tls_key_pool` | Steam Frame patches | 1 |
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
@@ -143,7 +143,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 4 |
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 2 |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 3 |
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
| [Room width](#adapter-scene_width) | `adapter.scene_width` | Game settings | |
| [Eye-image snapshots (diagnostics)](#adapter-snapshot) | `adapter.snapshot` | Game settings | |
@@ -170,7 +170,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 72 |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 73 |
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
@@ -313,7 +313,7 @@ Lets mixed-reality games start even though they insist on a camera view of your
- `patch_force_passthrough`
- **Changes:** Changes the APK (during the OVRPort conversion).
- **Suggested (on):** Mixed-reality-only game (passthrough required, no guardian): force passthrough on.
- **Catalog games (2):** BAM, Demeter.
- **Catalog games (3):** Arkio, BAM, Demeter.
- **Suggested by launch-test signatures:**
- `passthrough-missing` (error): The game needs Meta passthrough (the camera view), which the Frame lacks; the adapter emulates it.
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
@@ -672,7 +672,7 @@ Declares Meta's permissions some mixed-reality games ask for, so they don't stop
- `frame.meta_permissions`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Mixed-reality game that needs the room model: its 'use spatial data' permission must be granted (for example Demeter).
- **Catalog games (1):** Demeter.
- **Catalog games (2):** Arkio, Demeter.
- **Source:** [patches/frame/manifest_fixes.py](../src/frameport/patches/frame/manifest_fixes.py)
> Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants them at install (for example the 'use spatial data' permission of mixed-reality games like Demeter).
@@ -855,7 +855,7 @@ Allows very large pictures (8K video, theaters) instead of quitting.
Lets Team Beef ports (for example Lambda1VR) start on the Frame.
- `frame.tbxr_vendor`
- `frame.tbxr_vendor` · revision 2
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Team Beef port: it picks its OpenXR setup by headset maker, which the Frame reports as "valve".
- **Catalog games (1):** Lambda1VR.
@@ -955,7 +955,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
- `frame.unity_text_input` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
- **Catalog games (72):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad and 64 more.
- **Catalog games (73):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, BattleSisters, Beat Saber, Beat Saber (co-existence build) and 65 more.
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
@@ -1863,7 +1863,7 @@ Mixed-reality games that build their level from your room get a simple room with
- **Options:**
- Value: int, default `0`.
- Game settings → Mixed reality → "Pretend room layout".
- **Catalog games (2):** Demeter, Vermillion.
- **Catalog games (3):** Arkio, Demeter, Vermillion.
- **Suggested by launch-test signatures:**
- `scene-missing` (warning): The game wants Meta's room data (scene API), which the Frame lacks.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -2256,7 +2256,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
- `device.text_input_window`
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
- **Catalog games (72):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad, BodyCombat and 64 more.
- **Catalog games (73):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad and 65 more.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). Pairs with "Make Unity text fields work without a system keyboard".
+1 -1
View File
@@ -60,7 +60,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Oculus Rift game won't run on the Frame | Oculus/LibOVR game: needs Revive, whose hooks don't work under Proton-arm64, and FramePort won't defeat the Oculus runtime signature check | Play it on this PC (SteamVR + Revive). Only OpenVR/OpenXR-native Rift games run on the Frame |
| Un-cracked Oculus PC VR game: "Initializing OVR session" then exits / signature check on the runtime | Revive's hooks don't install on Proton-arm64; the Oculus shim rejects the unsigned Revive runtime | Not runnable on the Frame without the repack's crack extracted (not done by FramePort); use the PC version |
| PC VR game: `Failed to initialize Oculus API (-3001)` / `Unable to load LibOVRRT DLL` | Revive's LoadLibrary hook doesn't work under Proton arm64 (ARM64EC kernelbase); the game's LibOVR shim finds no runtime DLL | Symlink `LibOVRRT64_1.dll` → Revive's DLL in the prefix's system32 (manual so far) → then -3021 (runtime signature check; open) |
| Uploads to the Frame are slow (~15 MB/s) | PC and Frame both on Wi-Fi through the home router | Connect the PC to the Frame's own hotspot (or a USB cable): FramePort uses the direct link automatically (~80-100 MB/s); job log line "Transfer link" |
| Uploads to the Frame are slow | The link, storage or transfer backend can limit throughput | Check the job's "Transfer link"; try the Frame's hotspot or USB cable. Large install files prefer system OpenSSH SFTP, retaining paired-host checks and `.part` resume; unavailable or failed native uploads fall back to Paramiko. The log names the backend. Settings → Installing → Parallel uploads accepts 1–8 (default 3; saved preferences are preserved). More is not always faster. Measure the production path with `scripts/upload_bench.py --methods app-files-3 --check-resume` (three repetitions, checksum verification) |
| PC VR game exits right away: `Unhandled Exception: 0xc06d007e` (after `FOnlineSubsystemOculus::InitWithWindowsPlatform`) | Delay-loaded `LibOVRPlatform64_1.dll` (Oculus Platform SDK, installed with the Oculus app) is missing | None on the Frame (entitlement check; FramePort doesn't replace it). PC mode with the Oculus app |
| Unity PC VR game (Oculus + SteamVR build: `OVRPlugin.dll` and `openvr_api.dll` in `<Name>_Data/Plugins`) opens a window and quits after ~20 s; no VR Vulkan instance after the game's DXVK device in launch.log; Unity log (`unity log … Player.log`, agent v67): `OpenVR failed initialization` / `Initialization of device … failed` | Unity's built-in VR tries its SDKs in list order (Oculus first) unless `-vrmode` picks one; a catalog recipe verified with another build of the game (for example SUPERHOT VR's OpenXR build, GitHub #105) used to drop the build's own arguments | `pcvr.launch_args` = `-vrmode OpenVR` (detected for such builds; kept when the catalog's `xr` differs from the build's). A game's Electron launcher next to it (SHVR.exe) is ranked below the game |
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
+378 -98
View File
@@ -1,135 +1,415 @@
"""Upload speed to the Frame by method (GitHub discussion #242: paramiko capped at ~23 MB/s on a reporter's PC).
"""Compare bulk transports on one paired Frame endpoint and one destination filesystem.
uv run python scripts/upload_bench.py [--mib 512] [--host <addr>] [--methods app,paramiko-tuned,ssh,ssh-gcm,sftp]
uv run python scripts/upload_bench.py --mib 512 --repeat 3 --json /tmp/uploads.json
uv run python scripts/upload_bench.py --fast-link --remote-dir /path/on/sdcard --check-resume
Methods, each sending the same random file to ~/frameport-bench on the Frame (removed afterwards):
- app FramePort's own upload (Frame.put: pipelined SFTP, 1 MiB writes)
- paramiko-tuned the same through a paramiko transport/SFTP with a 64 MiB window
- ssh the system's OpenSSH: `ssh … "cat > file"` (default cipher)
- ssh-gcm the same with aes128-gcm@openssh.com
- ssh-chacha the same with chacha20-poly1305@openssh.com
- sftp OpenSSH's sftp in batch mode
Prints MB/s, wall time and the CPU time spent on this PC (this process + children). Dev tool, not part of the app.
The script uses a unique scratch folder, verifies SHA-256 outside the timed
transfer, and removes its own files afterwards. Native SCP/SFTP timings include startup; SSH streaming reports
handshake separately. Windows-native Python and WSL must be measured separately.
Use --methods app-files-1,app-files-2,sftp-files-1,sftp-files-2 to compare parallel file uploads.
Both file methods accept worker counts 1, 2, 3, 4 and 8, using the same eight-file payload.
app-files-N uses the production backend (OpenSSH preferred, Paramiko fallback); its log records the actual choice.
"""
from __future__ import annotations
import argparse
import contextlib
import csv
import hashlib
import io
import json
import os
import platform
import random
import shutil
import socket
import stat
import subprocess
import sys
import tarfile
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
import paramiko # noqa: E402
from frameport.frame.connection import Frame, app_key, saved_targets, ssh_dir # noqa: E402
REMOTE = "frameport-bench"
from frameport.frame.connection import Frame, saved_targets, sh_quote, ssh_dir # noqa: E402
def cpu() -> float:
t = os.times()
return t.user + t.system + t.children_user + t.children_system
def private_key() -> str:
"""OpenSSH refuses a key file others can read (the Windows data folder under /mnt/c shows 0777): a 0600 copy."""
@contextlib.contextmanager
def private_key():
"""Temporary private copy; never alter the user's pairing key/ACL."""
key = ssh_dir() / "id_ed25519"
if os.name == "nt" or stat.S_IMODE(key.stat().st_mode) & 0o077 == 0:
return str(key)
tmp = Path(tempfile.mkdtemp()) / "id_ed25519"
shutil.copy(key, tmp)
tmp.chmod(0o600)
return str(tmp)
if os.name != "nt" and stat.S_IMODE(key.stat().st_mode) & 0o077 == 0:
yield str(key)
return
with tempfile.TemporaryDirectory(prefix="frameport-bench-key-") as directory:
copy = Path(directory) / "id_ed25519"
if os.name == "nt":
row = next(csv.reader(io.StringIO(subprocess.check_output(
["whoami", "/user", "/fo", "csv", "/nh"], text=True))))
# Restrict the directory before writing the key, including under inherited Windows ACLs.
subprocess.run(["icacls", directory, "/inheritance:r", "/grant:r", f"*{row[-1]}:(OI)(CI)F"],
check=True, stdout=subprocess.DEVNULL)
shutil.copyfile(key, copy)
if os.name == "nt":
# Python 3.13's chmod can add an OWNER RIGHTS ACE that Windows OpenSSH refuses.
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
"/grant:r", f"*{row[-1]}:F"], check=True, stdout=subprocess.DEVNULL)
else:
copy.chmod(0o600)
yield str(copy)
def ssh_base(frame: Frame, key: str, port_flag: str = "-p") -> list[str]:
return ["-i", key, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
def ssh_base(frame, key, port_flag="-p"):
return ["-i", key, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "ConnectTimeout=10",
"-o", f"UserKnownHostsFile={ssh_dir() / 'known_hosts'}", "-o", "StrictHostKeyChecking=yes",
port_flag, str(frame.target.port)]
def m_app(frame, local, remote, key):
frame.put(local, remote, resume=False)
def cpu():
t = os.times()
return t.user + t.system + t.children_user + t.children_system
def m_paramiko_tuned(frame, local, remote, key):
sock = socket.create_connection((frame.target.host, frame.target.port), timeout=10)
t = paramiko.Transport(sock, default_window_size=64 << 20, default_max_packet_size=32768)
def digest(path):
with open(path, "rb") as source:
return hashlib.file_digest(source, "sha256").hexdigest()
def sftp_quote(path):
"""OpenSSH batch syntax, distinct from shell quoting."""
value = str(path)
if any(c in value for c in "\n\r\x00"):
raise ValueError("SFTP paths cannot contain newlines or NUL")
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def native(frame, key, tool="ssh"):
return [tool, *ssh_base(frame, key, "-p" if tool == "ssh" else "-P"),
f"{frame.target.user}@{frame.target.host}"]
def stream_ssh(frame, key, command, writer, cipher=None):
args = native(frame, key)
if cipher:
args[1:1] = ["-c", cipher]
t0 = time.monotonic()
# Drain stderr to a file: no pipe deadlock for long-running transfers or an authentication failure.
with tempfile.TemporaryFile() as errors:
process = subprocess.Popen([*args, f"printf 'READY\\n'; {command}"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=errors)
try:
if process.stdout.readline() != b"READY\n":
process.wait(timeout=15)
errors.seek(0)
raise RuntimeError(errors.read().decode(errors="replace")[-1000:])
setup = time.monotonic() - t0
writer(process.stdin)
process.stdin.close()
if process.wait(timeout=60):
errors.seek(0)
raise RuntimeError(errors.read().decode(errors="replace")[-1000:])
return setup
finally:
if process.poll() is None:
process.kill()
process.wait()
process.stdout.close()
if not process.stdin.closed:
process.stdin.close()
def send(method, frame, local, remote, key, small, large=None, logs=None):
"""Returns measured setup seconds (None when the native tool cannot expose handshake completion)."""
t0 = time.monotonic()
if method.startswith(("app-files-", "paramiko-files-")):
from frameport.core.events import Reporter
from frameport.install.installer import upload_files
workers = int(method.rsplit("-", 1)[1])
with contextlib.closing(Frame(frame.target, frame.password).connect()) as transfer:
items = [(path, path.name, path.stat().st_size) for path in large]
def record(event):
if event.kind == "log":
if logs is not None:
logs.append(event.message)
print(f" {event.message}", flush=True)
reporter = Reporter([record])
upload_files(transfer, items, remote, reporter, sum(size for _, _, size in items), workers=workers,
prefer_native=not method.startswith("paramiko-files-"))
return None # worker handshakes overlap transfers; measure the production end-to-end operation
if method.startswith("sftp-files-"):
workers = min(int(method.rsplit("-", 1)[1]), len(large))
frame.run(f"mkdir -p {sh_quote(remote)}")
groups = [[] for _ in range(workers)]
for path in sorted(large, key=lambda p: p.stat().st_size, reverse=True):
group = min(groups, key=lambda group: sum(p.stat().st_size for p in group))
group.append(path)
def upload(group):
args = native(frame, key, "sftp")
args[-1:-1] = ["-b", "-"]
commands = "".join(f"put {sftp_quote(path)} {sftp_quote(remote + '/' + path.name)}\n"
for path in group)
subprocess.run(args, input=commands.encode(), check=True, stdout=subprocess.DEVNULL, timeout=1800)
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(upload, groups))
return None # each worker keeps one native SFTP session for its entire batch
if method in ("app", "app-tar"):
with contextlib.closing(Frame(frame.target).connect()) as transfer:
setup = time.monotonic() - t0
if method == "app":
transfer.put(local, remote)
else:
transfer.put_tar([(p, p.name) for p in small], remote)
return setup
if method == "paramiko-tuned":
# Authenticate the same paired host key, rather than opening an unchecked raw Transport.
with contextlib.closing(Frame(frame.target).connect()) as transfer:
transport = transfer.client.get_transport()
with paramiko.SFTPClient.from_transport(transport, window_size=64 << 20) as client:
setup = time.monotonic() - t0
with open(local, "rb") as source, client.open(remote, "wb") as target:
target.set_pipelined(True)
shutil.copyfileobj(source, target, 1 << 20)
return setup
if method.startswith("ssh"):
cipher = {"ssh-gcm": "aes128-gcm@openssh.com", "ssh-chacha": "chacha20-poly1305@openssh.com"}.get(method)
if method == "ssh-tar":
def writer(pipe):
with tarfile.open(fileobj=pipe, mode="w|", format=tarfile.PAX_FORMAT) as archive:
for path in small:
archive.add(path, arcname=path.name, recursive=False)
command = f"mkdir -p {sh_quote(remote)} && tar -xf - -C {sh_quote(remote)}"
else:
def writer(pipe):
with open(local, "rb") as source:
shutil.copyfileobj(source, pipe, 1 << 20)
command = f"cat > {sh_quote(remote)}"
return stream_ssh(frame, key, command, writer, cipher)
if method == "sftp":
args = native(frame, key, "sftp")
args[-1:-1] = ["-b", "-"]
subprocess.run(args, input=f"put {sftp_quote(local)} {sftp_quote(remote)}\n".encode(),
check=True, stdout=subprocess.DEVNULL, timeout=1800)
elif method == "scp":
args = native(frame, key, "scp")
destination = args.pop()
subprocess.run([*args, str(local), f"{destination}:{remote}"], check=True, timeout=1800)
else:
raise ValueError(f"unknown method {method}")
return None
METHODS = ("app", "paramiko-tuned", "ssh", "ssh-gcm", "ssh-chacha", "sftp", "scp", "app-tar", "ssh-tar",
"app-files-1", "app-files-2", "app-files-3", "app-files-4", "app-files-8",
"paramiko-files-1", "paramiko-files-2", "paramiko-files-3", "paramiko-files-4", "paramiko-files-8",
"sftp-files-1", "sftp-files-2", "sftp-files-3", "sftp-files-4", "sftp-files-8")
def check_resume(frame, local, remote, key):
"""Interrupt the app mid-upload, then resume exactly that .part with app and native SFTP reput."""
class Interrupted(Exception):
pass
results = []
for method in ("app", "sftp"):
if method == "sftp" and not shutil.which("sftp"):
results.append({"method": method, "skipped": "sftp unavailable"})
continue
path = remote + "-" + method
def interrupt(done, size):
if done >= size // 2:
raise Interrupted()
try:
frame.put(local, path, interrupt)
except Interrupted:
pass
offset = frame.sftp.stat(path + ".part").st_size
if not 0 < offset < local.stat().st_size:
raise RuntimeError("interrupted upload did not leave a partial file")
started = time.monotonic()
if method == "app":
frame.put(local, path)
else:
args = native(frame, key, "sftp")
args[-1:-1] = ["-b", "-"]
part = path + ".part"
commands = f"reput {sftp_quote(local)} {sftp_quote(part)}\nrename {sftp_quote(part)} {sftp_quote(path)}\n"
subprocess.run(args, input=commands.encode(), check=True, timeout=1800, stdout=subprocess.DEVNULL)
code, out, err = frame.run(f"sha256sum {sh_quote(path)}")
if code or out.split()[0] != digest(local):
raise RuntimeError(f"resume checksum mismatch: {err}")
results.append({"method": method, "offset": offset, "seconds": time.monotonic() - started,
"sha256_ok": True, "cancelled_app": True})
from frameport.core.events import Cancelled, Reporter
from frameport.install.installer import upload_files
root = remote + "-production"
items = [(local, "resume.obb", local.stat().st_size)]
report = Reporter()
report.subscribe(lambda event: report.cancelled.set()
if event.kind == "progress" and event.fraction >= 0.5 else None)
try:
t.connect(username=frame.target.user, pkey=app_key())
sftp = paramiko.SFTPClient.from_transport(t, window_size=64 << 20, max_packet_size=32768)
with open(local, "rb") as src, sftp.open(remote, "wb") as dst:
dst.set_pipelined(True)
while chunk := src.read(4 << 20):
dst.write(chunk)
finally:
t.close()
upload_files(frame, items, root, report, local.stat().st_size, workers=1)
except Cancelled:
pass
offset = frame.sftp.stat(root + "/resume.obb.part").st_size
if not 0 < offset < local.stat().st_size:
raise RuntimeError("production cancellation did not leave a partial file")
started = time.monotonic()
logs = []
upload_files(frame, items, root, Reporter([lambda event: logs.append(event.message)
if event.kind == "log" else None]), local.stat().st_size, workers=1)
code, out, err = frame.run(f"sha256sum {sh_quote(root + '/resume.obb')}")
if code or out.split()[0] != digest(local):
raise RuntimeError(f"production resume checksum mismatch: {err}")
results.append({"method": "production", "offset": offset, "seconds": time.monotonic() - started,
"sha256_ok": True, "cancelled_production": True, "logs": logs})
return results
def _ssh_cat(cipher):
def run(frame, local, remote, key):
extra = ["-c", cipher] if cipher else []
with open(local, "rb") as src:
subprocess.run(["ssh", *ssh_base(frame, key), *extra, f"{frame.target.user}@{frame.target.host}",
f"cat > {remote}"], stdin=src, check=True)
return run
def m_sftp(frame, local, remote, key):
subprocess.run(["sftp", *ssh_base(frame, key, "-P"), "-b", "-", f"{frame.target.user}@{frame.target.host}"],
input=f'put "{local}" "{remote}"\n'.encode(), check=True, stdout=subprocess.DEVNULL)
METHODS = {"app": m_app, "paramiko-tuned": m_paramiko_tuned, "ssh": _ssh_cat(None),
"ssh-gcm": _ssh_cat("aes128-gcm@openssh.com"), "ssh-chacha": _ssh_cat("chacha20-poly1305@openssh.com"),
"sftp": m_sftp}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--mib", type=int, default=512)
ap.add_argument("--host", help="the Frame's address (default: the remembered Frame)")
ap.add_argument("--methods", default="app,paramiko-tuned,ssh,ssh-gcm,ssh-chacha,sftp")
a = ap.parse_args()
target = saved_targets()[0]
if a.host:
target.host = a.host
frame = Frame(target).connect()
remote = f"{frame.home}/{REMOTE}"
key = private_key()
with tempfile.NamedTemporaryFile(delete=False) as f:
block = os.urandom(1 << 20)
for _ in range(a.mib):
f.write(block)
local = f.name
size = a.mib << 20
print(f"{a.mib} MiB to {target.host} ({sys.platform}, paramiko {paramiko.__version__})")
try:
for name in a.methods.split(","):
if name.startswith(("ssh", "sftp")) and not shutil.which(name.split("-")[0]):
print(f"{name:15} skipped: no {name.split('-')[0]} on PATH")
continue
c0, t0 = cpu(), time.monotonic()
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--mib", type=int, default=512)
parser.add_argument("--repeat", type=int, default=3)
parser.add_argument("--small-files", type=int, default=1024)
parser.add_argument("--host")
parser.add_argument("--fast-link", action="store_true")
parser.add_argument("--remote-dir", help="destination filesystem, default: paired user's home")
parser.add_argument("--methods", default=",".join(METHODS[:9]))
parser.add_argument("--check-resume", action="store_true")
parser.add_argument("--json", type=Path)
parser.add_argument("--probe", action="store_true", help="show reachable links and storage without transfers")
args = parser.parse_args()
methods = args.methods.split(",")
if args.mib < 1 or args.repeat < 1 or not 1 <= args.small_files <= 10000 or set(methods) - set(METHODS):
parser.error("positive size/repeat and 1–10000 small files required; methods: " + ",".join(METHODS))
targets = saved_targets()
if not targets:
parser.error("pair a Frame first")
target = targets[0]
if args.host:
target.host = args.host
report = {"platform": platform.platform(), "python": sys.version, "paramiko": paramiko.__version__,
"wsl": "microsoft" in platform.release().lower(), "methods": [], "resume": [],
"cpu_scope": "Python parent only on Windows; parent + child processes on POSIX"}
with contextlib.ExitStack() as stack:
control = stack.enter_context(contextlib.closing(Frame(target).connect()))
frame, label = control.fast_link() if args.fast_link else (control, "explicit/current endpoint")
if frame is not control:
stack.callback(frame.close)
report.update(host=frame.target.host, port=frame.target.port, link=label or "home/current endpoint",
addresses=control.addresses())
_, inventory, _ = frame.run("lsblk -J -o NAME,TYPE,FSTYPE,MOUNTPOINTS")
_, wireless, _ = frame.run("iw dev wlan0 link 2>/dev/null")
report.update(storage_inventory=inventory, wireless=wireless)
if args.probe:
print(json.dumps(report, indent=2))
return 0
# One unique directory on the chosen filesystem; safely quote user-supplied locations.
parent = args.remote_dir or frame.home
code, scratch, err = frame.run(f"mktemp -d -p {sh_quote(parent)} frameport-bench.XXXXXXXX")
if code:
raise RuntimeError(err)
scratch = scratch.strip()
if not scratch or not scratch.startswith(parent.rstrip("/") + "/frameport-bench."):
raise RuntimeError("unexpected scratch directory")
stack.callback(frame.run, f"rm -rf -- {sh_quote(scratch)}")
_, storage, _ = frame.run(f"df -T {sh_quote(scratch)}")
report.update(destination=parent, storage=storage.strip())
key = stack.enter_context(private_key())
folder = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix="frameport-upload-bench-")))
local = folder / "payload.bin"
# No compression-friendly repeated block: each method sends exactly the same random payload.
with local.open("wb") as output:
for _ in range(args.mib):
output.write(os.urandom(1 << 20))
expected = digest(local)
small_dir = folder / "small"
small_dir.mkdir()
small = []
with local.open("rb") as source:
for i in range(args.small_files):
path = small_dir / f"item-{i:05}.bin"
source.seek((i * 65536) % local.stat().st_size)
path.write_bytes(source.read(65536))
small.append(path)
small_hashes = {p.name: digest(p) for p in small}
large_dir = folder / "large"
large_dir.mkdir()
large = []
# Equal total bytes to the single-file trial; all worker counts send exactly these eight files.
with local.open("rb") as source:
for i in range(8):
path = large_dir / f"main-{i:02}.obb"
count = local.stat().st_size // 8 if i < 7 else local.stat().st_size - source.tell()
with path.open("wb") as output:
remaining = count
while remaining:
block = source.read(min(remaining, 1 << 20))
output.write(block)
remaining -= len(block)
large.append(path)
large_hashes = {p.name: digest(p) for p in large}
print(f"{platform.system()} {'WSL' if report['wsl'] else 'native'} "
f"to {frame.target.host}, {parent}", flush=True)
rng = random.Random(0)
for repeat in range(args.repeat):
order = list(methods)
rng.shuffle(order)
for method in order:
row = {"method": method, "repeat": repeat + 1}
tool = ("ssh" if method.startswith("ssh") else "sftp" if method.startswith("sftp-files-")
else method if method in ("scp", "sftp") else None)
if tool and not shutil.which(tool):
row["skipped"] = f"no {tool} on PATH"
report["methods"].append(row)
continue
remote = f"{scratch}/{method}-{repeat}"
size = sum(p.stat().st_size for p in small) if method.endswith("-tar") else local.stat().st_size
c0, t0 = cpu(), time.monotonic()
try:
row["logs"] = []
setup = send(method, frame, local, remote, key, small, large, row["logs"])
elapsed, used = time.monotonic() - t0, cpu() - c0
row.update(bytes=size, total_s=elapsed, setup_s=setup, cpu_s=used,
total_MB_s=size / elapsed / 1e6,
payload_MB_s=size / (elapsed - setup) / 1e6 if setup is not None else None)
verify_start = time.monotonic()
large_trial = method.startswith(("app-files-", "paramiko-files-", "sftp-files-"))
if method.endswith("-tar") or large_trial:
pattern = "*.obb" if large_trial else "*.bin"
_, out, _ = frame.run(f"cd {sh_quote(remote)} && sha256sum {pattern}")
actual = {line.split(maxsplit=1)[1].strip(): line.split()[0] for line in out.splitlines()}
verified = actual == (large_hashes if large_trial else small_hashes)
else:
code, out, _ = frame.run(f"sha256sum {sh_quote(remote)}")
verified = code == 0 and bool(out.split()) and out.split()[0] == expected
row.update(verify_s=time.monotonic() - verify_start, sha256_ok=verified)
if not verified:
raise RuntimeError("checksum mismatch")
print(f"{repeat + 1} {method:15} {size / elapsed / 1e6:7.1f} MB/s total "
f"{elapsed:6.2f}s setup={setup if setup is not None else 'included'} "
f"CPU={used:.2f}s checksum=OK",
flush=True)
except Exception as exc:
row["error"] = str(exc)
print(f"{method}: {exc}", flush=True)
report["methods"].append(row)
# Exclude deletion and hash verification from transfer timings and retain no previous trial files.
frame.run(f"rm -rf -- {sh_quote(remote)} {sh_quote(remote + '.part')}")
if args.check_resume:
try:
METHODS[name](frame, local, remote, key)
except Exception as exc: # noqa: BLE001
print(f"{name:15} failed: {exc}")
continue
wall, used = time.monotonic() - t0, cpu() - c0
got = int(frame.run(f"stat -c %s {remote}")[1].strip() or 0)
frame.run(f"rm -f {remote}")
ok = "" if got == size else f" (size {got} != {size})"
print(f"{name:15} {size / wall / 1e6:7.1f} MB/s {wall:6.1f} s CPU here {used:5.1f} s{ok}")
finally:
os.remove(local)
return 0
report["resume"] = check_resume(frame, local, f"{scratch}/resume", key)
except Exception as exc:
report["resume"] = [{"error": str(exc)}]
print(f"resume check: {exc}", flush=True)
if args.json:
args.json.write_text(json.dumps(report, indent=2) + "\n")
return int(any(r.get("error") for r in [*report["methods"], *report["resume"]]))
if __name__ == "__main__":
+150 -24
View File
@@ -1,14 +1,14 @@
{
"updated": "2026-10-13",
"updated": "2026-10-14",
"people": [
{
"login": "spoopyghosty0",
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
"url": "https://github.com/spoopyghosty0",
"commits": 578,
"commits": 603,
"issues": 5,
"prs": 3,
"total": 608
"total": 633
},
{
"login": "Klownicle",
@@ -46,6 +46,15 @@
"prs": 0,
"total": 33
},
{
"login": "UnicronNL",
"avatar": "https://avatars.githubusercontent.com/u/1267745?v=4",
"url": "https://github.com/UnicronNL",
"commits": 5,
"issues": 6,
"prs": 2,
"total": 33
},
{
"login": "Lucas-Mathieu",
"avatar": "https://avatars.githubusercontent.com/u/184943223?v=4",
@@ -55,15 +64,6 @@
"prs": 4,
"total": 29
},
{
"login": "UnicronNL",
"avatar": "https://avatars.githubusercontent.com/u/1267745?v=4",
"url": "https://github.com/UnicronNL",
"commits": 5,
"issues": 6,
"prs": 1,
"total": 28
},
{
"login": "inseend",
"avatar": "https://avatars.githubusercontent.com/u/11666993?v=4",
@@ -145,6 +145,15 @@
"prs": 0,
"total": 15
},
{
"login": "coah80",
"avatar": "https://avatars.githubusercontent.com/u/100182500?v=4",
"url": "https://github.com/coah80",
"commits": 0,
"issues": 1,
"prs": 2,
"total": 13
},
{
"login": "nickwilde77555",
"avatar": "https://avatars.githubusercontent.com/u/180547421?v=4",
@@ -226,6 +235,15 @@
"prs": 0,
"total": 9
},
{
"login": "ghost",
"avatar": "https://avatars.githubusercontent.com/u/10137?v=4",
"url": "https://github.com/ghost",
"commits": 0,
"issues": 3,
"prs": 0,
"total": 9
},
{
"login": "greggray24",
"avatar": "https://avatars.githubusercontent.com/u/255593097?v=4",
@@ -262,6 +280,24 @@
"prs": 0,
"total": 9
},
{
"login": "puppet-head",
"avatar": "https://avatars.githubusercontent.com/u/8073775?v=4",
"url": "https://github.com/puppet-head",
"commits": 0,
"issues": 3,
"prs": 0,
"total": 9
},
{
"login": "Tazzash",
"avatar": "https://avatars.githubusercontent.com/u/42785850?v=4",
"url": "https://github.com/Tazzash",
"commits": 0,
"issues": 3,
"prs": 0,
"total": 9
},
{
"login": "DJ3thenew23",
"avatar": "https://avatars.githubusercontent.com/u/3744521?v=4",
@@ -289,6 +325,15 @@
"prs": 1,
"total": 6
},
{
"login": "jeliastam",
"avatar": "https://avatars.githubusercontent.com/u/2113009?v=4",
"url": "https://github.com/jeliastam",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "Loafmeister",
"avatar": "https://avatars.githubusercontent.com/u/60628625?v=4",
@@ -298,6 +343,15 @@
"prs": 0,
"total": 6
},
{
"login": "marcoszaballos",
"avatar": "https://avatars.githubusercontent.com/u/44778619?v=4",
"url": "https://github.com/marcoszaballos",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "maxfvd",
"avatar": "https://avatars.githubusercontent.com/u/337777776?v=4",
@@ -317,9 +371,9 @@
"total": 6
},
{
"login": "puppet-head",
"avatar": "https://avatars.githubusercontent.com/u/8073775?v=4",
"url": "https://github.com/puppet-head",
"login": "peetzweg",
"avatar": "https://avatars.githubusercontent.com/u/839848?v=4",
"url": "https://github.com/peetzweg",
"commits": 0,
"issues": 2,
"prs": 0,
@@ -343,6 +397,15 @@
"prs": 0,
"total": 6
},
{
"login": "wanwanwanwano",
"avatar": "https://avatars.githubusercontent.com/u/341975109?v=4",
"url": "https://github.com/wanwanwanwano",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "yzfsoldier",
"avatar": "https://avatars.githubusercontent.com/u/35410319?v=4",
@@ -361,6 +424,15 @@
"prs": 1,
"total": 5
},
{
"login": "Agent-Gato",
"avatar": "https://avatars.githubusercontent.com/u/141082538?v=4",
"url": "https://github.com/Agent-Gato",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Akimya49",
"avatar": "https://avatars.githubusercontent.com/u/46827872?v=4",
@@ -406,6 +478,15 @@
"prs": 0,
"total": 3
},
{
"login": "bobosand77",
"avatar": "https://avatars.githubusercontent.com/u/342317426?v=4",
"url": "https://github.com/bobosand77",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "chaseflow",
"avatar": "https://avatars.githubusercontent.com/u/235484785?v=4",
@@ -416,9 +497,9 @@
"total": 3
},
{
"login": "coah80",
"avatar": "https://avatars.githubusercontent.com/u/100182500?v=4",
"url": "https://github.com/coah80",
"login": "clockhart192",
"avatar": "https://avatars.githubusercontent.com/u/16326214?v=4",
"url": "https://github.com/clockhart192",
"commits": 0,
"issues": 1,
"prs": 0,
@@ -478,6 +559,15 @@
"prs": 0,
"total": 3
},
{
"login": "fazejohny",
"avatar": "https://avatars.githubusercontent.com/u/12040179?v=4",
"url": "https://github.com/fazejohny",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "felis-lynx",
"avatar": "https://avatars.githubusercontent.com/u/106830854?v=4",
@@ -541,6 +631,15 @@
"prs": 0,
"total": 3
},
{
"login": "LeaPhant",
"avatar": "https://avatars.githubusercontent.com/u/14080165?v=4",
"url": "https://github.com/LeaPhant",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "leoloopfiets",
"avatar": "https://avatars.githubusercontent.com/u/215600281?v=4",
@@ -550,6 +649,15 @@
"prs": 0,
"total": 3
},
{
"login": "LynkWyland",
"avatar": "https://avatars.githubusercontent.com/u/54166762?v=4",
"url": "https://github.com/LynkWyland",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Mitchlett",
"avatar": "https://avatars.githubusercontent.com/u/46537377?v=4",
@@ -568,6 +676,15 @@
"prs": 0,
"total": 3
},
{
"login": "Natouille04",
"avatar": "https://avatars.githubusercontent.com/u/60348431?v=4",
"url": "https://github.com/Natouille04",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "njorbie",
"avatar": "https://avatars.githubusercontent.com/u/340724129?v=4",
@@ -605,18 +722,18 @@
"total": 3
},
{
"login": "TainakaRitsu",
"avatar": "https://avatars.githubusercontent.com/u/12560774?v=4",
"url": "https://github.com/TainakaRitsu",
"login": "SylanaZero",
"avatar": "https://avatars.githubusercontent.com/u/9733124?v=4",
"url": "https://github.com/SylanaZero",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Tazzash",
"avatar": "https://avatars.githubusercontent.com/u/42785850?v=4",
"url": "https://github.com/Tazzash",
"login": "TainakaRitsu",
"avatar": "https://avatars.githubusercontent.com/u/12560774?v=4",
"url": "https://github.com/TainakaRitsu",
"commits": 0,
"issues": 1,
"prs": 0,
@@ -666,6 +783,15 @@
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "zoomiti",
"avatar": "https://avatars.githubusercontent.com/u/34012653?v=4",
"url": "https://github.com/zoomiti",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
}
]
}
+1 -1
View File
@@ -1,2 +1,2 @@
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
__version__ = "1.1.0"
__version__ = "1.1.1"
+8 -3
View File
@@ -465,6 +465,7 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
password: Optional[str] = typer.Option(None, help="the Frame's password (first connection only)"),
apk_only: bool = typer.Option(False, help="reuse game data already on the Frame"),
force: bool = typer.Option(False, help="rebuild and reinstall even if the Frame is already current"),
no_library: bool = typer.Option(False, help="don't add to the Steam library now"),
to: str = typer.Option("frame", help="frame, or pc (PC VR games only: install on this PC)"),
apk: Optional[Path] = typer.Option(None, help="install this APK instead of the last build (one game; "
@@ -479,12 +480,16 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
pkgs = _pkgs(package, all_)
if apk and len(pkgs) != 1:
raise typer.BadParameter("--apk needs exactly one game")
shortcuts = []
for pkg in pkgs:
if not apk_only and pipeline.missing_obb(library.game(pkg) or {}):
typer.echo(f"{pkg}: warning: " + pipeline.MISSING_OBB_NOTE.format(package=pkg), err=True)
pipeline.install_game(pkg, target, printing_reporter(False), apk_only, add_to_library=False, apk=apk)
if not no_library:
target.add_to_library(pkgs, printing_reporter(False))
result = pipeline.install_game(pkg, target, printing_reporter(False), apk_only,
add_to_library=False, apk=apk, force=force)
if result.get("shortcut_required", True):
shortcuts.append(pkg)
if not no_library and shortcuts:
target.add_to_library(shortcuts, printing_reporter(False))
@app.command()
+17 -2
View File
@@ -25,6 +25,8 @@ class Reporter:
self._sinks = list(sinks or [])
self._lock = threading.Lock()
self.stage_name = ""
self._stage_started = time.monotonic()
self._stage_seconds: dict[str, float] = {}
self.cancelled = threading.Event()
def subscribe(self, sink: Callable[[Event], None]) -> None:
@@ -44,8 +46,21 @@ class Reporter:
self.emit(Event("log", message, self.stage_name, data=data))
def stage(self, name: str, message: str = "") -> None:
self.stage_name = name
self.emit(Event("stage", message or name, name))
now = time.monotonic()
previous = self.stage_name
elapsed = now - self._stage_started
if previous:
self._stage_seconds[previous] = self._stage_seconds.get(previous, 0.0) + elapsed
self.stage_name, self._stage_started = name, now
self.emit(Event("stage", message or name, name,
data={"previous_stage": previous, "previous_stage_seconds": elapsed} if previous else {}))
def stage_timings(self) -> dict[str, float]:
"""Completed stages plus the current stage, for install diagnostics and benchmark comparisons."""
result = dict(self._stage_seconds)
if self.stage_name:
result[self.stage_name] = result.get(self.stage_name, 0.0) + time.monotonic() - self._stage_started
return result
def progress(self, fraction: float, message: str = "", **data) -> None:
"""`data` may carry `speed` (e.g. "42.1 MB/s · ~3 min left") for transfers."""
+6 -3
View File
@@ -62,10 +62,13 @@ def acquire(path: Path | None = None) -> bool:
f.write(str(os.getpid()))
f.flush()
_held = f
import psutil
try:
import psutil
# Windows denies reading the locked byte, so another launch cannot read the PID from gui.lock itself.
write_atomic(_owner_file(path), json.dumps({"pid": os.getpid(), "created": psutil.Process().create_time()}))
# Windows denies reading the locked byte, so another launch cannot read the PID from gui.lock itself.
write_atomic(_owner_file(path), json.dumps({"pid": os.getpid(), "created": psutil.Process().create_time()}))
except Exception: # noqa: BLE001 the lock is ours; a missing owner file only costs a later launch's recovery
pass
return True
+6 -2
View File
@@ -154,10 +154,14 @@ try {
def _recent_events() -> dict:
from ..updates import _powershell
try:
result = subprocess.run(["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
# Windows PowerShell 5.1 without pwsh's PSModulePath: started from pwsh 7 it can't load Get-WinEvent's module
exe, env = _powershell()
result = subprocess.run([exe, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
_EVENT_SCRIPT], capture_output=True, encoding="utf-8", errors="replace",
timeout=EVENT_TIMEOUT, creationflags=0x08000000)
timeout=EVENT_TIMEOUT, creationflags=0x08000000, env=env)
if result.returncode:
return {"status": "error", "error": (result.stderr or "PowerShell query failed")[-512:]}
data = json.loads(result.stdout.lstrip("\ufeff"))
+20 -1
View File
@@ -541,6 +541,17 @@ class Frame:
self._agent_digest = ""
self._video_codec_digest = ""
def upload_session(self) -> Frame:
"""An independent bulk-upload transport to the same authenticated headset."""
from dataclasses import replace
other = Frame(replace(self.target), self.password).connect()
if (other.client.get_transport().get_remote_server_key() !=
self.client.get_transport().get_remote_server_key()):
other.close()
raise paramiko.SSHException("Upload worker reached a different headset; refusing transfer")
return other
def alive(self) -> bool:
"""The SSH connection itself still works (a failed agent command doesn't mean the Frame is gone)."""
transport = self.client.get_transport() if self.client else None
@@ -688,18 +699,24 @@ class Frame:
self.run("xargs -0 mkdir -p", stdin="\0".join(dirs))
def put(self, local: Path, remote: str, progress=None, resume: bool = True, mkdir: bool = True,
attempts: int = 3) -> None:
attempts: int = 3, check_cancel=None) -> None:
"""Upload with resume: data goes to <remote>.part, appended from the existing size, then renamed. progress may
raise (e.g. Cancelled): the .part stays and the next put continues from there. A failed transfer is resumed
up to `attempts` times (Windows: an occasional OSError [Errno 22] mid-upload that the next try got past)."""
for attempt in range(attempts):
if check_cancel:
check_cancel()
try:
return self._put(Path(local), remote, progress, resume, mkdir)
except OSError as exc:
if check_cancel:
check_cancel()
if attempt == attempts - 1 or is_unreachable(exc):
raise
log.warning("upload of %s failed (%s), resuming", Path(local).name, exc)
time.sleep(2)
if check_cancel:
check_cancel()
if not self.alive():
self.close()
self.connect()
@@ -721,6 +738,8 @@ class Frame:
dst.set_pipelined(True)
src.seek(offset)
done = offset
if progress:
progress(done, size) # gives parallel progress an exact resume baseline
while True:
chunk = src.read(1 << 20)
if not chunk:
+183
View File
@@ -0,0 +1,183 @@
"""System OpenSSH for bulk files; the established Frame connection owns trust and progress polling."""
from __future__ import annotations
import contextlib
import csv
import io
import os
import posixpath
import shutil
import signal
import subprocess
import tempfile
import time
from pathlib import Path
from .connection import sh_quote, ssh_dir
def quote(value) -> str:
value = str(value)
if any(c in value for c in "\r\n\x00"):
raise ValueError("SFTP paths cannot contain newlines or NUL")
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
for char in "*?[":
escaped = escaped.replace(char, "\\" + char)
return '"' + escaped + '"'
def size_of(sftp, path):
try:
return sftp.stat(path).st_size
except FileNotFoundError:
return 0
@contextlib.contextmanager
def credentials(frame):
"""Private temporary copies avoid modifying pairing keys, including their Windows ACLs."""
key_paths = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
if not key_paths:
raise OSError("no FramePort key available for OpenSSH")
key = frame.client.get_transport().get_remote_server_key()
with tempfile.TemporaryDirectory(prefix="frameport-upload-") as directory:
directory = Path(directory)
sid = None
if os.name == "nt":
row = next(csv.reader(io.StringIO(subprocess.check_output(
["whoami", "/user", "/fo", "csv", "/nh"], text=True, **hidden()))))
sid = row[-1]
subprocess.run(["icacls", str(directory), "/inheritance:r", "/grant:r", f"*{sid}:(OI)(CI)F"],
check=True, stdout=subprocess.DEVNULL, **hidden())
else:
directory.chmod(0o700)
copies = []
for path in key_paths:
copy = directory / path.name
shutil.copyfile(path, copy)
if sid:
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
"/grant:r", f"*{sid}:F"], check=True, stdout=subprocess.DEVNULL, **hidden())
else:
copy.chmod(0o600)
copies.extend(["-i", str(copy)])
known = directory / "known_hosts"
host = frame.target.host if frame.target.port == 22 else f"[{frame.target.host}]:{frame.target.port}"
known.write_text(f"{host} {key.get_name()} {key.get_base64()}\n")
yield ["-F", os.devnull, *copies, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=yes", "-o", f'UserKnownHostsFile="{known.as_posix()}"',
"-o", f"GlobalKnownHostsFile={os.devnull}", "-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3",
"-P", str(frame.target.port)]
def hidden():
return {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
def stop_process(process):
"""SFTP starts an ssh child: stop the entire owned process group before resuming with another backend."""
if process.poll() is None:
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, **hidden())
if process.poll() is None:
process.kill()
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
def upload(frame, items, remote_root, reporter, total, sent, workers):
"""One persistent SFTP batch per worker. A failure leaves .part files for Paramiko to resume.
Only successful batches are promoted, after every process has exited successfully. All control operations
happen in the supervising thread, so Paramiko's SFTP client is never shared across threads.
"""
executable = shutil.which("sftp")
if not executable:
raise OSError("system OpenSSH SFTP is unavailable")
from ..install.installer import Speed
reporter.check_cancel()
if not items:
return sent
groups = [[] for _ in range(min(workers, len(items)))]
sizes, offsets, progress = {}, {}, {}
for path, rel, size in sorted(items, key=lambda item: item[2], reverse=True):
remote = posixpath.join(remote_root, rel)
quote(path)
quote(remote)
offset = size_of(frame.sftp, remote + ".part")
if offset > size:
frame.sftp.remove(remote + ".part")
offset = 0
offsets[rel] = progress[rel] = offset
sizes[rel] = size
if offset == size:
continue # complete from an earlier attempt (sftp's reput refuses a target of the same size): rename only
group = min(groups, key=lambda group: sum(item[2] for item in group))
group.append((path, rel, size))
groups = [group for group in groups if group]
speed = Speed(total - sent - sum(offsets.values()))
reporter.log(f"uploading large files with OpenSSH SFTP ({len(groups)} workers)")
setup_started = time.monotonic()
with credentials(frame) as args, contextlib.ExitStack() as stack:
monitor = stack.enter_context(frame.client.open_sftp())
monitor.get_channel().settimeout(2)
reporter.log(f"OpenSSH credentials and progress connection ready in {time.monotonic() - setup_started:.2f}s")
processes = []
try:
for group in groups:
reporter.check_cancel()
batch = stack.enter_context(tempfile.TemporaryFile())
for path, rel, _ in group:
remote = posixpath.join(remote_root, rel) + ".part"
command = "reput" if offsets[rel] else "put"
batch.write(f"{command} {quote(path)} {quote(remote)}\n".encode())
batch.seek(0)
errors = stack.enter_context(tempfile.TemporaryFile())
process = subprocess.Popen([executable, *args, "-b", "-",
f"{frame.target.user}@{frame.target.host}"], stdin=batch,
stdout=subprocess.DEVNULL, stderr=errors,
**(hidden() if os.name == "nt" else {"start_new_session": True}))
processes.append((process, errors))
while True:
reporter.check_cancel()
for process, errors in processes:
if process.poll() not in (None, 0):
errors.seek(0)
detail = errors.read().decode(errors="replace")[-1500:]
raise OSError(f"OpenSSH SFTP failed ({process.returncode}): {detail}")
for rel, size in sizes.items():
reporter.check_cancel()
progress[rel] = max(progress[rel], min(size, size_of(
monitor, posixpath.join(remote_root, rel) + ".part")))
done = sum(progress.values())
reporter.progress((sent + done) / max(total, 1),
f"{len(items)} files · {done / 2**20:.0f}/{sum(sizes.values()) / 2**20:.0f} MiB",
speed=speed.text(done - sum(offsets.values())))
if all(process.poll() == 0 for process, _ in processes):
break
time.sleep(0.2)
for _, rel, size in items:
reporter.check_cancel()
remote = posixpath.join(remote_root, rel)
if size_of(monitor, remote + ".part") != size:
raise OSError(f"OpenSSH upload has an unexpected size: {rel}")
for start in range(0, len(items), 100):
reporter.check_cancel()
commands = []
for _, rel, _ in items[start:start + 100]:
remote = posixpath.join(remote_root, rel)
commands.append(f"mv -f -- {sh_quote(remote + '.part')} {sh_quote(remote)}")
code, _, error = frame.run(" && ".join(commands))
if code:
raise OSError(f"cannot finalize OpenSSH upload: {error}")
finally:
for process, _ in processes:
stop_process(process)
return sent + sum(size for _, _, size in items)
+106
View File
@@ -0,0 +1,106 @@
"""Pure Quest update decisions shared by the pipeline and UI (no filesystem or network I/O)."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from .patches import base, upstream
def fingerprint(value) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()[:32]
# These settings select APK support as well as runtime behaviour. Keep their revisions too: a newer shim can
# be needed even though the value did not change. Foveation remains an APK patch, including all its parameters.
APK_SETTINGS = {"adapter.haptic_fix", "adapter.controller_models", "adapter.surface_native",
"adapter.zink_shader_fix", "adapter.zink_shader_dump"}
def apk_recipe_fingerprint(recipe: dict, package: str = "", superseded=None) -> str:
base.load_all()
patches = upstream.without_superseded(recipe.get("patches") or {}, superseded)
selected = {}
for pid, params in patches.items():
patch = base.REGISTRY.get(pid)
if patch is None or patch.stage != "install" or pid in APK_SETTINGS:
selected[pid] = params
elif patch.category == "adapter" and patch.revision_for(package) != 1:
# Adapter revisions also track changes to the native code implementing a setting.
selected[pid] = {"revision": patch.revision_for(package)}
if recipe.get("as_is"):
return base.recipe_fingerprint({"as_is": True, "overport": False}, package)
return base.recipe_fingerprint({**recipe, "patches": selected, "use_alt": False}, package)
def configuration(recipe: dict, package: str = "", superseded=None) -> dict:
from .patches.settings import BASE_SETTINGS, SETTINGS, adapter_settings
patches = upstream.without_superseded(recipe.get("patches") or {}, superseded)
# Explicit defaults override settings embedded in an older APK when a recipe removes an override.
settings = {key: default for key, _, default, *_ in SETTINGS}
settings.update(BASE_SETTINGS)
settings.update(adapter_settings(patches))
install = {pid: {"params": params or {}, "revision": base.get(pid).revision_for(package)}
for pid, params in patches.items() if base.get(pid).stage == "install" and
base.get(pid).category != "adapter"}
return {"settings": settings, "patches": install}
def configuration_fingerprint(recipe: dict, package: str = "", superseded=None) -> str:
return fingerprint(configuration(recipe, package, superseded))
@dataclass(frozen=True)
class UpdatePlan:
rebuild: bool = False
apk: bool = False
data: bool = False
configure: bool = False
artwork: bool = False
shortcut: bool = False
reasons: tuple[str, ...] = ()
@property
def changed(self) -> bool:
return any((self.rebuild, self.apk, self.data, self.configure, self.artwork, self.shortcut))
@property
def launch_test(self) -> bool:
return self.rebuild or self.apk or self.data
def plan_update(game: dict, deployment: dict | None, *, apk_only: bool = False,
data_manifest: dict | None = None) -> UpdatePlan:
"""Unknown legacy identities use the full path once. Catalog metadata never affects these identities."""
recipe, build = game.get("recipe") or {}, game.get("build") or {}
package = game.get("package", "")
dep = deployment or {}
missing = not dep or dep.get("apk_present") is False
current = apk_recipe_fingerprint(recipe, package, build.get("superseded"))
rebuild = not build.get("ok", True) or not build.get("apk_recipe_fp") or build["apk_recipe_fp"] != current
if build.get("source_version") != (game.get("analysis") or {}).get("version"):
rebuild = True
if game.get("source_sha256") and build.get("source_sha256") != game["source_sha256"]:
rebuild = True
if recipe.get("use_alt") and not build.get("alt_sha256"):
rebuild = True
sha = build.get("alt_sha256") if recipe.get("use_alt") else build.get("sha256")
apk = missing or rebuild or not sha or dep.get("sha256") != sha
configure = apk or dep.get("configuration_fp") != configuration_fingerprint(
recipe, package, build.get("superseded"))
if data_manifest is None:
data_manifest = game.get("data_manifest")
data = False
if not apk_only and data_manifest:
if "data_manifest" in dep:
data = any(dep["data_manifest"].get(k) != v for k, v in data_manifest.items())
else:
data = fingerprint(data_manifest) != dep.get("data_fp")
artwork = missing or bool(game.get("steam_art_stale"))
shortcut = missing or bool(game.get("steam_name_stale")) or artwork
reasons = tuple(name for name, needed in (("APK build inputs changed or unrecorded", rebuild),
("APK differs", apk), ("Game data differs", data), ("Runtime configuration differs", configure),
("Artwork differs", artwork), ("Steam entry differs", shortcut)) if needed)
return UpdatePlan(rebuild, apk, data, configure, artwork, shortcut, reasons)
+244 -25
View File
@@ -3,13 +3,15 @@ from __future__ import annotations
import contextlib
import posixpath
import threading
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from dataclasses import dataclass
from pathlib import Path
from ..artwork import fetch as artwork
from ..build import sha256
from ..core.events import Reporter
from ..core.events import Cancelled, Reporter
from ..core.models import Recipe, data_manifest
from ..frame.connection import Frame, sh_quote
from ..patches import base
@@ -39,6 +41,56 @@ def install_context(recipe: Recipe) -> base.InstallContext:
return ctx
def configuration_args(entry: dict, recipe: Recipe) -> dict:
"""One complete configuration for both an APK install and the configuration-only path."""
from ..game_update import configuration, configuration_fingerprint
ctx = install_context(recipe)
original = entry["recipe"]
build = entry.get("build") or {}
applied = configuration(original, recipe.package, build.get("superseded"))
settings = applied["settings"]
return dict(settings=settings, files={k: v.decode() if isinstance(v, bytes) else v
for k, v in ctx.files.items()}, env=ctx.env,
flatscreen=show_window(ctx, _flatscreen(recipe.package)), config_sync=ctx.config_sync,
recipe={"patches": sorted(recipe.patches), "source": recipe.source, "alt": recipe.use_alt},
configuration=applied,
configuration_fp=configuration_fingerprint(original, recipe.package, build.get("superseded")),
apk_recipe_fp=build.get("apk_recipe_fp"), source_version=build.get("source_version"))
def update_existing(frame: Frame, entry: dict, deployment: dict, plan, recipe: Recipe,
reporter: Reporter, manifest: dict | None = None) -> dict:
"""No local converted APK is needed for runtime/data updates to a matching deployed build."""
args = configuration_args(entry, recipe)
# the library's current Steam tags (user tags, genres): the shortcut check after this update writes them
args.update(package=entry["package"], title=entry.get("title") or entry["package"],
apk_sha256=deployment["sha256"], tags=_tags(entry["package"]))
if plan.data:
reporter.stage("Check game data")
prep = frame.agent("prepare", package=entry["package"], title=args["title"])
manifest = manifest or {}
pending = [(Path(entry["data_dir"]) / rel, f"obb/{rel}", size)
for rel, size in manifest.items() if prep["existing_obb"].get(rel) != size]
need = sum(size for _, _, size in pending)
if need > prep["free_bytes"] - (1 << 30):
raise RuntimeError("not enough space on the Frame for the changed game data + 1 GiB headroom")
with transfer_link(frame, reporter, need) as xfer:
if pending:
reporter.stage("Upload changed game data")
upload_files(xfer, pending, prep["incoming"], reporter, max(need, 1))
args.update(obb_manifest=manifest, existing_only=True, consume_artwork=False,
apk_name=deployment.get("apk", "game.apk"))
reporter.stage("Finalize game data")
return frame.agent("finalize", **args)
reporter.stage("Apply game configuration")
result = frame.agent("configure", **args)
if result.get("deployment", {}).get("configuration_fp") != args["configuration_fp"]:
raise RuntimeError("the Frame did not confirm the applied configuration")
reporter.check("Game configuration", True, "applied; used from the game's next start")
return result
def local_data_manifest(data_dir: Path | None, files: list[str] | None = None) -> dict[str, int]:
return data_manifest(data_dir, files)
@@ -66,6 +118,9 @@ class Speed:
def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
from ..core import library
entry = library.game(plan.package)
reporter.stage("Prepare Frame")
apk_sha = sha256(plan.apk)
prep = frame.agent("prepare", package=plan.package, title=plan.title, dest=plan.dest,
@@ -93,7 +148,10 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
reporter.check_cancel()
reporter.progress(done / total, f"APK {done / 2**20:.0f}/{size / 2**20:.0f} MiB",
speed=speed.text(done))
xfer.put(plan.apk, posixpath.join(incoming, "game.apk"), apk_cb)
if plan.apk.stat().st_size >= BIG_FILE:
upload_files(xfer, [(plan.apk, "game.apk", plan.apk.stat().st_size)], incoming, reporter, total)
else:
xfer.put(plan.apk, posixpath.join(incoming, "game.apk"), apk_cb)
sent += plan.apk.stat().st_size
if to_send:
reporter.stage(f"Upload data ({len(to_send)} files)")
@@ -102,19 +160,25 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
elif manifest:
reporter.log("game data already on the Frame; not re-sending")
artwork.fetch(plan.package, plan.apk)
upload_steam_art(frame, plan.package, prep["base"], reporter)
artwork_manifest = None
if not prep.get("installed") or (entry or {}).get("steam_art_stale"):
artwork.fetch(plan.package, plan.apk)
artwork_manifest = upload_steam_art(frame, plan.package, prep["base"], reporter)
reporter.stage("Finalize install")
ctx = install_context(plan.recipe)
config = configuration_args(entry, plan.recipe) if entry else dict(
settings=ctx.adapter_settings, files={k: v.decode() if isinstance(v, bytes) else v
for k, v in ctx.files.items()}, env=ctx.env,
flatscreen=show_window(ctx, _flatscreen(plan.package)), config_sync=ctx.config_sync or None,
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source, "alt": plan.recipe.use_alt})
if artwork_manifest is not None:
config["artwork_manifest"] = artwork_manifest
result = frame.agent(
"finalize", package=plan.package, title=plan.title, dest=plan.dest, apk_sha256=apk_sha,
tags=_tags(plan.package),
apk_name=plan.apk.name, settings=ctx.adapter_settings,
files={k: v.decode() if isinstance(v, bytes) else v for k, v in ctx.files.items()}, env=ctx.env,
obb_manifest=manifest or None, flatscreen=show_window(ctx, _flatscreen(plan.package)),
config_sync=ctx.config_sync or None,
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source, "alt": plan.recipe.use_alt},
apk_name=plan.apk.name, obb_manifest=manifest if not plan.apk_only else None,
preserve_data=plan.apk_only, consume_artwork=artwork_manifest is not None, **config,
)
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
log_controller_models(result.get("controller_models"), reporter)
@@ -356,10 +420,102 @@ def ensure_proton(frame: Frame, reporter: Reporter, tool: str | None = None, tim
raise RuntimeError(f"{name} didn't finish installing on the Frame in time; check Steam's downloads on the Frame")
BIG_FILE = 8 << 20 # bigger files go one by one (resumable); smaller ones are streamed in batches
BIG_FILE = 8 << 20 # bigger files use resumable SFTP; smaller ones are streamed in batches
BATCH_BYTES, BATCH_FILES = 256 << 20, 2000
def upload_threads(value=None) -> int:
"""Bound the saved preference; the one-job FIFO still bounds concurrent installs."""
from ..core import library
if value is None:
value = library.setting("install.upload_threads", 3)
try:
return max(1, min(8, int(value)))
except (TypeError, ValueError, OverflowError):
return 3
def _upload_parallel(frame, items, remote_root, reporter, total, sent, workers):
"""Each worker owns one transport; failures stop the cohort and leave resumable .part files."""
lock, stop, local = threading.Lock(), threading.Event(), threading.local()
sessions, errors, progress, offsets = [], [], {}, {}
speed = Speed(total - sent)
def upload(item):
path, rel, size = item
try:
reporter.check_cancel()
if stop.is_set():
raise Cancelled()
if not hasattr(local, "frame"):
session = frame.upload_session()
with lock:
sessions.append(session)
if stop.is_set():
session.close()
raise Cancelled()
local.frame = session
def show(done, size):
reporter.check_cancel()
if stop.is_set():
raise Cancelled()
with lock:
offsets.setdefault(rel, done)
progress[rel] = max(progress.get(rel, 0), done)
completed = sum(progress.values())
resumed = sum(offsets.values())
speed.total = total - sent - resumed
reporter.progress((sent + completed) / max(total, 1),
f"{rel} {done / 2**20:.0f}/{size / 2**20:.0f} MiB",
speed=speed.text(completed - resumed))
def check_cancel():
reporter.check_cancel()
if stop.is_set():
raise Cancelled()
local.frame.put(path, posixpath.join(remote_root, rel), show, mkdir=False, check_cancel=check_cancel)
except Exception as exc:
with lock:
if not errors:
errors.append(exc)
stop.set()
raise
try:
with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="frameport-upload") as pool:
pending = {pool.submit(upload, item) for item in items}
interrupted = False
while pending:
if reporter.cancelled.is_set():
with lock:
if not errors:
errors.append(Cancelled())
stop.set()
done, pending = wait(pending, timeout=0.2, return_when=FIRST_COMPLETED)
for future in done:
if not future.cancelled():
try:
future.result()
except Exception:
stop.set()
if stop.is_set() and not interrupted:
interrupted = True
for future in pending:
future.cancel()
pending = {future for future in pending if not future.cancelled()}
with lock:
active = list(sessions)
for session in active:
session.close() # interrupt blocked transfers; never close the control connection
if errors:
raise errors[0]
finally:
for session in sessions:
session.close()
return sent + sum(size for _, _, size in items)
FAST_LINK_MIN = 64 << 20 # below this a second connection isn't worth it
@@ -384,12 +540,14 @@ def transfer_link(frame: Frame, reporter: Reporter, need: int):
def upload_files(frame: Frame, items: list[tuple[Path, str, int]], remote_root: str, reporter: Reporter,
total: int, sent: int = 0, speed: Speed | None = None) -> int:
total: int, sent: int = 0, speed: Speed | None = None, workers: int | None = None,
prefer_native: bool = True) -> int:
"""Upload (local, relative path, size) items under remote_root. Interruptible (Cancel, dropped connection) and
resumable: finished files are skipped next time (the agent counts files already in incoming/), a cut-off big file
continues from its .part. Returns bytes sent."""
big = [i for i in items if i[2] >= BIG_FILE]
big = sorted((i for i in items if i[2] >= BIG_FILE), key=lambda item: item[2], reverse=True)
small = [i for i in items if i[2] < BIG_FILE]
reporter.check_cancel()
frame.mkdirs(posixpath.dirname(posixpath.join(remote_root, rel)) for _, rel, _ in big)
state = {"sent": sent}
speed = speed or Speed(total)
@@ -398,12 +556,45 @@ def upload_files(frame: Frame, items: list[tuple[Path, str, int]], remote_root:
reporter.check_cancel()
done = state["sent"] + extra
reporter.progress(done / total, label, speed=speed.text(done))
for local, rel, size in big:
reporter.check_cancel()
frame.put(local, posixpath.join(remote_root, rel),
lambda done, size, rel=rel: show(done, f"{rel} {done / 2**20:.0f}/{size / 2**20:.0f} MiB"),
mkdir=False)
state["sent"] += size
threads = min(upload_threads(workers), len(big))
native_done = False
if big and prefer_native and isinstance(frame, Frame):
from ..frame import native_upload
original = reporter
class ProgressReporter:
last = 0.0
def __getattr__(self, name):
return getattr(original, name)
def progress(self, fraction, label, **kwargs):
self.last = max(self.last, fraction)
original.progress(self.last, label, **kwargs)
reporter = ProgressReporter() # fallback resumes in a different order; never move the progress bar backward
try:
state["sent"] = native_upload.upload(frame, big, remote_root, reporter, total, sent, max(threads, 1))
native_done = True
except Cancelled:
raise
except Exception as exc: # native startup, authentication or transfer failure: retain the portable path
reporter.check_cancel()
reporter.log(f"OpenSSH unavailable or failed; resuming with Paramiko: {exc}")
speed.samples.clear()
if native_done:
pass
elif threads > 1 and hasattr(frame, "upload_session"):
reporter.log(f"uploading large files with {threads} workers")
state["sent"] = _upload_parallel(frame, big, remote_root, reporter, total, sent, threads)
speed.samples.clear()
else:
for local, rel, size in big:
reporter.check_cancel()
frame.put(local, posixpath.join(remote_root, rel),
lambda done, size, rel=rel: show(done, f"{rel} {done / 2**20:.0f}/{size / 2**20:.0f} MiB"),
mkdir=False)
state["sent"] += size
batch: list[tuple[Path, str]] = []
batch_bytes = 0
@@ -455,7 +646,16 @@ def _flatscreen(package: str) -> bool:
return bool(g.get("analysis")) and library.analysis_from_dict(g["analysis"]).vr_kind == "none"
def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter) -> None:
def art_manifest(package: str, art: dict) -> dict[str, str]:
import hashlib
from ..artwork.sources import icon_source
return {**{f"{kind}{path.suffix}": sha256(path) for kind, path in art.items()},
".icon-source": hashlib.sha256(icon_source(package).encode()).hexdigest()}
def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter) -> dict[str, str]:
"""The complete Steam art set (portrait, wide, hero, logo, icon — composed where the store has no such shape)."""
from ..artwork.steam import steam_set_for
@@ -467,6 +667,7 @@ def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter)
frame.put(f, posixpath.join(remote_art, f"{kind}{f.suffix}"), resume=False)
send_icon_source(frame, package, remote_art)
reporter.check("Steam artwork", bool(art) or None, ", ".join(sorted(art)) or "none found")
return art_manifest(package, art)
def send_icon_source(frame: Frame, package: str, remote_art: str) -> None:
@@ -509,7 +710,8 @@ def update_steam_art(frame: Frame, package: str, reporter: Reporter, title: str
"""Replace an installed game's Steam library art with the current artwork (after the user picked new art):
the art set goes to the game's anchor, then the shortcut is rewritten (Steam restarts once). `title`: the game's
name in the library; a different name on the Frame is renamed first (agent v77 `rename`: the shortcut keeps its
appid, so Play, grid art and saves stay). Placeholder art shows the name, so it's sent again too."""
appid, so Play, grid art and saves stay). Placeholder art shows the name, so it's sent again too.
The result has `library: True` when the shortcut was rewritten too (else the caller still checks it)."""
from ..artwork.steam import steam_set_for
dep = next((d for d in frame.agent("list_installed")["games"] if d.get("package") == package), None)
@@ -526,16 +728,33 @@ def update_steam_art(frame: Frame, package: str, reporter: Reporter, title: str
art = steam_set_for(package)
if not art:
if renamed:
return add_to_steam(frame, [package], reporter)
return {**(add_to_steam(frame, [package], reporter) or {}), "library": True}
raise RuntimeError("no artwork to send")
manifest = art_manifest(package, art)
previous = dep.get("artwork_manifest") or {}
if manifest == previous:
reporter.log("Steam artwork already current; not re-sending")
return {**(add_to_steam(frame, [package], reporter) or {}), "library": True} if renamed else {"unchanged": True}
reporter.stage("Artwork for the Steam library")
remote = posixpath.join(dep["anchor"], "artwork")
frame.run(f"rm -rf {sh_quote(remote)} && mkdir -p {sh_quote(remote)}")
if not previous:
# Unrecorded art (installs before agent v86): what's in the folder is unknown, and an old portrait.jpg next
# to a new portrait.png would be picked at random for the Steam grid. Start clean as before; all is sent.
frame.run(f"rm -rf -- {sh_quote(remote)}")
frame.run(f"mkdir -p {sh_quote(remote)}")
for name in previous.keys() - manifest.keys():
# Deployment records are data, not shell paths. Never act on an unexpected name.
if "/" not in name and name not in (".", ".."):
frame.run(f"rm -f -- {sh_quote(posixpath.join(remote, name))}")
for kind, f in art.items():
frame.put(f, posixpath.join(remote, f"{kind}{f.suffix}"), resume=False)
send_icon_source(frame, package, remote)
name = f"{kind}{f.suffix}"
if previous.get(name) != manifest[name]:
frame.put(f, posixpath.join(remote, name), resume=False)
if previous.get(".icon-source") != manifest[".icon-source"]:
send_icon_source(frame, package, remote)
frame.agent("record_artwork", package=package, manifest=manifest)
reporter.check("Steam artwork", True, ", ".join(sorted(art)))
return add_to_steam(frame, [package], reporter)
return {**(add_to_steam(frame, [package], reporter) or {}), "library": True}
def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: float = 120) -> dict:
+3 -1
View File
@@ -781,6 +781,7 @@
"KiB": "",
"Language": "",
"Language packs from the game's files": "",
"Large files upload together using OpenSSH when available. Default: 3. Lower this on a busy network.": "",
"Last build": "",
"Last launch test's log": "",
"Last launch test: passed": "",
@@ -1052,6 +1053,7 @@
"OVRPort's own VrApi→OpenXR adapter for engines that call libvrapi.so directly: the same upstream code as FramePort's 'VrApi → OpenXR bridge' without its Frame-specific changes. Only in OVRPort's experimental CLI builds (the stable CLI lists it but can't apply it).": "",
"OVRPort's platform loader answers the Meta Platform SDK's language-pack calls (ovr_LanguagePack_GetCurrent/SetCurrent) with 'no request', so a game that asks for its language pack never gets an answer. Adds a small library that serves language packs (<tag>.lang, for example de.lang) found in the game's own data folders (OBB and app files) through the same calls, the asset-file list and the status call, without any download. The game then finds the file path itself. The pack it applies with SetCurrent wins; otherwise the environment variable FRAMEPORT_LANGPACK (a tag), otherwise the only pack there is. Everything else goes to the loader unchanged. Off by default: turn it on for a game whose data has such files (the game page lists the patch only then).": "",
"Package": "",
"Parallel uploads": "",
"passed": "",
"Password (first time only)": "",
"Paste an install link: an install button's address, a manifest (.json) or a direct link to an APK, .zip or .exe.": "",
@@ -1529,6 +1531,7 @@
"The Frame's Steam library still shows the old artwork.": "",
"The Frame's Steam library still shows the old name.": "",
"the game failed its checks (see the list above)": "",
"The game's files or settings changed. Update applies only what's needed; saves are kept.": "",
"The headset is asleep: put it on, then take the screenshot.": "",
"The headset's view and sound open in an mpv window. Open it in your browser any time.": "",
"The headset's view and sound open in your web browser. Stop the stream when you're done.": "",
@@ -1547,7 +1550,6 @@
"The PC VR counterpart of OVRPort's 'Patch Oculus detection for Unreal'. Unreal's Oculus plugin only starts when the Oculus service announces a headset (the Windows event 'OculusHMDConnected'); without it the game runs as a flat window. On the Frame the launcher runs the game through FramePort's small helper (fp_oculushmd.exe) that provides that event while the game runs, instead of relying only on Revive's hook of the check. Ignored on this PC (the Oculus app or Revive handle it there).": "",
"the previous version, kept as a backup": "",
"The processor types the game has code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games can't run on it.": "",
"The recipe or your game files changed since the install. Update to install the new build; saves are kept.": "",
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
"The store's name": "",
"The time the Frame spends collecting these numbers.": "",
@@ -38,6 +38,7 @@ class TbxrVendor(Patch):
"the Meta path. Keeps GL4ES in charge of its desktop OpenGL draw calls so animated models "
"don't turn into stretched triangles.")
order = 47
revision = 2 # 2: GL4ES keeps its draw calls (#191). Ports without GL4ES rebuild to the same APK (nothing re-sent)
def applies(self, a):
return bool((a.extra or {}).get("tbxr_libs")) and "arm64-v8a" in a.abis
+160 -12
View File
@@ -598,7 +598,10 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
if reporter:
reporter.log(f"analyzing {src.apk.name}")
a = analyze(src.apk, data_bytes=src.data_bytes())
from .core.models import data_manifest
manifest = data_manifest(src.data_dir, src.data_files)
a = analyze(src.apk, data_bytes=sum(manifest.values()))
a.extra["lang_packs"] = langpacks.find_tags(_data_folder(src))
a.extra["asset_files"] = langpacks.find_content_files(_data_folder(src))
recipe = engine.suggest(a)
@@ -607,7 +610,8 @@ def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
names = {"auto_title": recipe.title or a.label} if old.get("title_locked") else {"title": recipe.title or a.label}
return library.upsert_game(
a.package, **names, name=src.name, apk=str(src.apk),
data_dir=str(src.data_dir) if src.data_dir else None, data_files=src.data_files, data_bytes=src.data_bytes(),
data_dir=str(src.data_dir) if src.data_dir else None, data_files=src.data_files,
data_bytes=sum(manifest.values()), data_manifest=manifest, source_sha256=builder.sha256(src.apk),
origin=str(src.origin),
analysis=a.to_dict(), recipe=library.recipe_to_dict(recipe), suggested=library.recipe_to_dict(recipe),
status=recipe.status,
@@ -852,8 +856,10 @@ def prepare_as_is(package: str, reporter: Reporter) -> dict:
art, store_title = artwork.fetch(package, apk)
info = {"apk": str(apk), "alt_apk": None, "sha256": sha256(apk), "alt_sha256": None, "applied": [],
"checks": checks, "ok": all(c["ok"] is not False for c in checks), "as_is": True,
"recipe_fp": recipe_fingerprint(entry["recipe"], package), "source_version": _source_version(entry)}
library.upsert_game(package, build=info, title=entry.get("title") or store_title)
"recipe_fp": recipe_fingerprint(entry["recipe"], package), "source_version": _source_version(entry),
**_build_record(entry)}
library.upsert_game(package, build=info, title=entry.get("title") or store_title,
source_sha256=info["source_sha256"])
return info
@@ -877,6 +883,78 @@ def _build_lock(package: str) -> threading.Lock:
return _build_locks.setdefault(package, threading.Lock())
def _build_inputs(entry: dict, native_names: list[str] | None = None) -> dict:
"""Only the native assets carried by this APK and the local converter; no downloads or Java invocations."""
from .core.paths import artifacts_dir
from .tools import toolchain
build = entry.get("build") or {}
previous = build.get("build_inputs") or {}
recipe = entry.get("recipe") or {}
native_names = native_names if native_names is not None else build.get("native_names") or []
native = previous.get("native") or {}
manifest = artifacts_dir() / "SHA256SUMS"
if manifest.is_file():
checksums = {line.split(maxsplit=1)[1].strip().removeprefix("./"): line.split()[0]
for line in manifest.read_text().splitlines() if len(line.split()) == 2}
native = {name: checksums.get(name) for name in native_names}
jar = toolchain.overport_jar() if recipe.get("overport", True) and not recipe.get("as_is") else None
converter = previous.get("overport")
if jar and jar.is_file():
st = jar.stat()
stat = {"path": str(jar), "size": st.st_size, "mtime_ns": st.st_mtime_ns}
if stat != build.get("overport_stat"):
converter = builder.sha256(jar)
return {"native": native, "overport": converter}
def _source_stat(entry: dict) -> dict | None:
source = Path(entry["apk"])
try:
st = source.stat()
except OSError:
return None
return {"path": str(source.resolve()), "size": st.st_size, "mtime_ns": st.st_mtime_ns}
def _build_record(entry: dict, apk: Path | None = None, superseded=None) -> dict:
import zipfile
from .core.paths import artifacts_dir
from .game_update import apk_recipe_fingerprint
from .tools import toolchain
native_names = []
if apk and not entry["recipe"].get("as_is"):
with zipfile.ZipFile(apk) as archive:
native_names = [name.removeprefix("lib/") for name in archive.namelist()
if name.startswith("lib/") and (artifacts_dir() / name.removeprefix("lib/")).is_file()]
if "frame.oculusos" in (entry["recipe"].get("patches") or {}):
native_names.append("dex/oculusos-stubs.dex")
jar = toolchain.overport_jar()
converter_stat = None
if jar and jar.is_file():
st = jar.stat()
converter_stat = {"path": str(jar), "size": st.st_size, "mtime_ns": st.st_mtime_ns}
return {"apk_recipe_fp": apk_recipe_fingerprint(entry["recipe"], entry["package"], superseded),
"source_stat": _source_stat(entry), "source_sha256": builder.sha256(Path(entry["apk"])),
"build_inputs": _build_inputs(entry, native_names), "native_names": native_names,
"overport_stat": converter_stat}
def _build_inputs_changed(entry: dict) -> bool:
build = entry.get("build") or {}
if not build.get("build_inputs") or build["build_inputs"] != _build_inputs(entry):
return True
stat = _source_stat(entry)
if stat is None:
# An unavailable dump does not prevent configuration updates to a known installed APK.
return False
if stat != build.get("source_stat"):
return builder.sha256(Path(entry["apk"])) != build.get("source_sha256")
return False
def build_game(package: str, reporter: Reporter, outdir: Path | None = None) -> dict:
if analysis_outdated(library.game(package) or {}): # analysed by an older FramePort: new fields first
refresh_analyses([package], reporter)
@@ -897,8 +975,10 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
build_info = {"apk": str(res.apk), "alt_apk": str(res.alt_apk) if res.alt_apk else None, "sha256": res.sha256,
"alt_sha256": res.alt_sha256, "applied": res.applied, "checks": res.checks, "ok": res.ok,
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"], package),
"superseded": res.meta.get("superseded") or {}, "source_version": _source_version(entry)}
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title)
"superseded": res.meta.get("superseded") or {}, "source_version": _source_version(entry),
**_build_record(entry, res.apk, res.meta.get("superseded"))}
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title,
source_sha256=build_info["source_sha256"])
return build_info
@@ -918,33 +998,101 @@ def _find_late_data(entry: dict, reporter: Reporter | None = None) -> dict:
def install_game(package: str, target: Target, reporter: Reporter, apk_only: bool = False,
add_to_library: bool = True, apk: Path | None = None) -> dict:
add_to_library: bool = True, apk: Path | None = None, force: bool = False) -> dict:
"""Install the game's last build (or `apk`, e.g. a test build of the same package signed with the same key)."""
reporter = reporter or Reporter()
entry = library.game(package)
if is_linux(entry):
return install_linux(package, target, reporter, add_to_library)
if is_rift(entry):
return install_rift(package, target, reporter, add_to_library)
from .game_update import plan_update
test_build = apk is not None
optimized = not test_build and hasattr(target, "update_existing")
deployment, update, manifest = None, None, None
if optimized:
reporter.stage("Check update requirements")
deployment = next((d for d in target.installed() if d.get("package") == package), None)
if not apk_only:
entry = _find_late_data(entry, reporter) if not entry.get("data_dir") and entry.get("apk") else entry
from .core.models import data_manifest
manifest = data_manifest(Path(entry["data_dir"]) if entry.get("data_dir") else None,
entry.get("data_files"))
# Cache desired data for badges without doing filesystem I/O in render paths.
library.update_game(package, lambda game: game.update(data_manifest=manifest))
entry["data_manifest"] = manifest
update = plan_update(entry, deployment, apk_only=apk_only, data_manifest=manifest)
if update.data and not update.apk and hasattr(target, "deployment"):
deployment = target.deployment(package)
update = plan_update(entry, deployment, apk_only=apk_only, data_manifest=manifest)
if force or _build_inputs_changed(entry):
update = dataclasses.replace(update, rebuild=True, apk=True, configure=True)
for reason in update.reasons:
reporter.log(reason)
if update.rebuild:
info = build_game(package, reporter)
if not info["ok"]:
raise RuntimeError("the game failed its checks (see the list above)")
entry = library.game(package)
b = entry.get("build") or {}
recipe = library.recipe_from_dict(entry["recipe"])
if b.get("superseded"):
recipe = dataclasses.replace(recipe, patches=upstream.without_superseded(recipe.patches, b["superseded"]))
if deployment and not update.apk:
if update.configure or update.data:
result = target.update_existing(entry, deployment, update, recipe, reporter, manifest)
else:
reporter.log("APK, game data and runtime configuration already current")
result = {"base": deployment["base"], "appid": deployment["appid"]}
art = None
if update.artwork or update.shortcut:
art = target.update_steam_art(package, reporter, title=steam_title(entry))
library.update_game(package, lambda game: game.pop("steam_art_stale", None))
# The deployment can't tell whether Steam still has the shortcut (a connection lost during "Add to
# Steam", or Steam putting its old shortcuts.vdf back, GitHub #21/#41): check it every time. The agent
# restarts Steam only when the library entry really changes.
check_shortcut = not (art or {}).get("library")
if add_to_library and check_shortcut:
target.add_to_library([package], reporter)
result["launch_test_required"] = update.launch_test
result["shortcut_required"] = check_shortcut
result["stage_seconds"] = reporter.stage_timings()
_record_install(package, target.label, {"result": result, "time": time.time()})
return result
b = entry.get("build") or {}
recipe = library.recipe_from_dict(entry["recipe"])
if not test_build and not b.get("apk"): # never built yet (e.g. `frameport install` right after a scan)
if not test_build and not b.get("apk"):
build_game(package, reporter)
return install_game(package, target, reporter, apk_only, add_to_library)
apk = Path(apk) if apk else Path(b["alt_apk"] if recipe.use_alt and b.get("alt_apk") else b["apk"])
if not test_build and not apk.exists(): # the converted copy was removed after an earlier install: make it again
if not test_build and not apk.is_file():
build_game(package, reporter)
return install_game(package, target, reporter, apk_only, add_to_library)
if not test_build and b.get("superseded"): # workarounds this build left out (upstream fixed): not in settings.conf
if optimized and not update.rebuild:
expected_sha = b.get("alt_sha256") if recipe.use_alt else b.get("sha256")
reporter.check_cancel()
if builder.sha256(apk) != expected_sha:
reporter.log("the cached APK changed; rebuilding before installation")
build_game(package, reporter)
return install_game(package, target, reporter, apk_only, add_to_library)
if not test_build and b.get("superseded"):
recipe = dataclasses.replace(recipe, patches=upstream.without_superseded(recipe.patches, b["superseded"]))
if not entry.get("data_dir") and entry.get("apk") and not apk_only:
entry = _find_late_data(entry, reporter) # OBBs copied next to the APK after it was added (GitHub #156)
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
title = steam_title(entry)
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only,
preserve_data = apk_only or (optimized and deployment is not None and not update.data and not force)
result = target.install(package, title, apk, data_dir, recipe, reporter, preserve_data,
data_files=entry.get("data_files"))
if add_to_library:
if add_to_library: # also after a reinstall: the agent restarts Steam only when the entry changes
target.add_to_library([package], reporter)
result["launch_test_required"] = True
result["shortcut_required"] = True
result["stage_seconds"] = reporter.stage_timings()
if optimized and update.artwork:
library.update_game(package, lambda game: game.pop("steam_art_stale", None))
_record_install(package, target.label, {"apk": str(apk), "result": result, "time": time.time()})
if not test_build:
remove_converted_copies(package)
+11
View File
@@ -39,6 +39,10 @@ class FrameLeptonTarget(Target):
def installed(self) -> list[dict]:
return self.connect().frame.agent("list_installed")["games"]
def deployment(self, package: str) -> dict | None:
games = self.connect().frame.agent("list_installed", package=package, details=True)["games"]
return games[0] if games else None
def install(self, package, title, apk: Path, data_dir, recipe: Recipe, reporter: Reporter, apk_only=False,
data_files=None):
plan = installer.InstallPlan(package, title, apk, data_dir, recipe, apk_only, dest=self.install_dest(),
@@ -48,6 +52,9 @@ class FrameLeptonTarget(Target):
def add_to_library(self, packages, reporter):
return installer.add_to_steam(self.connect().frame, packages, reporter)
def update_existing(self, entry, deployment, plan, recipe, reporter, manifest=None):
return installer.update_existing(self.connect().frame, entry, deployment, plan, recipe, reporter, manifest)
def update_steam_art(self, package, reporter, title=None):
return installer.update_steam_art(self.connect().frame, package, reporter, title=title)
@@ -67,6 +74,10 @@ class FrameLeptonTarget(Target):
return self.connect().frame.agent("launch", package=package, timeout=60)
def set_settings(self, package, settings):
from ..core import library
superseded = ((library.game(package) or {}).get("build") or {}).get("superseded") or {}
settings = {key: value for key, value in settings.items() if f"adapter.{key}" not in superseded}
return self.connect().frame.agent("set_settings", package=package, settings=settings)
def uninstall(self, package, keep_data=True):
+12 -6
View File
@@ -268,7 +268,10 @@ class FramePortApp:
if sys.platform == "win32":
from ..diag import display
display.record_window(event, self.page.window, theme=T.THEME, reduce_motion=self.reduce_motion)
try: # diagnostics only: never let a window property stop the app from starting
display.record_window(event, self.page.window, theme=T.THEME, reduce_motion=self.reduce_motion)
except Exception as exc: # noqa: BLE001
applog.log.debug("window diagnostics: %s", exc)
def _on_window_event(self, e) -> None:
"""Remember the window's size and position (once a resize/move ends; not while maximized or full screen) and
@@ -1527,6 +1530,8 @@ class FramePortApp:
if not g:
return None # removed from the library meanwhile
rift = g.get("kind") == "rift"
# An explicit install action on a current game is the existing "Reinstall" action.
force = to == "frame" and C.install_state(g, getattr(self, "frame_info", None)) == "installed"
where = tr("your Frame") if to == "frame" else tr("this PC")
self._record(pkg, to=to, state="queued", apk_only=apk_only)
@@ -1535,12 +1540,13 @@ class FramePortApp:
self._record(pkg, to=to, state="running")
as_is = library.recipe_from_dict(library.game(pkg)["recipe"]).as_is
rep.stage("Checking the game" if rift or as_is else "Patching the game")
info = pipeline.build_game(pkg, rep)
if not info["ok"]:
raise RuntimeError(tr("the game failed its checks (see the list above)"))
if rift or to != "frame":
info = pipeline.build_game(pkg, rep)
if not info["ok"]:
raise RuntimeError(tr("the game failed its checks (see the list above)"))
rep.check_cancel()
target = self._target_for(to)
pipeline.install_game(pkg, target, rep, apk_only=apk_only)
result = pipeline.install_game(pkg, target, rep, apk_only=apk_only, force=force)
self._record(pkg, remove=True) # installed; the launch test below is a separate question
if to == "frame": # the install sent the current artwork to the Frame's Steam library
library.update_game(pkg, lambda e: e.pop("steam_art_stale", None))
@@ -1553,7 +1559,7 @@ class FramePortApp:
rep.stage("Installed")
return tr("{get} is installed on {where}: SteamVR shows it over your games").format(
get=g.get('title'), where=where)
if not library.setting("install.launch_test", True): # Settings → Installing
if result.get("launch_test_required") is False or not library.setting("install.launch_test", True):
rep.stage("Installed")
return tr("{get} is installed on {where}: put the headset on and launch it from your Steam "
"library").format(get=g.get('title'), where=where)
+4
View File
@@ -174,6 +174,10 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
return "missing"
b = game.get("build") or {}
if game.get("kind") not in ("linux", "rift") and b.get("apk_recipe_fp") and dep.get("configuration_fp"):
from ..game_update import plan_update
return "outdated" if plan_update(game, dep).changed else "installed"
built = {b.get("sha256"), b.get("alt_sha256")} - {None}
if built and dep.get("sha256") and dep["sha256"] not in built:
return "outdated"
+1 -2
View File
@@ -22,8 +22,7 @@ HELP: dict[str, str] = _Translated({
# ---- game status / library
"status": "How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" were tested on a Frame; "
"\"Untested\" is a suggestion nobody has confirmed yet. \"Can't run\" means a known blocker.",
"update_ready": "The recipe or your game files changed since the install. Update to install the new build; saves "
"are kept.",
"update_ready": "The game's files or settings changed. Update applies only what's needed; saves are kept.",
"check_exe": "FramePort isn't sure which program starts this game. Open the game and use … → Change program… to "
"pick it.",
"platform_quest": "A Meta Quest game. FramePort patches it for the Frame, where it runs in Lepton, Valve's Android "
+10 -1
View File
@@ -94,12 +94,16 @@ class SettingsView:
def installing(self) -> ft.Control:
from ...core import library
from ...install.installer import upload_threads
def changed(e):
library.set_setting("install.launch_test", bool(e.control.value))
def keep_changed(e):
library.set_setting("build.keep_copies", bool(e.control.value))
def threads_changed(e):
library.set_setting("install.upload_threads", upload_threads(e.control.value))
def video_changed(e):
library.set_setting("video.hw_decode", bool(e.control.value))
target = self.app.target if self.app.frame_state == "connected" else None
@@ -120,7 +124,12 @@ class SettingsView:
value=bool(library.setting("video.hw_decode", True)),
on_change=video_changed), expand=True),
C.help_icon("hw_video_decode")], spacing=T.S1)
return ft.Column([launch, keep, video,
parallel = C.dropdown(label=tr("Parallel uploads"), value=str(upload_threads()), width=T.px(240),
options=[ft.DropdownOption(str(n), str(n)) for n in range(1, 9)],
on_select=threads_changed)
return ft.Column([launch, keep, video, parallel,
C.meta(tr("Large files upload together using OpenSSH when available. "
"Default: 3. Lower this on a busy network.")),
C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED, clean,
tooltip=tr("Your own game files aren't touched."))],
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
+3 -1
View File
@@ -52,7 +52,9 @@ def test_window_events_are_cached_without_running_queries(monkeypatch, caplog):
])
def test_event_query_distinguishes_empty_results_from_failures(monkeypatch, response, status):
def run(args, **kwargs):
assert args[:4] == ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive"]
assert args[0].lower().endswith(("powershell.exe", "powershell"))
assert args[1:4] == ["-NoLogo", "-NoProfile", "-NonInteractive"]
assert not any(k.upper() == "PSMODULEPATH" for k in kwargs["env"]) # pwsh 7's modules break 5.1
assert kwargs["timeout"] == 5 and kwargs["creationflags"] == 0x08000000
assert "-FilterXPath" in args[-1] and "-MaxEvents 60" in args[-1]
assert ".Message" not in args[-1] # event metadata, not unrelated free text or device IDs
+519
View File
@@ -0,0 +1,519 @@
"""Update classification and the operations it must skip, including persisted live settings."""
from pathlib import Path
import pytest
from test_agent import load_agent
from frameport import pipeline
from frameport.core import library
from frameport.core.events import Reporter
from frameport.game_update import apk_recipe_fingerprint, configuration, configuration_fingerprint, plan_update
from frameport.ui.components import install_state
PKG = "com.example.update"
def game_and_dep(patches=None):
recipe = {"package": PKG, "source": "user", "patches": patches or {"adapter.scale": {"value": 1.5}}}
game = {"package": PKG, "title": "Example", "recipe": recipe, "analysis": {"version": "1"},
"build": {"apk": "/missing/converted.apk", "sha256": "apk-a", "ok": True,
"source_version": "1", "apk_recipe_fp": apk_recipe_fingerprint(recipe, PKG)}}
dep = {"package": PKG, "base": "/frame/game", "appid": 42, "sha256": "apk-a", "apk_present": True,
"configuration_fp": configuration_fingerprint(recipe, PKG),
"configuration": configuration(recipe, PKG), "data_manifest": {"main.obb": 4}}
return game, dep
def test_custom_recipe_and_catalog_metadata_do_not_force_update():
game, dep = game_and_dep()
game["recipe"].update(catalog_rev="new", notes="changed", title="New", source="user")
game["catalog_update"] = "newer-tested-recipe"
assert not plan_update(game, dep).changed
assert install_state(game, {"installed": [dep]}) == "installed"
def test_runtime_parameter_change_and_removal_do_not_rebuild():
game, dep = game_and_dep()
game["recipe"]["patches"]["adapter.scale"]["value"] = 1.2
plan = plan_update(game, dep)
assert plan.configure and not plan.rebuild and not plan.apk and not plan.launch_test
game["recipe"]["patches"].clear()
assert configuration(game["recipe"], PKG)["settings"]["scale"] == 1.0
assert plan_update(game, dep).configure and not plan_update(game, dep).rebuild
dep["configuration_fp"] = configuration_fingerprint(game["recipe"], PKG)
assert not plan_update(game, dep).changed
@pytest.mark.parametrize("patch", ["frame.unity_text_input", "device.foveation", "adapter.controller_models",
"adapter.surface_native", "adapter.zink_shader_dump"])
def test_apk_support_changes_require_rebuild(patch):
game, dep = game_and_dep()
game["recipe"]["patches"][patch] = {}
assert plan_update(game, dep).rebuild
assert plan_update(game, dep).launch_test
def test_alternate_variant_can_reuse_an_existing_build():
game, dep = game_and_dep()
game["build"].update(alt_sha256="apk-b", alt_apk="/missing/alt.apk")
game["recipe"]["use_alt"] = True
plan = plan_update(game, dep)
assert plan.apk and not plan.rebuild
dep["sha256"] = "apk-b"
assert not plan_update(game, dep).rebuild
game["build"].pop("alt_sha256")
assert plan_update(game, dep).rebuild
def test_data_and_artwork_have_separate_actions():
game, dep = game_and_dep()
plan = plan_update(game, dep, data_manifest={"main.obb": 5})
assert plan.data and plan.launch_test and not plan.apk and not plan.rebuild
assert not plan_update(game, dep, data_manifest={"main.obb": 5}, apk_only=True).data
game["steam_art_stale"] = True
plan = plan_update(game, dep)
assert plan.artwork and plan.shortcut and not plan.launch_test
def test_legacy_and_source_changes_use_full_path():
game, dep = game_and_dep()
game["build"].pop("apk_recipe_fp")
assert plan_update(game, dep).rebuild
game, dep = game_and_dep()
game["analysis"]["version"] = "2"
assert plan_update(game, dep).rebuild
def test_superseded_setting_does_not_stay_pending():
game, dep = game_and_dep({"adapter.haptic_fix": {"value": 1}})
game["build"]["superseded"] = {"adapter.haptic_fix": "upstream"}
game["build"]["apk_recipe_fp"] = apk_recipe_fingerprint(game["recipe"], PKG, game["build"]["superseded"])
dep["configuration_fp"] = configuration_fingerprint(game["recipe"], PKG, game["build"]["superseded"])
assert not plan_update(game, dep).changed
assert configuration(game["recipe"], PKG, game["build"]["superseded"])["settings"]["haptic_fix"] == 0
game["recipe"]["patches"].clear() # removing an omitted workaround is also a no-op
assert not plan_update(game, dep).changed
class Target:
label = "test-frame"
def __init__(self, dep):
self.dep, self.calls = dep, []
def installed(self):
return [self.dep]
def update_existing(self, entry, dep, plan, recipe, reporter, manifest=None):
self.calls.append("configure" if not plan.data else "data")
return {"base": dep["base"], "appid": dep["appid"]}
def update_steam_art(self, package, reporter, title=None):
self.calls.append("artwork")
def install(self, *args, **kwargs):
self.calls.append("install")
return {"base": self.dep["base"], "appid": self.dep["appid"]}
def add_to_library(self, *args):
self.calls.append("shortcut")
def store_game(monkeypatch, game):
monkeypatch.setattr(library, "_migrate", lambda data: False)
library.upsert_game(PKG, **{k: v for k, v in game.items() if k != "package"}, apk="/missing/source.apk")
monkeypatch.setattr(pipeline, "_find_late_data", lambda entry, rep: entry)
monkeypatch.setattr(pipeline, "_build_inputs_changed", lambda entry: False)
monkeypatch.setattr(pipeline, "build_game", lambda *a, **k: pytest.fail("unnecessary rebuild"))
def test_pipeline_configure_without_any_local_apk(monkeypatch):
game, dep = game_and_dep()
game["recipe"]["patches"]["adapter.scale"]["value"] = 1.2
store_game(monkeypatch, game)
target = Target(dep)
result = pipeline.install_game(PKG, target, Reporter())
assert target.calls == ["configure", "shortcut"]
assert result["launch_test_required"] is False
assert result["shortcut_required"] is True
def test_pipeline_noop_and_artwork_skip_build_and_install(monkeypatch):
game, dep = game_and_dep()
store_game(monkeypatch, game)
target = Target(dep)
result = pipeline.install_game(PKG, target, Reporter())
# nothing to send, but Steam may have lost the shortcut since: the agent checks it (no restart if present)
assert target.calls == ["shortcut"] and result["launch_test_required"] is False
target.calls.clear()
library.update_game(PKG, lambda entry: entry.update(steam_art_stale=True))
pipeline.install_game(PKG, target, Reporter())
assert target.calls == ["artwork", "shortcut"]
def test_pipeline_artwork_that_rewrote_the_shortcut_is_not_added_twice(monkeypatch):
game, dep = game_and_dep()
store_game(monkeypatch, game)
target = Target(dep)
target.update_steam_art = lambda *a, **k: (target.calls.append("artwork"), {"library": True})[1]
library.update_game(PKG, lambda entry: entry.update(steam_art_stale=True))
result = pipeline.install_game(PKG, target, Reporter())
assert target.calls == ["artwork"] and result["shortcut_required"] is False
def test_pipeline_cli_mode_asks_for_the_shortcut_check(monkeypatch):
game, dep = game_and_dep()
store_game(monkeypatch, game)
target = Target(dep)
result = pipeline.install_game(PKG, target, Reporter(), add_to_library=False)
assert target.calls == [] and result["shortcut_required"] is True
def test_pipeline_changed_data_without_local_apk(monkeypatch, tmp_path):
game, dep = game_and_dep()
data = tmp_path / "data"
data.mkdir()
(data / "main.obb").write_bytes(b"12345")
game["data_dir"] = str(data)
store_game(monkeypatch, game)
target = Target(dep)
result = pipeline.install_game(PKG, target, Reporter())
assert target.calls == ["data", "shortcut"] and result["launch_test_required"] is True
target.calls.clear()
pipeline.install_game(PKG, target, Reporter(), apk_only=True)
assert target.calls == ["shortcut"]
def installed_agent(monkeypatch, tmp_path):
agent = load_agent(monkeypatch, tmp_path)
base = tmp_path / "Applications/quest-frame" / PKG
(base / "lepton-app").mkdir(parents=True)
(base / "lepton-app/game.apk").write_bytes(b"apk")
dep = {"package": PKG, "title": "Example", "base": str(base), "appid": 42, "sha256": "apk-a",
"tags": ["Favorite"], "apk": "original.apk", "apk_size": 3, "data_manifest": {"modded.obb": 4}}
dep["apk_stat"] = {"size": 3, "mtime_ns": (base / "lepton-app/game.apk").stat().st_mtime_ns}
agent.write_deployment(PKG, dep)
monkeypatch.setattr(agent, "new_base", lambda args, pkg, deployment: (deployment["base"], 10 << 30))
monkeypatch.setattr(agent, "container_running", lambda appid: False)
monkeypatch.setattr(agent, "lepton_path", lambda: ("/lepton", None))
monkeypatch.setattr(agent, "install_controller_models", lambda *a: {"ok": True})
monkeypatch.setattr(agent, "write_launcher", lambda *a: None)
monkeypatch.setattr(agent, "sync_game_config", lambda *a: {})
return agent, base, dep
def test_agent_configuration_preserves_apk_data_tags_and_ignores_staging(monkeypatch, tmp_path):
agent, base, _ = installed_agent(monkeypatch, tmp_path)
pending = base / "incoming"
pending.mkdir()
(pending / "game.apk").write_bytes(b"pending different apk")
recipe = {"patches": {"adapter.scale": {"value": 1.5}}}
applied = configuration(recipe, PKG)
result = agent.cmd_configure({"package": PKG, "apk_sha256": "apk-a", "settings": applied["settings"],
"configuration": applied, "configuration_fp": configuration_fingerprint(recipe, PKG),
"recipe": {"patches": ["adapter.scale"]}})
assert "data_manifest" not in result["deployment"]
dep = agent.deployment(PKG)
assert dep["tags"] == ["Favorite"] and dep["apk"] == "original.apk" and dep["sha256"] == "apk-a"
assert dep["data_manifest"] == {"modded.obb": 4}
assert (base / "lepton-app/game.apk").read_bytes() == b"apk"
assert (pending / "game.apk").exists()
# Live writes update the applied baseline, including resetting a deleted recipe override.
agent.cmd_set_settings({"package": PKG, "settings": {"scale": 1.0}})
assert agent.deployment(PKG)["configuration_fp"] == configuration_fingerprint({"patches": {}}, PKG)
settings_conf = (base / "settings.conf").read_text()
for bad in (".", "1.2.3", "-", "abc"): # refused before anything is written
with pytest.raises(agent.AgentError):
agent.cmd_set_settings({"package": PKG, "settings": {"scale": bad}})
assert (base / "settings.conf").read_text() == settings_conf
@pytest.mark.parametrize("failure", ["running", "missing", "different", "drive"])
def test_agent_refuses_unsafe_configurations(monkeypatch, tmp_path, failure):
agent, base, original = installed_agent(monkeypatch, tmp_path)
sha = "apk-a"
if failure == "running":
monkeypatch.setattr(agent, "container_running", lambda appid: True)
elif failure == "missing":
(base / "lepton-app/game.apk").unlink()
elif failure == "different":
sha = "other-apk"
else:
def unavailable(*args):
raise agent.AgentError("drive missing")
monkeypatch.setattr(agent, "new_base", unavailable)
with pytest.raises(agent.AgentError):
agent.cmd_configure({"package": PKG, "apk_sha256": sha, "settings": {"scale": 1.5}})
assert agent.deployment(PKG) == original
assert not (base / "settings.conf").exists()
def test_generated_config_removal_restores_original_and_preserves_user_changes(monkeypatch, tmp_path):
agent, base, _ = installed_agent(monkeypatch, tmp_path)
files = Path(agent.data_files_dir(str(base), PKG))
files.mkdir(parents=True)
(files / "user.cfg").write_text("original")
args = {"package": PKG, "apk_sha256": "apk-a", "files": {"user.cfg": "managed"}}
agent.cmd_configure(args)
agent.cmd_configure({**args, "files": {}})
assert (files / "user.cfg").read_text() == "original"
agent.cmd_configure(args)
(files / "user.cfg").write_text("user mod")
agent.cmd_configure({**args, "files": {}})
assert (files / "user.cfg").read_text() == "user mod"
def test_failed_configuration_does_not_record_success(monkeypatch, tmp_path):
agent, base, original = installed_agent(monkeypatch, tmp_path)
def fail(*args):
raise RuntimeError("configuration sync failed")
monkeypatch.setattr(agent, "sync_game_config", fail)
with pytest.raises(RuntimeError):
agent.cmd_configure({"package": PKG, "apk_sha256": "apk-a", "settings": {"scale": 1.5},
"configuration_fp": "new"})
assert agent.deployment(PKG) == original
def test_generated_paths_checked_before_writing_settings(monkeypatch, tmp_path):
agent, base, original = installed_agent(monkeypatch, tmp_path)
with pytest.raises(agent.AgentError):
agent.cmd_configure({"package": PKG, "apk_sha256": "apk-a", "settings": {"scale": 1.5},
"files": {"../../escape": "bad"}})
assert agent.deployment(PKG) == original
assert not (base / "settings.conf").exists()
def test_build_inputs_ignore_unrelated_native_assets_and_detect_source_changes(monkeypatch, tmp_path):
import zipfile
from frameport.core import paths
from frameport.tools import toolchain
game, _ = game_and_dep()
source = tmp_path / "source.apk"
source.write_bytes(b"source")
game["apk"] = str(source)
native = tmp_path / "artifacts"
(native / "arm64-v8a").mkdir(parents=True)
(native / "arm64-v8a/chosen.so").write_bytes(b"native")
manifest = native / "SHA256SUMS"
manifest.write_text("a" * 64 + " ./arm64-v8a/chosen.so\n")
output = tmp_path / "output.apk"
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("lib/arm64-v8a/chosen.so", b"native")
monkeypatch.setattr(paths, "artifacts_dir", lambda: native)
monkeypatch.setattr(toolchain, "overport_jar", lambda: None)
game["build"].update(pipeline._build_record(game, output))
assert not pipeline._build_inputs_changed(game)
manifest.write_text(manifest.read_text() + "b" * 64 + " ./arm64-v8a/unrelated.so\n")
assert not pipeline._build_inputs_changed(game)
manifest.write_text("c" * 64 + " ./arm64-v8a/chosen.so\n")
assert pipeline._build_inputs_changed(game)
manifest.write_text("a" * 64 + " ./arm64-v8a/chosen.so\n")
source.write_bytes(b"different source, same version")
assert pipeline._build_inputs_changed(game)
def test_stage_timings_include_the_final_stage(monkeypatch):
from frameport.core import events
now = [10.0]
monkeypatch.setattr(events.time, "monotonic", lambda: now[0])
reporter = Reporter()
reporter.stage("Build")
now[0] += 3
reporter.stage("Upload")
now[0] += 2
assert reporter.stage_timings() == {"Build": 3, "Upload": 2}
assert reporter.stage_timings() == {"Build": 3, "Upload": 2} # snapshots do not double-count
def test_artwork_update_transfers_only_changed_slot(monkeypatch, tmp_path):
from frameport.artwork import sources, steam
from frameport.install import installer
portrait, hero = tmp_path / "portrait.png", tmp_path / "hero.png"
portrait.write_bytes(b"portrait")
hero.write_bytes(b"old hero")
art = {"portrait": portrait, "hero": hero}
monkeypatch.setattr(steam, "steam_set_for", lambda package: art)
monkeypatch.setattr(sources, "icon_source", lambda package: "custom")
manifest = installer.art_manifest(PKG, art)
hero.write_bytes(b"new hero")
calls = []
dep = {"package": PKG, "title": "Example", "anchor": "/frame/anchor", "artwork_manifest": manifest}
class Frame:
def agent(self, command, **args):
calls.append(command)
return {"games": [dep]} if command == "list_installed" else {"verified": True}
def run(self, command):
calls.append("mkdir")
def put(self, local, remote, **kwargs):
calls.append(local.name)
monkeypatch.setattr(installer, "add_to_steam", lambda *a: calls.append("shortcut"))
installer.update_steam_art(Frame(), PKG, Reporter(), title="Example")
assert "hero.png" in calls and "portrait.png" not in calls
assert "record_artwork" in calls and "shortcut" in calls
calls.clear()
dep["artwork_manifest"] = installer.art_manifest(PKG, art)
installer.update_steam_art(Frame(), PKG, Reporter(), title="Example")
assert calls == ["list_installed"]
def test_artwork_update_without_a_recorded_manifest_starts_clean(monkeypatch, tmp_path):
"""Installs before agent v86 have no artwork_manifest: an old portrait.jpg must not stay next to portrait.png."""
from frameport.artwork import sources, steam
from frameport.install import installer
portrait = tmp_path / "portrait.png"
portrait.write_bytes(b"portrait")
monkeypatch.setattr(steam, "steam_set_for", lambda package: {"portrait": portrait})
monkeypatch.setattr(sources, "icon_source", lambda package: "custom")
monkeypatch.setattr(installer, "send_icon_source", lambda *a: None)
monkeypatch.setattr(installer, "add_to_steam", lambda *a: {"state": "done"})
dep = {"package": PKG, "title": "Example", "anchor": "/frame/anchor"}
commands, puts = [], []
class Frame:
def agent(self, command, **args):
return {"games": [dep]} if command == "list_installed" else {"verified": True}
def run(self, command):
commands.append(command)
def put(self, local, remote, **kwargs):
puts.append(remote)
result = installer.update_steam_art(Frame(), PKG, Reporter(), title="Example")
assert commands[0] == "rm -rf -- '/frame/anchor/artwork'" and commands[1].startswith("mkdir -p")
assert puts == ["/frame/anchor/artwork/portrait.png"] and result["library"] is True
def test_agent_artwork_manifest_requires_matching_uploaded_content(monkeypatch, tmp_path):
agent, base, _ = installed_agent(monkeypatch, tmp_path)
art = base / "artwork"
art.mkdir()
(art / "hero.png").write_bytes(b"hero")
manifest = {"hero.png": agent.sha256_file(str(art / "hero.png"))}
assert agent.cmd_record_artwork({"package": PKG, "manifest": manifest})["verified"]
with pytest.raises(agent.AgentError):
agent.cmd_record_artwork({"package": PKG, "manifest": {"hero.png": "f" * 64}})
assert agent.deployment(PKG)["artwork_manifest"] == manifest
def test_apk_only_finalize_does_not_apply_staged_data_or_artwork(monkeypatch, tmp_path):
agent, base, _ = installed_agent(monkeypatch, tmp_path)
(base / "lepton-app/obb").mkdir()
(base / "lepton-app/obb/main.obb").write_bytes(b"user mod")
incoming = base / "incoming"
(incoming / "obb").mkdir(parents=True)
(incoming / "obb/main.obb").write_bytes(b"old staged copy")
(incoming / "game.apk").write_bytes(b"new apk")
(base / "incoming-artwork").mkdir()
(base / "incoming-artwork/hero.png").write_bytes(b"old staged art")
monkeypatch.setattr(agent, "cmd_prepare", lambda args: {"base": str(base), "anchor": str(base), "appid": 42,
"incoming": str(incoming), "lepton": "/lepton"})
result = agent.cmd_finalize({"package": PKG, "title": "Example", "preserve_data": True,
"consume_artwork": False,
"apk_sha256": agent.sha256_file(str(incoming / "game.apk"))})
assert result["moved_data_files"] == 0
assert (base / "lepton-app/obb/main.obb").read_bytes() == b"user mod"
assert (incoming / "obb/main.obb").read_bytes() == b"old staged copy"
assert not (base / "artwork").exists()
assert (base / "incoming-artwork/hero.png").exists()
def test_data_digest_fast_path_and_subset_manifest():
from frameport.game_update import fingerprint
game, dep = game_and_dep()
dep.pop("data_manifest")
dep["data_fp"] = fingerprint({"main.obb": 4})
assert not plan_update(game, dep, data_manifest={"main.obb": 4}).data
assert plan_update(game, dep, data_manifest={"main.obb": 5}).data
dep["data_manifest"] = {"main.obb": 4, "unused.obb": 10}
assert not plan_update(game, dep, data_manifest={"main.obb": 4}).data
def test_routine_deployment_summary_excludes_large_manifests():
import importlib.util
path = Path(__file__).resolve().parents[1] / "agent/frameport_agent.py"
spec = importlib.util.spec_from_file_location("frameport_agent_summary", path)
agent = importlib.util.module_from_spec(spec)
spec.loader.exec_module(agent)
full = {"package": PKG, "data_fp": "digest", "configuration_fp": "runtime", "sha256": "apk",
"data_manifest": {"main.obb": 123}, "configuration": {"settings": {}}, "managed_config_files": {}}
summary = agent.deployment_summary(full)
assert summary == {"package": PKG, "data_fp": "digest", "configuration_fp": "runtime", "sha256": "apk"}
assert "data_manifest" in full # summarizing does not mutate the durable record
@pytest.mark.parametrize("changed_data, force, preserve", [(False, False, True), (True, False, False),
(False, True, False)])
def test_apk_recipe_update_preserves_unchanged_source_data(monkeypatch, tmp_path, changed_data, force, preserve):
game, dep = game_and_dep()
data = tmp_path / "data"
data.mkdir()
(data / "main.obb").write_bytes(b"12345" if changed_data else b"1234")
game["data_dir"] = str(data)
game["recipe"]["patches"]["frame.unity_text_input"] = {}
store_game(monkeypatch, game)
output = tmp_path / "built.apk"
output.write_bytes(b"checked new build")
def build(package, reporter):
current = library.game(package)
library.update_game(package, lambda entry: entry["build"].update(
apk=str(output), sha256=pipeline.builder.sha256(output),
apk_recipe_fp=apk_recipe_fingerprint(current["recipe"], package)))
return {"ok": True}
monkeypatch.setattr(pipeline, "build_game", build)
target = Target(dep)
installed = []
def install(*args, **kwargs):
installed.append(args[6])
return {"base": dep["base"], "appid": dep["appid"]}
target.install = install
result = pipeline.install_game(PKG, target, Reporter(), force=force)
assert installed == [preserve]
assert result["launch_test_required"] is True
def test_agent_drops_records_an_older_app_did_not_send(monkeypatch, tmp_path):
"""An app before agent v86 installs without fingerprints: a newer PC mustn't skip work on the old record."""
agent, base, dep = installed_agent(monkeypatch, tmp_path)
agent.write_deployment(PKG, {**dep, "configuration_fp": "old", "apk_recipe_fp": "old",
"artwork_manifest": {"portrait.jpg": "a" * 64}})
anchor = tmp_path / "Applications/quest-frame" / PKG
prep = {"base": str(base), "anchor": str(anchor), "appid": 42, "lepton": "/lepton"}
# no new art: the record of the art in place stays
agent.configure_install({"package": PKG, "title": "Example", "settings": {}}, prep, 0)
record = agent.deployment(PKG)
assert "configuration_fp" not in record and "apk_recipe_fp" not in record
assert record["artwork_manifest"] == {"portrait.jpg": "a" * 64}
# new art moved in without a manifest: its record is dropped too
(base / "incoming-artwork").mkdir()
(base / "incoming-artwork/portrait.png").write_bytes(b"new")
agent.configure_install({"package": PKG, "title": "Example", "settings": {}, "consume_artwork": True}, prep, 0)
assert "artwork_manifest" not in agent.deployment(PKG)
assert (anchor / "artwork/portrait.png").exists()
def test_agent_config_written_by_an_older_frameport_is_not_kept_as_the_users(monkeypatch, tmp_path):
agent, base, dep = installed_agent(monkeypatch, tmp_path)
files = base / "lepton-data/external/Android/data" / PKG / "files"
files.mkdir(parents=True)
(files / "user.cfg").write_text("r_x=0\n") # FramePort's own, from before agent v86 kept records
(files / "mine.cfg").write_text("users own\n")
prep = {"base": str(base), "anchor": str(tmp_path / "Applications/quest-frame" / PKG), "appid": 42,
"lepton": "/lepton"}
agent.configure_install({"package": PKG, "title": "Example", "settings": {},
"files": {"user.cfg": "r_x=0\n", "mine.cfg": "r_y=1\n"}}, prep, 0)
managed = agent.deployment(PKG)["managed_config_files"]
assert managed["user.cfg"]["backup"] is False and managed["mine.cfg"]["backup"] is True
# the recipe drops both: FramePort's file goes away, the user's own comes back
agent.configure_install({"package": PKG, "title": "Example", "settings": {}, "files": {}}, prep, 0)
assert not (files / "user.cfg").exists() and (files / "mine.cfg").read_text() == "users own\n"
+12
View File
@@ -127,3 +127,15 @@ def test_claim_recovers_a_legacy_windows_holder_without_readable_pid(monkeypatch
monkeypatch.setattr(instance, "end_leftover", lambda pid: ended.append(pid))
assert instance.claim(lambda: False)
assert ended == [123]
def test_lock_is_kept_when_the_owner_file_cannot_be_written(tmp_path, monkeypatch):
"""An antivirus or a read-only file must not stop the GUI from starting once it holds the lock."""
def fail(*args, **kwargs):
raise PermissionError("locked by another program")
monkeypatch.setattr(instance, "write_atomic", fail)
try:
assert instance.acquire(tmp_path / "gui.lock")
finally:
instance.release()
+176
View File
@@ -0,0 +1,176 @@
"""Bulk backend selection, paired-host trust, resumable native batches and cancellation."""
import contextlib
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from frameport.core.events import Cancelled, Reporter
from frameport.frame import native_upload as native
from frameport.frame.connection import Frame, FrameTarget
from frameport.install import installer
def test_pinned_credentials(tmp_path, monkeypatch):
monkeypatch.setattr(native, "ssh_dir", lambda: tmp_path)
(tmp_path / "id_ed25519").write_text("private")
key = SimpleNamespace(get_name=lambda: "ssh-ed25519", get_base64=lambda: "PAIRED_KEY")
client = SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key))
frame = Frame(FrameTarget("paired.example", port=2222), client=client)
with native.credentials(frame) as args:
known = next(arg.split("=", 1)[1].strip('"') for arg in args if arg.startswith("UserKnownHostsFile="))
from pathlib import Path
assert Path(known).read_text() == "[paired.example]:2222 ssh-ed25519 PAIRED_KEY\n"
assert "StrictHostKeyChecking=yes" in args
assert "BatchMode=yes" in args
copy = args[args.index("-i") + 1]
assert Path(copy).read_text() == "private"
assert Path(copy) != tmp_path / "id_ed25519"
assert not Path(copy).exists()
assert (tmp_path / "id_ed25519").read_text() == "private"
@pytest.mark.parametrize("value", ["bad\npath", "bad\rpath", "bad\x00path"])
def test_quote_refuses_batch_injection(value):
with pytest.raises(ValueError):
native.quote(value)
def test_quote_protects_spaces_quotes_globs():
assert native.quote('a *?["b') == '"a \\*\\?\\[\\"b"'
def test_size_lookup_does_not_hide_connection_errors():
sftp = Mock()
sftp.stat.side_effect = ConnectionError("lost connection")
with pytest.raises(ConnectionError):
native.size_of(sftp, "/file")
def setup_batch(tmp_path, monkeypatch, code=0):
paths = [tmp_path / "main.obb", tmp_path / "patch.obb"]
for path in paths:
path.write_bytes(b"0123456789")
items = [(path, path.name, 10) for path in paths]
files = {"/incoming/main.obb.part": 4, "/incoming/patch.obb.part": 20}
monitor = Mock()
monitor.__enter__ = Mock(return_value=monitor)
monitor.__exit__ = Mock(return_value=False)
def stat(path):
if path not in files:
raise FileNotFoundError(path)
return SimpleNamespace(st_size=files[path])
monitor.stat.side_effect = stat
monitor.remove.side_effect = lambda path: files.pop(path)
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor),
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
@contextlib.contextmanager
def creds(frame):
yield ["-o", "StrictHostKeyChecking=yes"]
monkeypatch.setattr(native, "credentials", creds)
commands, processes = [], []
def popen(args, **kwargs):
commands.append(kwargs["stdin"].read().decode())
for _, rel, size in items:
files["/incoming/" + rel + ".part"] = size
process = Mock(returncode=code)
process.poll.return_value = code
processes.append(process)
return process
monkeypatch.setattr(native.subprocess, "Popen", popen)
return frame, items, files, commands, processes
def test_native_batches_resume_reset_oversize_and_verify(tmp_path, monkeypatch):
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 3) == 20
assert len(processes) == 2 # never more processes than files
assert commands[0].startswith("reput ")
assert commands[1].startswith("put ")
assert all(".part" in command for command in commands)
frame.sftp.remove.assert_called_once_with("/incoming/patch.obb.part")
assert frame.run.call_count == 1
assert all("mv -f --" in call.args[0] for call in frame.run.call_args_list)
frame.sftp.get_channel().settimeout.assert_called_once_with(2)
assert all(process.wait.called for process in processes)
def test_native_complete_parts_are_only_renamed(tmp_path, monkeypatch):
"""A .part as big as its file (left by a cancelled run) isn't sent again: sftp's reput refuses it."""
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
files["/incoming/main.obb.part"] = 10
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 3) == 20
assert len(processes) == 1 and "main.obb" not in commands[0] and commands[0].startswith("put ")
assert "main.obb.part" in frame.run.call_args.args[0]
files["/incoming/patch.obb.part"] = 10
commands.clear()
processes.clear()
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 3) == 20
assert not processes and frame.run.call_count == 2
def test_native_failure_does_not_promote_files(tmp_path, monkeypatch):
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch, code=1)
with pytest.raises(OSError, match="OpenSSH SFTP failed"):
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 2)
assert not frame.run.called
assert files["/incoming/main.obb.part"] == 10
assert all(process.wait.called for process in processes)
def test_native_success_still_checks_remote_sizes(tmp_path, monkeypatch):
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
original = native.subprocess.Popen
def truncated(*args, **kwargs):
process = original(*args, **kwargs)
files["/incoming/main.obb.part"] = 9
return process
monkeypatch.setattr(native.subprocess, "Popen", truncated)
with pytest.raises(OSError, match="unexpected size"):
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 2)
assert not frame.run.called
assert all(process.wait.called for process in processes)
def test_native_cancel_stops_owned_processes(tmp_path, monkeypatch):
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
report = Reporter()
report.progress = lambda *args, **kwargs: report.cancelled.set()
monkeypatch.setattr(native, "stop_process", Mock())
with pytest.raises(Cancelled):
native.upload(frame, items, "/incoming", report, 20, 0, 2)
assert not frame.run.called
assert native.stop_process.call_count == 2
@pytest.mark.parametrize("cancel", [False, True])
def test_production_fallback_and_cancel(tmp_path, monkeypatch, cancel):
path = tmp_path / "game.apk"
path.write_bytes(b"apk")
frame = Frame(FrameTarget("paired"))
frame.mkdirs = Mock()
frame.put = Mock()
report = Reporter()
fractions = []
report.progress = lambda fraction, *args, **kwargs: fractions.append(fraction)
def fail(frame, items, remote, reporter, *args):
reporter.progress(0.8, "native")
if cancel:
raise Cancelled()
raise OSError("native unavailable")
def fallback(local, remote, progress, **kwargs):
progress(0, installer.BIG_FILE)
progress(installer.BIG_FILE, installer.BIG_FILE)
frame.put.side_effect = fallback
monkeypatch.setattr(native, "upload", fail)
call = lambda: installer.upload_files(frame, [(path, "game.apk", installer.BIG_FILE)],
"/incoming", report, installer.BIG_FILE, workers=1)
if cancel:
with pytest.raises(Cancelled):
call()
assert not frame.put.called
else:
assert call() == installer.BIG_FILE
frame.put.assert_called_once()
assert fractions == sorted(fractions)
+213
View File
@@ -0,0 +1,213 @@
"""Bounded independent upload sessions, aggregate progress and cancellation of blocked workers."""
import threading
from types import SimpleNamespace
import pytest
from frameport.core import library
from frameport.core.events import Cancelled, Reporter
from frameport.install import installer
@pytest.mark.parametrize("value, expected", [(1, 1), (2, 2), (8, 8), (0, 1), (99, 8),
("3", 3), ("bad", 3), (float("inf"), 3)])
def test_upload_threads_bounds(value, expected):
assert installer.upload_threads(value) == expected
def test_upload_threads_default_and_setting():
assert installer.upload_threads() == 3
library.set_setting("install.upload_threads", 8)
assert installer.upload_threads() == 8
def items(tmp_path, count=4):
result = []
for n in range(count):
path = tmp_path / f"main-{n}.obb"
path.write_bytes(b"a" * 10)
result.append((path, f"obb/{path.name}", 10))
return result
class Control:
def __init__(self, action):
self.action = action
self.sessions, self.calls, self.tars, self.dirs = [], [], [], []
self.lock = threading.Lock()
def mkdirs(self, dirs):
self.dirs.extend(dirs)
def upload_session(self):
owner = self
class Session:
def __init__(self):
self.closed = threading.Event()
def put(self, path, remote, progress, **kwargs):
progress(0, 10)
owner.action(self, path, progress)
with owner.lock:
owner.calls.append((remote, kwargs))
def close(self):
self.closed.set()
session = Session()
with self.lock:
self.sessions.append(session)
return session
def put(self, path, remote, progress, **kwargs):
self.calls.append((remote, kwargs))
progress(0, 10)
progress(10, 10)
def put_tar(self, files, root, progress):
self.tars.append([rel for _, rel in files])
progress(sum(path.stat().st_size for path, _ in files), files[-1][1])
def close(self):
pytest.fail("the control connection must remain open")
def test_two_workers_and_monotonic_aggregate_progress(monkeypatch, tmp_path):
monkeypatch.setattr(installer, "BIG_FILE", 5)
barrier = threading.Barrier(2)
activity = {"active": 0, "peak": 0}
lock = threading.Lock()
def action(session, path, progress):
with lock:
activity["active"] += 1
activity["peak"] = max(activity["peak"], activity["active"])
barrier.wait(timeout=3)
progress(5, 10)
progress(10, 10)
with lock:
activity["active"] -= 1
frame = Control(action)
events = []
reporter = Reporter([events.append])
assert installer.upload_files(frame, items(tmp_path), "/incoming", reporter, 40, workers=2) == 40
assert activity["peak"] == 2
assert len(frame.sessions) == 2 and all(session.closed.is_set() for session in frame.sessions)
assert sorted(remote for remote, _ in frame.calls) == [f"/incoming/obb/main-{n}.obb" for n in range(4)]
assert all(kwargs["mkdir"] is False and callable(kwargs["check_cancel"]) for _, kwargs in frame.calls)
fractions = [event.fraction for event in events if event.kind == "progress"]
assert fractions == sorted(fractions) and fractions[-1] == 1.0
def test_one_worker_uses_existing_serial_transport(monkeypatch, tmp_path):
monkeypatch.setattr(installer, "BIG_FILE", 5)
frame = Control(lambda *args: pytest.fail("no worker transport needed"))
installer.upload_files(frame, items(tmp_path), "/incoming", Reporter(), 40, workers=1)
assert not frame.sessions and len(frame.calls) == 4
def test_worker_count_is_limited_to_large_files_and_tar_is_sequential(monkeypatch, tmp_path):
monkeypatch.setattr(installer, "BIG_FILE", 5)
barrier = threading.Barrier(2)
def action(session, path, progress):
assert not frame.tars
barrier.wait(timeout=3)
progress(10, 10)
frame = Control(action)
selected = items(tmp_path, 2)
small = tmp_path / "small.txt"
small.write_bytes(b"x")
selected.append((small, "obb/small.txt", 1))
assert installer.upload_files(frame, selected, "/incoming", Reporter(), 21, workers=8) == 21
assert len(frame.sessions) == 2
assert frame.tars == [["obb/small.txt"]]
def test_cancel_interrupts_blocked_workers_and_stops_queued_files(monkeypatch, tmp_path):
monkeypatch.setattr(installer, "BIG_FILE", 5)
started = threading.Barrier(3)
def action(session, path, progress):
started.wait(timeout=3)
assert session.closed.wait(3), "cancellation must close blocked worker sockets"
raise OSError("transport closed")
frame, reporter, errors = Control(action), Reporter(), []
def run():
try:
installer.upload_files(frame, items(tmp_path), "/incoming", reporter, 40, workers=2)
except Exception as exc:
errors.append(exc)
thread = threading.Thread(target=run)
thread.start()
started.wait(timeout=3)
reporter.cancelled.set()
thread.join(timeout=3)
assert not thread.is_alive()
assert len(errors) == 1 and isinstance(errors[0], Cancelled)
assert all(session.closed.is_set() for session in frame.sessions)
assert not frame.calls
def test_failure_propagates_and_closes_sessions(monkeypatch, tmp_path):
monkeypatch.setattr(installer, "BIG_FILE", 5)
def fail(session, path, progress):
raise OSError("disk full")
frame = Control(fail)
with pytest.raises(OSError, match="disk full"):
installer.upload_files(frame, items(tmp_path), "/incoming", Reporter(), 40, workers=2)
assert all(session.closed.is_set() for session in frame.sessions)
assert not frame.calls
def test_parallel_upload_setting_options_and_persistence():
import flet as ft
from frameport.ui.views.settings import SettingsView
app = SimpleNamespace(target=None, frame_state="disconnected")
controls = SettingsView(app).installing().controls
dropdown = next(control for control in controls if isinstance(control, ft.Dropdown))
assert dropdown.value == "3"
assert [option.key for option in dropdown.options] == [str(n) for n in range(1, 9)]
dropdown.on_select(SimpleNamespace(control=SimpleNamespace(value="8")))
assert library.setting("install.upload_threads") == 8
def test_upload_session_checks_paired_headset_key_and_keeps_control(monkeypatch):
import paramiko
from frameport.frame.connection import Frame, FrameTarget
closed, children = [], []
def client(key):
return SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key),
close=lambda: closed.append(key))
control = Frame(FrameTarget("frame"))
control.client = client("paired")
def connect(other):
other.client = client("other headset")
children.append(other)
return other
monkeypatch.setattr(Frame, "connect", connect)
with pytest.raises(paramiko.SSHException, match="different headset"):
control.upload_session()
assert closed == ["other headset"]
assert control.client.get_transport().get_remote_server_key() == "paired"
assert children[0].target is not control.target
def test_closed_worker_does_not_retry_after_cancellation(monkeypatch, tmp_path):
from frameport.frame.connection import Frame, FrameTarget
frame = Frame(FrameTarget("frame"))
calls = []
cancelled = threading.Event()
def put(*args):
calls.append("put")
cancelled.set()
raise OSError("socket closed")
def check():
if cancelled.is_set():
raise Cancelled()
monkeypatch.setattr(frame, "_put", put)
monkeypatch.setattr(frame, "connect", lambda *args: pytest.fail("must not reconnect a cancelled worker"))
with pytest.raises(Cancelled):
frame.put(tmp_path / "file", "/incoming/file", check_cancel=check)
assert calls == ["put"]