mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-29 04:02:45 +01:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5368d73ea | ||
|
|
bd92c25a09 | ||
|
|
05024e02bf | ||
|
|
56e122d85f | ||
|
|
3fcb901f8c | ||
|
|
faea99e837 | ||
|
|
05c4f5603b | ||
|
|
60fa213862 | ||
|
|
b67c403447 | ||
|
|
f251129c11 |
No files matched your search
+161
-25
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user