mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 17:01:30 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
324be31ea9 | ||
|
|
39e1762e53 | ||
|
|
3d260dccb1 | ||
|
|
26a34ddadb |
No files matched your search
@@ -223,6 +223,13 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
|
||||
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
|
||||
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
|
||||
- Newer versions (GitHub #161): game menu "Update with a newer APK…" (`pipeline.replace_apk`, outdated via
|
||||
`build.source_version`) and, for Linux apps / PC VR games, "Update with a newer version…" (`replace_linux_app` /
|
||||
`replace_rift_game` via `add_linux_app(replace=)` / `add_rift_game(replace=)`, CLI `frameport new-version`): the
|
||||
entry keeps package, name, art, tags, switches, user recipe (a confirmed exe if the new folder has it); a new
|
||||
`source_id` goes into the deployment's `recipe` dict at install (no agent change) → `C.source_replaced` = outdated
|
||||
(Frame + `pc_outdated`); such an install re-sends same-size files changed after the last install (mtime/ctime,
|
||||
`installer.changed_since`); the old Rift folder is in `replaced_dirs` (rescans skip it). Untested on the device.
|
||||
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
|
||||
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
|
||||
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
|
||||
@@ -904,6 +911,13 @@ hide_space_warp / vk_hide_fdm / no Valve layers; FNAF HW (#135) "freeze" = first
|
||||
on Zink (threads busy in zink/disk cache), not a hang; VR4 (#199) reporter's patch.203 OBB truncated. Upload benchmark
|
||||
(`scripts/upload_bench.py`, #242): home Wi-Fi caps every method at ~11 MB/s; paramiko uses ~3x OpenSSH's CPU; Windows
|
||||
OpenSSH refuses FramePort's key file (ACL "bad permissions") until its permissions are tightened.
|
||||
**OpenSSH uploads (2026-10-15, #324/#242):** sftp escapes `* ? [` inside double quotes itself (and undoes it for
|
||||
put's destination), so `native_upload.quote` escapes only `\\`/`"`; Win32 sftp turns every local backslash into `/`
|
||||
(escapes too) → local paths go as `C:/…`. Logins: `Frame.auth` = the attempt that worked (app_key / user_key = a key
|
||||
file from Settings → Installing → SSH keys, library `ssh.extra_keys`, CLI `frame ssh-keys` / agent / password).
|
||||
OpenSSH strict (FramePort + Settings keys, IdentitiesOnly) unless auth was "agent"; a refused strict login retries
|
||||
relaxed (+ private copies of ~/.ssh/id_{rsa,dsa,ecdsa,ed25519}: OpenSSH skips its defaults once any -i is given, + the
|
||||
agent); `-v` key lines go into the error. FramePort doesn't add its key to the Frame for this. Windows-tested.
|
||||
**Meta user id (GitHub #182, 2026-10-11):** OVRPort's platform loader (runtimes 3.4.3-23204ea and -aa54c3f) answers
|
||||
`ovr_GetLoggedInUserID` (and two other callers) without a Meta account with a made-up id from a helper (`stp; mov x29,
|
||||
sp; bl random; …; mov/movk x9 = 0x53444835ec580000 (6e18); add x0, x8, x9; ldp; ret`), kept only in memory → a new id
|
||||
|
||||
@@ -13,11 +13,11 @@ e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/li
|
||||
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
|
||||
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
0f058921e9c1f5b9ef5847cc9e6b80a23e75028c76c1d3bb02d1610b535f81c9 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
54f05223eeaa19527af26d6c2e2dd304302d712f4a8db2cd95d7536babfdf8db ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
|
||||
9e8a7ce599b62a7c607b0d3637e4ec1b1ea1974c3373a7af3f5920ad2adc5e00 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
3c4c30d31865f81360bb5ebc92dcec996eb2b3e9388085f020cc2fb460f75749 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
package: ScaredBySquares.Game
|
||||
title: Scared By Squares
|
||||
status: unknown
|
||||
notes: 'Showed black and dropped back to the home environment while the game kept running (sound only, GitHub 323).
|
||||
Fixed in FramePort 1.2.1 and checked on the Frame without the headset (the game runs at 72 fps and SteamVR keeps it
|
||||
as the running game); not yet confirmed in the headset. Rebuild and reinstall the game after updating FramePort.'
|
||||
details: 'The game (Unreal 4.27, OpenXR) creates two controller actions named "turn" (an action mapping and an axis
|
||||
mapping called Turn). The Frame''s runtime accepts the duplicate, then fails to load the input manifest it builds
|
||||
from the actions ("SetXrInputConfig failed. Failed to load manifest") and treats the game as closed. FrameBridge''s
|
||||
action_dedup (on by default) creates the second one as "turn_2".'
|
||||
tested_version: '2'
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
- frame.unreal_skip_obb_check
|
||||
updated: '2026-10-16'
|
||||
min_app: 1.2.1
|
||||
source_hint: Scared By Squares
|
||||
+9
-1
@@ -173,7 +173,8 @@ signatures:
|
||||
suggest: [adapter.vk_shader_dump, adapter.zink_shader_dump]
|
||||
report: true
|
||||
- id: space-warp-used
|
||||
pattern: 'xrCreateSwapchain \d+x\d+ format=(97|129) '
|
||||
# space warp's motion vectors (97 = R16G16B16A16_SFLOAT); its depth (129) is also what eye depth layers use (#323)
|
||||
pattern: 'xrCreateSwapchain \d+x\d+ format=97 '
|
||||
unless: 'per-game: hide_space_warp=1'
|
||||
severity: info
|
||||
diagnosis: The game uses Meta's space warp (it renders half of its frames plus motion vectors, and the runtime makes
|
||||
@@ -181,6 +182,13 @@ signatures:
|
||||
Metro Awakening). Turning it off makes the game render every frame itself.
|
||||
question: Did textures flicker, jump or smear while you moved?
|
||||
suggest: [adapter.hide_space_warp]
|
||||
- id: duplicate-action-renamed
|
||||
pattern: 'action_dedup: action .* already exists in its set'
|
||||
severity: info
|
||||
diagnosis: The game creates two controller actions with the same name (for example an Unreal action and axis both
|
||||
called Turn). The Frame's runtime then can't load the game's controller setup and drops the game back to the home
|
||||
environment while it keeps running. FramePort renamed the second one, so this is handled.
|
||||
suggest: []
|
||||
- id: unity-runtime-msaa
|
||||
pattern: 'recommended MSAA level is [248]\. Switching to the recommended level'
|
||||
severity: info
|
||||
|
||||
@@ -167,6 +167,10 @@ FramePort downloads it, checks it, restarts and keeps your games and settings. *
|
||||
- Settings → **Updates** turns the check off or turns on **Install updates automatically**.
|
||||
- **Recipes** (the tested patches and settings for each game) update by themselves. A game whose recipe changed shows
|
||||
**Update on Frame**.
|
||||
- **A newer version of a game:** right-click it (or use **…** on its page) → **Update with a newer APK…** (Quest and
|
||||
Android games) or **Update with a newer version…** (Linux apps: the new AppImage, archive or folder; PC VR games:
|
||||
the new game folder). Its name, artwork, tags and your settings stay. Then click **Update on Frame** (or **on this
|
||||
PC**).
|
||||
- **Dev builds:** if you're asked to test a change before its release, use Settings → Updates → **Install the latest
|
||||
dev build…**. The next release then arrives as a normal update.
|
||||
|
||||
@@ -255,6 +259,7 @@ option. The main ones:
|
||||
|---|---|
|
||||
| `scan <folder>` / `list` / `show <game>` | add games, list the library, show a game's patches |
|
||||
| `add-linux <path> [--exe <program>]` | add a Linux app (AppImage, folder or archive) |
|
||||
| `new-version <game> <path>` | point a game at its newer version (APK, Linux app file/folder, PC VR game folder) |
|
||||
| `recipe <game> --enable/--disable <patch>` | change a game's patches (`patches` lists them all) |
|
||||
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for this PC), launch test |
|
||||
| `frame discover` / `frame connect` / `frame info` | find, connect to and describe the Frame |
|
||||
|
||||
+21
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 169 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
Every patch and game setting FramePort knows: 170 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
|
||||
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
|
||||
|
||||
@@ -12,7 +12,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
|
||||
- [Patches from OVRPort](#group-overport) (25)
|
||||
- [Steam Frame patches](#group-frame) (55)
|
||||
- [Game settings](#group-adapter) (68)
|
||||
- [Game settings](#group-adapter) (69)
|
||||
- [Files and settings on the Frame](#group-device) (8)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
|
||||
@@ -56,7 +56,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [OpenGL ES: multiview shaders on flat panels (HUD, menus)](#frame-gl_multiview_fbo) | `frame.gl_multiview_fbo` | Steam Frame patches | 1 |
|
||||
| [GL shim (Mesa GLSL compatibility)](#frame-gl_shim) | `frame.gl_shim` | Steam Frame patches | 2 |
|
||||
| [Godot apps: start without a clipboard service](#frame-godot_clipboard) | `frame.godot_clipboard` | Steam Frame patches | |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 19 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 20 |
|
||||
| [Language packs from the game's files](#frame-langpacks) | `frame.langpacks` | Steam Frame patches | |
|
||||
| [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 |
|
||||
@@ -90,7 +90,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 2 |
|
||||
| [Unreal: Quest controller bindings (RPOC key map)](#frame-unreal_quest_keymap) | `frame.unreal_quest_keymap` | Steam Frame patches | 3 |
|
||||
| [Unreal: don't wait for the Quest shader precompile](#frame-unreal_quest_precompile) | `frame.unreal_quest_precompile` | Steam Frame patches | 3 |
|
||||
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 5 |
|
||||
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 6 |
|
||||
| [Unreal: skip the OBB delivery check](#frame-unreal_skip_obb_delivery) | `frame.unreal_skip_obb_delivery` | Steam Frame patches | 1 |
|
||||
| [Unreal: bypass the stalled loading splash](#frame-unreal_splash_bypass) | `frame.unreal_splash_bypass` | Steam Frame patches | 1 |
|
||||
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 7 |
|
||||
@@ -100,6 +100,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 3 |
|
||||
| [Quiet Meta's VrApi loader (OpenXR games)](#frame-vrapi_stub) | `frame.vrapi_stub` | Steam Frame patches | 2 |
|
||||
| [OpenGL ES: shader fixes (Vulkan layer under Zink)](#frame-zink_shader_fix) | `frame.zink_shader_fix` | Steam Frame patches | 1 |
|
||||
| [Unique controller action names](#adapter-action_dedup) | `adapter.action_dedup` | Game settings | |
|
||||
| [Pointer start](#adapter-aim_forward) | `adapter.aim_forward` | Game settings | |
|
||||
| [Pointer angle](#adapter-aim_pitch) | `adapter.aim_pitch` | Game settings | |
|
||||
| [Pointer turn](#adapter-aim_yaw) | `adapter.aim_yaw` | Game settings | |
|
||||
@@ -645,7 +646,7 @@ Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs s
|
||||
- `frame.hw_video_decode` · also for 2D apps
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** The game plays video through Android's decoders (MediaCodec, ExoPlayer or VLC): decode it on the Frame's hardware.
|
||||
- **Catalog games (19):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Dance Central, Eye of the Temple, Fruit Ninja, Ghost Town and 11 more.
|
||||
- **Catalog games (20):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Dance Central, Eye of the Temple, Fruit Ninja, Ghost Town and 12 more.
|
||||
- **Source:** [patches/frame/hw_video_decode.py](../src/frameport/patches/frame/hw_video_decode.py)
|
||||
|
||||
> Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (for example video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (for example Steam's own hardware video decoding is on) the video is decoded in software as before.
|
||||
@@ -1092,7 +1093,7 @@ Starts Unreal games without re-checking their data file (they'd seem to hang on
|
||||
- `frame.unreal_skip_obb_check` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unreal checks the OBB's contents before starting (bVerifyOBBOnStartUp); on the Frame a failed or slow check can't be seen and the game seems to hang.
|
||||
- **Catalog games (5):** Dance Central, Kill It With Fire VR, MacacoVR, Maestro, Ragnarock.
|
||||
- **Catalog games (6):** Dance Central, Kill It With Fire VR, MacacoVR, Maestro, Ragnarock, Scared By Squares.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-obb-check-stuck` (fatal): The game stays on Unreal's OBB check screen (DownloaderActivity), which the Frame doesn't show, and never starts VR (for example Contractors, GitHub #159).
|
||||
- **Source:** [patches/frame/unreal_obb_check.py](../src/frameport/patches/frame/unreal_obb_check.py)
|
||||
@@ -1228,6 +1229,20 @@ Repairs shaders that hang the graphics chip at certain effects, from the game's
|
||||
|
||||
Settings of FrameBridge, FramePort's OpenXR layer inside every converted Quest game. Most can be changed on the Frame later without a rebuild (game page → Game settings).
|
||||
|
||||
### <a id="adapter-action_dedup"></a>Unique controller action names
|
||||
|
||||
Stops games from dropping back to the home environment at start because two of their controller actions share a name (for example Scared By Squares). Leave it on.
|
||||
|
||||
- `adapter.action_dedup`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- Value: int, default `1`.
|
||||
- Game settings → Troubleshooting → "Unique controller action names" (advanced).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Gives a controller action whose name already exists in its action set a free name ("turn" -> "turn_2"). The Frame's runtime accepts duplicate names but then can't load the game's input manifest and treats the game as closed: black, then the home environment while the game keeps running with sound (for example Scared By Squares, whose Unreal project has an action and an axis both called Turn; GitHub #323).
|
||||
|
||||
### <a id="adapter-aim_forward"></a>Pointer start
|
||||
|
||||
Moves where the pointer starts, forward or back.
|
||||
|
||||
@@ -63,6 +63,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| 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 | 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) |
|
||||
| Log: `OpenSSH SFTP failed (255): … Permission denied (publickey,password)`, then Paramiko | OpenSSH offered keys the Frame doesn't authorize (GitHub #242) | The log lists the keys OpenSSH offered (`Offering public key`) and its warnings. A connection that logged in with the PC's own keys/agent makes OpenSSH use them too (copies of `~/.ssh/id_*` + the agent); a refused FramePort key is retried that way once. A Frame that accepts only another key file: Settings → Installing → SSH keys (CLI `frameport frame ssh-keys --add <file>`); passphrase keys go through the SSH agent |
|
||||
| Log: `OpenSSH SFTP failed (1): stat C://…/[…: No such file or directory` | sftp got glob escapes inside its quotes (GitHub #324, fixed): `[` in a local folder name | Update FramePort; the native_upload `quote()` passes only `\\` and `\"` escapes, Windows paths with `/` |
|
||||
| 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 |
|
||||
@@ -114,6 +116,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Video player / app finds no local videos; MediaProvider "Requested path /home/steamos/... doesn't appear under ..." | Lepton's /sdcard is a symlink to a host path, so Android's media index rejects every file | upload in the Files tab (Videos = /sdcard/Movies) and browse folders in the app |
|
||||
| Batman: Arkham Shadow closes during smoke-bomb effects; later input-tree corruption | Meta XR Audio Wwise deletes queued metadata still referenced by the current audio frame, then writes through the stale pointer | FrameBridge automatically retires metadata after current-frame references disappear, for the verified AArch64 SDK build only; see [AUDIO_METADATA.md](AUDIO_METADATA.md) |
|
||||
| Game stays on a loading or "Waiting" box although it reached FOCUSED; log `FrameBridge: xrEndFrame failed -25` then no more `pacing:` lines (for example PowerWash Simulator on some Frames, GitHub #39) | The eye image rect ends a few pixels past its swapchain (Unity rounds the swapchain width, the runtime's recommended size differs per Frame); SteamVR rejects every frame with XR_ERROR_SWAPCHAIN_RECT_INVALID | FrameBridge clamps every projection/quad image rect to its swapchain (`rect_clamp`, default on since 0.11.0; log `rect_clamp: view N image rect clamped`): rebuild + reinstall the game |
|
||||
| Black flash, then the SteamVR home environment while the game keeps running (sound plays); vrserver.txt `[OpenXR] SetXrInputConfig failed. Failed to load manifest provided by SetXrInputConfig`, then the app "has exited" and reconnects as `VRApplication_OpenXRInstance`; headless launch tests pace at 10 fps (for example Scared By Squares, GitHub #323) | Two controller actions in one action set share a name (Unreal: an action mapping and an axis mapping both called "Turn"). The Frame's runtime accepts the duplicate but can't load the input manifest it builds and drops the app as a scene app | FrameBridge gives the later duplicate a free name (`action_dedup`, default on; log `action_dedup: action "turn" ("Turn") already exists in its set: created as "turn_2"`): rebuild + reinstall the game. `input_diag=1` logs every action set and action the game creates |
|
||||
| Unity game (OVRPlugin, GLES or Vulkan) crashes ~10 s in: `FrameBridge: xrCreateSwapchain … faces=6 … result=-2`, OVRPlugin `CreateSwapchain for eye 0: 0x0, 0 stages`, then render-thread SIGSEGV in memset ← libOVRPlugin ← `ovrp_EndFrame4` (libgallium/ANR follow; for example Budget Cuts Ultimate, GitHub #107); triage `cube-swapchain-refused` | The game shows a cube-map layer (OVROverlay cubemap); the Frame's runtime has no cube layers and refuses cube swapchains (XR_ERROR_RUNTIME_FAILURE); OVRPlugin doesn't check and writes into the image list it never got | FrameBridge `cube_standin` (default on): a refused cube swapchain is served as a GL cube map in the game's context or a device-local Vulkan cube image transitioned to COLOR_ATTACHMENT_OPTIMAL, and its layers are dropped (log `cube_standin: runtime refused …`): rebuild + reinstall. Vulkan path covers UNDERDOGS (GitHub #296); host-tested, headset verification pending |
|
||||
| Game (Unity, GLES) freezes, `zink: DEVICE LOST` | multisampled render-to-texture hangs the GPU | `frame.unity_no_msaa`; if it persists: unfixable → PC version |
|
||||
| Unreal GLES game crashes ~3 s after start: `Fatal signal 11 … fault addr 0x10000 in tid … (RHIThread)`, `#01 … libgallium_dri.so` (GitHub #83 Star Wars Pinball VR, triage `unreal-msrtt-crash`) | Unreal's mobile MSAA renders through multisampled render-to-texture; Zink's `find_rp_state` then indexes `rendering_state_cache[6]` one past its end (sample count ≥ 32) and calls a junk hash function (0x10000). Mesa bug (no bounds check) | `frame.unreal_gl_shim` (GL shim with `gl_hide_msrtt`, multiview kept for Unreal: Unreal 4.25 only enables multiview with GL_OVR_multiview, GL_OVR_multiview2 **and** GL_OVR_multiview_multisampled_render_to_texture, so the shim keeps the last one visible for Unreal and maps glFramebufferTextureMultisampleMultiviewOVR to the single-sampled glFramebufferTextureMultiviewOVR; log `multiview multisampled render-to-texture (N samples) drawn single-sampled`) |
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// action_dedup (session_fixes.c, GitHub #323): a free name for a controller action whose name or localized name is
|
||||
// already used in its action set. No OpenXR types, so the host tests can compile it.
|
||||
#pragma once
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static size_t action_name_len(const char *s, size_t max) { // strnlen (not in plain C11)
|
||||
size_t n = 0;
|
||||
while (n < max && s[n]) ++n;
|
||||
return n;
|
||||
}
|
||||
|
||||
// taken(ctx, name, localized): 1 when `name` or `localized` is already used in the set.
|
||||
typedef int (*action_name_taken_fn)(void *ctx, const char *name, const char *localized);
|
||||
|
||||
// Writes "<name>_N" / "<localized> N" (N = 2..99, the first pair not taken; cut to fit name_size/localized_size
|
||||
// including the terminator) and returns 1; 0 when the original names are free or no free pair exists.
|
||||
static int action_unique_names(const char *name, const char *localized, action_name_taken_fn taken, void *ctx,
|
||||
char *out_name, size_t name_size, char *out_localized, size_t localized_size) {
|
||||
if (!taken(ctx, name, localized) || name_size < 8 || localized_size < 8) return 0;
|
||||
for (int n = 2; n < 100; ++n) {
|
||||
char suffix[8], lsuffix[8];
|
||||
snprintf(suffix, sizeof(suffix), "_%d", n);
|
||||
snprintf(lsuffix, sizeof(lsuffix), " %d", n);
|
||||
size_t keep = action_name_len(name, name_size - 1);
|
||||
if (keep + strlen(suffix) > name_size - 1) keep = name_size - 1 - strlen(suffix);
|
||||
snprintf(out_name, name_size, "%.*s%s", (int)keep, name, suffix);
|
||||
size_t lkeep = action_name_len(localized, localized_size - 1);
|
||||
if (lkeep + strlen(lsuffix) > localized_size - 1) lkeep = localized_size - 1 - strlen(lsuffix);
|
||||
snprintf(out_localized, localized_size, "%.*s%s", (int)lkeep, localized, lsuffix);
|
||||
if (!taken(ctx, out_name, out_localized)) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -113,6 +113,10 @@ static void snap_on_enumerate(XrSwapchain handle, uint32_t count, const XrSwapch
|
||||
static void snap_on_acquire(XrSwapchain handle, uint32_t index);
|
||||
static void snap_end_frame(const XrFrameEndInfo *info);
|
||||
static int frame_balance = 0; // end a still-open frame before the next xrBeginFrame
|
||||
// diagnostics (headless launch tests, GitHub #323): report shouldRender=TRUE from xrWaitFrame. Without a worn headset the
|
||||
// session never becomes VISIBLE, the runtime answers shouldRender=FALSE and engines that honour it (Unreal's OpenXR
|
||||
// plugin) submit no layers at all, so a headless run never exercises the game's real frame submission.
|
||||
static int force_render = 0;
|
||||
static int frame_begins, frame_discarded, frame_ends, frame_balanced; // per pacing period (layer_debug)
|
||||
static int frame_open;
|
||||
static int sc_acquires, sc_waits, sc_releases, sc_wait_fails; // per pacing period (layer_debug) // drop XrCompositionLayerColorScaleBiasKHR (the runtime's color pass)
|
||||
@@ -120,6 +124,7 @@ static int controller_fix = 1;
|
||||
static int swapchain_fix = 1;
|
||||
static int cube_standin = 1; // cube swapchains the runtime refuses: served by the adapter (cube_standin.c)
|
||||
static int rect_clamp = 1;
|
||||
static int action_dedup = 1; // rename duplicate action names in a set (session_fixes.c; GitHub #323)
|
||||
static int pose_consistency = 0; // repeat xrLocateViews queries for one display time get the same poses (GitHub #8) // clamp submitted image rects to their swapchain (SteamVR rejects a 1 px overrun)
|
||||
static int pose_time_fix; // poses asked for at CLOCK_MONOTONIC "now" or far in the past: at XrTime "now" (pose_time.c)
|
||||
static int pose_debug; // diagnostics: requested pose times vs the predicted display time (pose_time.c)
|
||||
@@ -178,6 +183,7 @@ static void read_settings(const char *path) {
|
||||
if (sscanf(line, "strip_color_bias=%f", &value) == 1) strip_color_bias = (int)value;
|
||||
if (sscanf(line, "fade_quad=%f", &value) == 1) fade_quad = value > 0 ? (int)value : 0;
|
||||
if (sscanf(line, "frame_balance=%f", &value) == 1) frame_balance = value != 0;
|
||||
if (sscanf(line, "force_render=%f", &value) == 1) force_render = value != 0;
|
||||
if (sscanf(line, "snapshot=%f", &value) == 1) snapshot = value > 0 ? (int)value : 0;
|
||||
if (sscanf(line, "flip_dump=%f", &value) == 1) flip_dump = value > 0 ? (int)value : 0;
|
||||
if (sscanf(line, "gl_error_clear=%f", &value) == 1) gl_error_clear = value != 0;
|
||||
@@ -185,6 +191,7 @@ static void read_settings(const char *path) {
|
||||
if (sscanf(line, "swapchain_fix=%f", &value) == 1) swapchain_fix = value != 0;
|
||||
if (sscanf(line, "cube_standin=%f", &value) == 1) cube_standin = value != 0;
|
||||
if (sscanf(line, "rect_clamp=%f", &value) == 1) rect_clamp = value != 0;
|
||||
if (sscanf(line, "action_dedup=%f", &value) == 1) action_dedup = value != 0;
|
||||
if (sscanf(line, "pose_consistency=%f", &value) == 1) pose_consistency = value != 0;
|
||||
if (sscanf(line, "pose_debug=%f", &value) == 1) pose_debug = value != 0;
|
||||
if (sscanf(line, "pose_time_fix=%f", &value) == 1) pose_time_fix = value != 0;
|
||||
@@ -277,6 +284,7 @@ static void initialize(void) {
|
||||
if (fade_quad) LOG("per-game: fade_quad=%d (colour-scale fades drawn as a black quad%s)", fade_quad,
|
||||
fade_quad > 1 ? "; the layer's own scale removed" : "");
|
||||
if (frame_balance) LOG("per-game: frame_balance=1 (an open frame is ended before the next one begins)");
|
||||
if (force_render) LOG("per-game: force_render=1 (xrWaitFrame reports shouldRender=TRUE; diagnostics)");
|
||||
if (eye_debug || release_wait) LOG("per-game: eye_debug=%d release_wait=%d log_file=%s", eye_debug, release_wait,
|
||||
log_file ? path : "none");
|
||||
if (controller_models && *process && !strchr(process, '/')) render_model_init(process);
|
||||
@@ -1414,6 +1422,16 @@ XRAPI_ATTR XrResult XRAPI_CALL xrWaitFrame(XrSession session, const XrFrameWaitI
|
||||
pose_time_display(state->predictedDisplayTime);
|
||||
pose_time_note_offset((long long)(state->predictedDisplayTime - mono));
|
||||
if (layer_debug) debug_aim_vs_grip(state->predictedDisplayTime);
|
||||
// shouldRender changes (a few): a game that submits no layers while the runtime says not to render shows
|
||||
// nothing, e.g. Unreal's OpenXR plugin in a headless launch test
|
||||
static int last_should_render = -1, should_render_logged;
|
||||
if ((int)state->shouldRender != last_should_render && should_render_logged < 12) {
|
||||
++should_render_logged;
|
||||
LOG("xrWaitFrame: shouldRender=%d period=%.2f ms%s", (int)state->shouldRender,
|
||||
state->predictedDisplayPeriod / 1e6, force_render && !state->shouldRender ? " (force_render: reported as 1)" : "");
|
||||
}
|
||||
last_should_render = (int)state->shouldRender;
|
||||
if (force_render) state->shouldRender = XR_TRUE;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -2199,7 +2217,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
if (layer_debug) HOOK_AS(xrGetActionStateBoolean, hook_xrGetActionStateBoolean)
|
||||
if (layer_debug || aim_correction_on() || profile_remap || input_diag || proximity_emul)
|
||||
HOOK_AS(xrSuggestInteractionProfileBindings, hook_xrSuggestInteractionProfileBindings)
|
||||
if (proximity_emul) HOOK_AS(xrCreateAction, hook_xrCreateAction)
|
||||
if (action_dedup || proximity_emul || input_diag) HOOK_AS(xrCreateAction, hook_xrCreateAction)
|
||||
if (layer_debug || aim_correction_on()) {
|
||||
HOOK_AS(xrCreateActionSpace, hook_xrCreateActionSpace)
|
||||
}
|
||||
|
||||
@@ -107,8 +107,6 @@ static const char *diag_perf_detail(XrPerfSettingsDomainEXT domain, XrPerfSettin
|
||||
}
|
||||
DIAG_CHECKED(xrStringToPath, (XrInstance instance, const char *path, XrPath *out), (instance, path, out),
|
||||
diag_name_detail(path))
|
||||
DIAG_CHECKED(xrCreateAction, (XrActionSet set, const XrActionCreateInfo *info, XrAction *action), (set, info, action),
|
||||
diag_name_detail(info ? info->actionName : NULL))
|
||||
DIAG_CHECKED(xrAttachSessionActionSets, (XrSession session, const XrSessionActionSetsAttachInfo *info),
|
||||
(session, info), NULL)
|
||||
DIAG_CHECKED(xrPerfSettingsSetPerformanceLevelEXT,
|
||||
@@ -116,11 +114,46 @@ DIAG_CHECKED(xrPerfSettingsSetPerformanceLevelEXT,
|
||||
(session, domain, level), diag_perf_detail(domain, level))
|
||||
#undef DIAG_CHECKED
|
||||
|
||||
// Every action set and action the game creates, with its localized name: SteamVR turns them into an input manifest and
|
||||
// drops a game whose manifest it can't load ("SetXrInputConfig failed. Failed to load manifest" in vrserver.txt; the
|
||||
// app then counts as exited: home environment while the game keeps running, GitHub #323). Bytes outside printable
|
||||
// ASCII and the JSON specials " and \ are written as \xNN.
|
||||
static const char *diag_escaped(const char *in, char *out, size_t size) {
|
||||
size_t used = 0;
|
||||
for (const unsigned char *c = (const unsigned char *)(in ? in : "(null)"); *c && used + 5 < size; ++c) {
|
||||
int special = *c < 0x20 || *c > 0x7e || *c == '"' || *c == '\\';
|
||||
used += (size_t)(special ? snprintf(out + used, size - used, "\\x%02x", *c)
|
||||
: snprintf(out + used, size - used, "%c", *c));
|
||||
}
|
||||
out[used] = 0;
|
||||
return out;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL diag_xrCreateActionSet(XrInstance instance, const XrActionSetCreateInfo *info,
|
||||
XrActionSet *set) {
|
||||
PFN_xrCreateActionSet fn = (PFN_xrCreateActionSet)lookup(instance, "xrCreateActionSet");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrResult result = fn(instance, info, set);
|
||||
char a[XR_MAX_ACTION_SET_NAME_SIZE * 4 + 8], b[XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE * 4 + 8];
|
||||
if (info)
|
||||
LOG("input_diag: action set \"%s\" (\"%s\") priority %u -> %d", diag_escaped(info->actionSetName, a, sizeof(a)),
|
||||
diag_escaped(info->localizedActionSetName, b, sizeof(b)), info->priority, (int)result);
|
||||
return result;
|
||||
}
|
||||
// Called by session_fixes.c's hook_xrCreateAction (hooked whenever input_diag is on), with the names it created.
|
||||
static void diag_log_action(const XrActionCreateInfo *info, XrResult result) {
|
||||
char a[XR_MAX_ACTION_NAME_SIZE * 4 + 8], b[XR_MAX_LOCALIZED_ACTION_NAME_SIZE * 4 + 8];
|
||||
if (info)
|
||||
LOG("input_diag: action \"%s\" (\"%s\") type %d, %u subaction path(s) -> %d",
|
||||
diag_escaped(info->actionName, a, sizeof(a)), diag_escaped(info->localizedActionName, b, sizeof(b)),
|
||||
(int)info->actionType, info->countSubactionPaths, (int)result);
|
||||
if (XR_FAILED(result)) diag_call_failed("xrCreateAction", result, diag_name_detail(info ? info->actionName : NULL));
|
||||
}
|
||||
|
||||
// xrGetInstanceProcAddr: our wrapper for `name`, NULL if it isn't one of ours.
|
||||
static PFN_xrVoidFunction input_diag_hook(const char *name) {
|
||||
#define DIAG(fn) if (!strcmp(name, #fn)) return (PFN_xrVoidFunction)diag_##fn;
|
||||
DIAG(xrStringToPath)
|
||||
DIAG(xrCreateAction)
|
||||
DIAG(xrCreateActionSet)
|
||||
DIAG(xrAttachSessionActionSets)
|
||||
DIAG(xrPerfSettingsSetPerformanceLevelEXT)
|
||||
#undef DIAG
|
||||
|
||||
@@ -328,12 +328,68 @@ static int touch_has(const char *path) {
|
||||
// rest touched; index (proximity_emul=2) = trigger touched. Only when the runtime lacks the extension itself.
|
||||
static XrAction thumb_proximity_action, trigger_proximity_action;
|
||||
|
||||
// ---------------------------------------------------------------- duplicate action names (action_dedup)
|
||||
// OpenXR requires action names and localized names to be unique within an action set (XR_ERROR_NAME_DUPLICATED), but
|
||||
// the Frame's runtime accepts duplicates and then can't load the input manifest it builds from them: vrserver logs
|
||||
// "SetXrInputConfig failed. Failed to load manifest", drops the app's connection and treats it as exited, so the
|
||||
// headset goes back to the home environment while the game keeps running with sound (GitHub #323, Scared By Squares:
|
||||
// Unreal's OpenXR plugin creates "turn" twice, from an action mapping and an axis mapping of the same name). The
|
||||
// game only uses the handles, so a later duplicate is created under a free name ("turn_2", "Turn 2").
|
||||
#include "action_names.h"
|
||||
#define DEDUP_MAX 512
|
||||
static struct { XrActionSet set; char name[XR_MAX_ACTION_NAME_SIZE]; char localized[XR_MAX_LOCALIZED_ACTION_NAME_SIZE]; }
|
||||
dedup_seen[DEDUP_MAX];
|
||||
static int dedup_count;
|
||||
static pthread_mutex_t dedup_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static int dedup_taken(void *ctx, const char *name, const char *localized) { // caller holds dedup_lock
|
||||
XrActionSet set = *(XrActionSet *)ctx;
|
||||
for (int i = 0; i < dedup_count; ++i)
|
||||
if (dedup_seen[i].set == set && (!strcmp(dedup_seen[i].name, name) || !strcmp(dedup_seen[i].localized, localized)))
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fills `out` (a copy of `info`) with free names when `info`'s are taken in `set`; 1 if it did.
|
||||
static int dedup_rename(XrActionSet set, const XrActionCreateInfo *info, XrActionCreateInfo *out,
|
||||
char name[XR_MAX_ACTION_NAME_SIZE], char localized[XR_MAX_LOCALIZED_ACTION_NAME_SIZE]) {
|
||||
if (!action_unique_names(info->actionName, info->localizedActionName, dedup_taken, &set, name,
|
||||
XR_MAX_ACTION_NAME_SIZE, localized, XR_MAX_LOCALIZED_ACTION_NAME_SIZE))
|
||||
return 0;
|
||||
*out = *info;
|
||||
memcpy(out->actionName, name, XR_MAX_ACTION_NAME_SIZE);
|
||||
memcpy(out->localizedActionName, localized, XR_MAX_LOCALIZED_ACTION_NAME_SIZE);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void dedup_note(XrActionSet set, const XrActionCreateInfo *info) { // caller holds dedup_lock
|
||||
if (dedup_count >= DEDUP_MAX) return;
|
||||
dedup_seen[dedup_count].set = set;
|
||||
snprintf(dedup_seen[dedup_count].name, XR_MAX_ACTION_NAME_SIZE, "%s", info->actionName);
|
||||
snprintf(dedup_seen[dedup_count].localized, XR_MAX_LOCALIZED_ACTION_NAME_SIZE, "%s", info->localizedActionName);
|
||||
++dedup_count;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrCreateAction(XrActionSet set, const XrActionCreateInfo *info,
|
||||
XrAction *action) {
|
||||
PFN_xrCreateAction fn = (PFN_xrCreateAction)lookup(active_instance, "xrCreateAction");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrResult r = fn(set, info, action);
|
||||
if (XR_SUCCEEDED(r) && info && action) { // OVRPlugin's action names
|
||||
XrActionCreateInfo renamed;
|
||||
char name[XR_MAX_ACTION_NAME_SIZE], localized[XR_MAX_LOCALIZED_ACTION_NAME_SIZE];
|
||||
const XrActionCreateInfo *use = info;
|
||||
if (action_dedup && info) pthread_mutex_lock(&dedup_lock);
|
||||
if (action_dedup && info && dedup_rename(set, info, &renamed, name, localized)) {
|
||||
LOG("action_dedup: action \"%s\" (\"%s\") already exists in its set: created as \"%s\" (\"%s\")",
|
||||
info->actionName, info->localizedActionName, name, localized);
|
||||
use = &renamed;
|
||||
}
|
||||
XrResult r = fn(set, use, action);
|
||||
if (action_dedup && info) {
|
||||
if (XR_SUCCEEDED(r)) dedup_note(set, use);
|
||||
pthread_mutex_unlock(&dedup_lock);
|
||||
}
|
||||
if (input_diag) diag_log_action(use, r);
|
||||
if (proximity_emul && XR_SUCCEEDED(r) && info && action) { // OVRPlugin's action names
|
||||
if (!strcmp(info->actionName, "hand_thumb_proximity")) thumb_proximity_action = *action;
|
||||
else if (!strcmp(info->actionName, "hand_trigger_proximity")) trigger_proximity_action = *action;
|
||||
else return r;
|
||||
|
||||
+5
-2
@@ -137,7 +137,8 @@ def main() -> int:
|
||||
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
|
||||
("tools", lambda a: a.go("settings")),
|
||||
# the index on the left: "This PC" scrolls the page there
|
||||
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc"))]
|
||||
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc")),
|
||||
("settings-installing", lambda a: a.page.run_task(a.settings_view.show_section, "installing"))]
|
||||
if game:
|
||||
steps.insert(1, ("game", lambda a: a.open_game(game)))
|
||||
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
|
||||
@@ -150,7 +151,8 @@ def main() -> int:
|
||||
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
|
||||
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
|
||||
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
|
||||
# last: the right-click menu stays open over whatever comes next
|
||||
# last: the menus stay open over whatever comes next (the page's "…" menu, then the right-click menu)
|
||||
steps.append(("game-menu", lambda a: (a.show_activity(False), a.open_game_menu(game))))
|
||||
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(game))))
|
||||
if args.docs:
|
||||
@@ -278,6 +280,7 @@ def main() -> int:
|
||||
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
|
||||
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
|
||||
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
|
||||
("linux-game-menu", lambda a: (a.page.pop_dialog(), a.open_game_menu(LINUX_FOLDER))),
|
||||
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(LINUX_APPIMAGE)))]
|
||||
if args.links:
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"adapter.action_dedup": {
|
||||
"anchor": "adapter-action_dedup",
|
||||
"title": "Unique controller action names",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.aim_forward": {
|
||||
"anchor": "adapter-aim_forward",
|
||||
"title": "Pointer start",
|
||||
|
||||
@@ -398,6 +398,30 @@ def rename(package: str = typer.Argument(..., help="the game (package or part of
|
||||
pipeline.sync_title(pkg, _target(None, to="pc"), rep)
|
||||
|
||||
|
||||
@app.command("new-version")
|
||||
def new_version(package: str = typer.Argument(..., help="the game (package or part of its title)"),
|
||||
path: Path = typer.Argument(..., exists=True, help="the newer version: an APK (Quest/Android games), "
|
||||
"an AppImage/archive/folder (Linux apps) or the game folder (PC VR)")):
|
||||
"""Point a game at a newer version of it (GitHub #161). Its name, artwork, tags and your own recipe stay; install
|
||||
it again (frameport install) to update it on the Frame."""
|
||||
[pkg] = _pkgs(package, False)
|
||||
try:
|
||||
res = pipeline.replace_source(pkg, path, printing_reporter(verbose=False))
|
||||
except pipeline.WrongPackage as exc:
|
||||
raise typer.BadParameter(f"that APK is a different app: {exc}") from None
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc)) from None
|
||||
g = res["entry"]
|
||||
typer.echo(f"{pkg}: now {path}" + (f" (program {g.get('exe')})" if g.get("kind") in ("rift", "linux") else ""))
|
||||
if res.get("older"):
|
||||
typer.echo("Heads-up: that APK is an older version than the one before.", err=True)
|
||||
if res.get("different"):
|
||||
typer.echo(f"Heads-up: {res.get('name')} looks like a different app than {g.get('title')}.", err=True)
|
||||
if g.get("kind") == "rift" and g.get("exe_confirmed") is False:
|
||||
typer.echo(f"More than one program could start it; FramePort picked {g.get('exe')}. Another one: "
|
||||
f"frameport recipe {pkg} --exe <program>", err=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def patches():
|
||||
"""List every available patch."""
|
||||
@@ -706,6 +730,31 @@ def frame_trust_key(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
typer.echo(f"connected: {info.get('hostname') or t.host} (new key saved)")
|
||||
|
||||
|
||||
@frame_app.command("ssh-keys")
|
||||
def frame_ssh_keys(add: list[str] = typer.Option([], "--add", help="a private key file to log in with too"),
|
||||
remove: list[str] = typer.Option([], "--remove", help="stop using this key file (it stays)")):
|
||||
"""Private SSH key files FramePort logs in with besides its own key (Settings → Installing → SSH keys): for a
|
||||
Frame that accepts only your own key. The Frame connection and OpenSSH uploads both use them. Lists them."""
|
||||
from .frame.connection import KeyFileError, add_extra_key, extra_key_status, remove_extra_key
|
||||
|
||||
failed = False
|
||||
for path in remove:
|
||||
typer.echo(f"removed {path}" if remove_extra_key(path) else f"not in the list: {path}")
|
||||
for path in add:
|
||||
try:
|
||||
typer.echo(f"added {add_extra_key(path)}")
|
||||
except KeyFileError as exc:
|
||||
typer.echo(f"can't use {path}: {exc}", err=True)
|
||||
failed = True
|
||||
status = extra_key_status()
|
||||
for path, problem in status:
|
||||
typer.echo(f"{path}" + (f" (not usable: {problem})" if problem else ""))
|
||||
if not status:
|
||||
typer.echo("no extra keys: FramePort's own key and your SSH agent's keys only")
|
||||
if failed:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@frame_app.command("usb-check")
|
||||
def frame_usb_check(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
no_speed: bool = typer.Option(False, help="skip the upload speed test (2 × 128 MB)")):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""SSH connection to a Steam Frame and the remote agent protocol.
|
||||
|
||||
Authentication: FramePort's own keys first (<user data>/ssh/id_ed25519, installed by the bootstrap script, and
|
||||
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then the user's SSH agent/keys, then a
|
||||
password if given.
|
||||
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then key files the user added in
|
||||
Settings (EXTRA_KEYS_SETTING), then the user's SSH agent/keys, then a password if given.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -163,6 +163,96 @@ def app_keys() -> list[paramiko.PKey]:
|
||||
return keys
|
||||
|
||||
|
||||
EXTRA_KEYS_SETTING = "ssh.extra_keys" # library setting: private key files the user added (Settings → Installing)
|
||||
|
||||
|
||||
class KeyFileError(ValueError):
|
||||
"""A key file the user named can't be used (missing, not a private key, protected with a passphrase)."""
|
||||
|
||||
|
||||
def load_key_file(path) -> paramiko.PKey:
|
||||
"""A private key FramePort can log in with, or KeyFileError with a sentence saying why not."""
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_file():
|
||||
raise KeyFileError(f"{p}: file not found")
|
||||
try:
|
||||
return paramiko.PKey.from_path(p)
|
||||
except (paramiko.SSHException, ValueError, OSError, UnicodeDecodeError, TypeError) as exc:
|
||||
# paramiko says so with PasswordRequiredException (PEM) or a plain SSHException (OpenSSH format)
|
||||
if isinstance(exc, paramiko.PasswordRequiredException) or "password" in str(exc).lower():
|
||||
raise KeyFileError(f"{p.name} is protected with a passphrase, which FramePort can't ask for: add the "
|
||||
"key to your SSH agent instead (FramePort also logs in with the agent's keys)") from None
|
||||
raise KeyFileError(f"{p.name} isn't a private SSH key FramePort can read "
|
||||
f"(OpenSSH or PEM format; a .pub file is the public half): {exc}") from None
|
||||
|
||||
|
||||
def extra_key_paths() -> list[Path]:
|
||||
"""The key files from Settings, as stored (each may be missing or unreadable by now: see extra_keys)."""
|
||||
try:
|
||||
from ..core import library
|
||||
|
||||
stored = library.peek_setting(EXTRA_KEYS_SETTING, [])
|
||||
except Exception: # noqa: BLE001 - no library yet
|
||||
return []
|
||||
if isinstance(stored, str):
|
||||
stored = [stored]
|
||||
paths: list[Path] = []
|
||||
for value in stored if isinstance(stored, list) else []:
|
||||
if isinstance(value, str) and value.strip():
|
||||
p = Path(value.strip()).expanduser()
|
||||
if p not in paths:
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
|
||||
def add_extra_key(path) -> Path:
|
||||
"""Check a key file (KeyFileError if FramePort can't use it) and add it to Settings. Returns the stored path."""
|
||||
p = Path(path).expanduser()
|
||||
if p.name.endswith(".pub") and p.with_name(p.name[:-4]).is_file():
|
||||
raise KeyFileError(f"{p.name} is the public half: pick {p.name[:-4]} (the file without .pub) instead")
|
||||
load_key_file(p)
|
||||
p = p.resolve()
|
||||
from ..core import library
|
||||
|
||||
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
|
||||
if Path(v).expanduser() != p] + [str(p)], [])
|
||||
return p
|
||||
|
||||
|
||||
def remove_extra_key(path) -> bool:
|
||||
"""Take a key file out of Settings (the file itself stays). False if it wasn't there."""
|
||||
from ..core import library
|
||||
|
||||
p = Path(path).expanduser()
|
||||
before = extra_key_paths()
|
||||
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
|
||||
if Path(v).expanduser() != p], [])
|
||||
return p in before
|
||||
|
||||
|
||||
def extra_key_status() -> list[tuple[Path, str]]:
|
||||
"""Each key file from Settings with "" when usable, else why not (for Settings and the CLI)."""
|
||||
status = []
|
||||
for path in extra_key_paths():
|
||||
try:
|
||||
load_key_file(path)
|
||||
status.append((path, ""))
|
||||
except KeyFileError as exc:
|
||||
status.append((path, str(exc)))
|
||||
return status
|
||||
|
||||
|
||||
def extra_keys() -> list[tuple[Path, paramiko.PKey]]:
|
||||
"""The usable keys from Settings; unusable ones are logged and skipped (a login still tries the others)."""
|
||||
keys = []
|
||||
for path in extra_key_paths():
|
||||
try:
|
||||
keys.append((path, load_key_file(path)))
|
||||
except KeyFileError as exc:
|
||||
log.warning("SSH key from Settings skipped: %s", exc)
|
||||
return keys
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameTarget:
|
||||
host: str
|
||||
@@ -409,6 +499,10 @@ class Frame:
|
||||
_video_codec_digest: str = "" # "<manifest digest>:<on|off>" handled on this connection (incl. a failed install)
|
||||
_video_codec_lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
home: str = ""
|
||||
# how the last login worked: "app_key" (FramePort's own key), "user_key" (a key file from Settings),
|
||||
# "agent" (this PC's SSH agent / ~/.ssh keys) or "password"; OpenSSH uploads follow it (native_upload)
|
||||
auth: str = ""
|
||||
openssh_relaxed: bool | None = None # native_upload: True once OpenSSH needed this PC's own keys/agent
|
||||
|
||||
# ------------------------------------------------------------------ connect
|
||||
def connect(self, timeout: float = 10) -> Frame:
|
||||
@@ -459,7 +553,8 @@ class Frame:
|
||||
kwargs = dict(hostname=self.target.host, port=self.target.port, username=self.target.user, timeout=timeout,
|
||||
banner_timeout=timeout, auth_timeout=timeout)
|
||||
errors = []
|
||||
attempts = [("app_key", k) for k in app_keys()] + [("agent", None), ("password", None)]
|
||||
attempts = ([("app_key", k) for k in app_keys()] + [("user_key", k) for _, k in extra_keys()]
|
||||
+ [("agent", None), ("password", None)])
|
||||
for attempt, pkey in attempts:
|
||||
if attempt == "password" and not self.password:
|
||||
continue
|
||||
@@ -467,7 +562,7 @@ class Frame:
|
||||
client.load_host_keys(str(known))
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # trust on first use (pairing)
|
||||
try:
|
||||
if attempt == "app_key":
|
||||
if attempt in ("app_key", "user_key"):
|
||||
client.connect(pkey=pkey, allow_agent=False, look_for_keys=False, **kwargs)
|
||||
elif attempt == "agent":
|
||||
client.connect(allow_agent=True, look_for_keys=True, **kwargs)
|
||||
@@ -492,6 +587,7 @@ class Frame:
|
||||
raise
|
||||
else:
|
||||
raise FrameNotPaired(bool(self.password), errors)
|
||||
self.auth = attempt
|
||||
client.save_host_keys(str(known))
|
||||
transport = client.get_transport()
|
||||
transport.set_keepalive(15)
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
@@ -13,19 +12,30 @@ import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PureWindowsPath
|
||||
|
||||
from .connection import sh_quote, ssh_dir
|
||||
from .connection import extra_keys, sh_quote, ssh_dir
|
||||
|
||||
|
||||
def quote(value) -> str:
|
||||
def quote(value, windows=False) -> str:
|
||||
"""One sftp batch argument, taken literally.
|
||||
|
||||
Inside double quotes sftp escapes glob characters (* ? [) itself and undoes that for put's destination, so
|
||||
they must NOT be escaped again: an extra backslash became part of the name (GitHub #324: "[ENG]" in a folder
|
||||
name made every OpenSSH upload fail, on Linux too). Only backslash and the quote are escaped. ``windows``: a
|
||||
local Windows path goes with forward slashes; Win32-OpenSSH's sftp turns every backslash of a local path into
|
||||
'/', escapes included ("C:\\\\Users" → "C://Users").
|
||||
"""
|
||||
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 + '"'
|
||||
if windows:
|
||||
value = PureWindowsPath(value).as_posix()
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def quote_local(path) -> str:
|
||||
return quote(path, windows=os.name == "nt")
|
||||
|
||||
|
||||
def size_of(sftp, path):
|
||||
@@ -35,27 +45,74 @@ def size_of(sftp, path):
|
||||
return 0
|
||||
|
||||
|
||||
def key_files(relaxed=False) -> list[Path]:
|
||||
"""The private keys OpenSSH offers: FramePort's own (<data>/ssh) and the usable key files from Settings;
|
||||
``relaxed`` also this PC's default keys (default_key_files)."""
|
||||
keys = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
|
||||
for path in [path for path, _ in extra_keys()] + (default_key_files() if relaxed else []):
|
||||
if path not in keys:
|
||||
keys.append(path)
|
||||
return keys
|
||||
|
||||
|
||||
def default_key_files() -> list[Path]:
|
||||
"""This PC's own keys that Paramiko's "agent" login tries (look_for_keys: ~/.ssh/id_rsa, id_dsa, id_ecdsa,
|
||||
id_ed25519; on Windows ~/ssh too), usable without a passphrase. OpenSSH needs them named: it skips its default
|
||||
keys as soon as one -i is given, and Windows OpenSSH refuses a key file others can read (a private copy can't
|
||||
be refused)."""
|
||||
from .connection import KeyFileError, load_key_file
|
||||
|
||||
folders = [Path.home() / ".ssh"] + ([Path.home() / "ssh"] if os.name == "nt" else [])
|
||||
found = []
|
||||
for folder in folders:
|
||||
for name in ("id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"):
|
||||
path = folder / name
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
load_key_file(path)
|
||||
except KeyFileError:
|
||||
continue # passphrase-protected or not a key: only the agent can use it
|
||||
found.append(path)
|
||||
return found
|
||||
|
||||
|
||||
def current_sid() -> str:
|
||||
"""This Windows account's SID. whoami prints in the console code page: read bytes, so a user name with
|
||||
accents can't break decoding (only the ASCII SID is used)."""
|
||||
out = subprocess.check_output(["whoami", "/user", "/fo", "csv", "/nh"], **hidden())
|
||||
match = re.search(rb"S-1-[0-9]+(?:-[0-9]+)+", out)
|
||||
if not match:
|
||||
raise OSError("cannot find this Windows account's SID for the key copies' permissions")
|
||||
return match.group().decode()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def credentials(frame, port_flag="-P"):
|
||||
"""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:
|
||||
def credentials(frame, port_flag="-P", relaxed=False):
|
||||
"""OpenSSH arguments that log in like the Frame connection and trust only the host key it pinned.
|
||||
|
||||
Keys are offered as private temporary copies (that avoids modifying pairing keys, including their Windows
|
||||
ACLs; Windows OpenSSH refuses key files others can read). Strict (default): FramePort's keys and the key
|
||||
files from Settings only (IdentitiesOnly). ``relaxed``: also this PC's default ~/.ssh keys and SSH agent, the
|
||||
way Paramiko's "agent" login does (GitHub #242: a Frame that only authorizes the user's own key). Verbose
|
||||
(-v), so a refused login can say which keys were offered (failure()).
|
||||
"""
|
||||
key_paths = key_files(relaxed)
|
||||
if not key_paths and not relaxed:
|
||||
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]
|
||||
sid = current_sid()
|
||||
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
|
||||
for n, path in enumerate(key_paths):
|
||||
copy = directory / f"key{n}" # own names: a key from Settings may be called id_ed25519 too
|
||||
shutil.copyfile(path, copy)
|
||||
if sid:
|
||||
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
|
||||
@@ -66,13 +123,72 @@ def credentials(frame, port_flag="-P"):
|
||||
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",
|
||||
yield ["-F", os.devnull, "-v", *copies, "-o", "BatchMode=yes",
|
||||
*([] if relaxed else ["-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",
|
||||
port_flag, str(frame.target.port)]
|
||||
|
||||
|
||||
class OpenSSHAuthFailed(OSError):
|
||||
"""OpenSSH reached the Frame but the login was refused (the keys it offered aren't authorized there)."""
|
||||
|
||||
|
||||
AUTH_REFUSED = ("Permission denied (", "Too many authentication failures", "No more authentication methods")
|
||||
# debug lines (-v) worth keeping: which keys were found, offered, refused or unreadable
|
||||
AUTH_DEBUG = ("identity file", "Offering public key", "Server accepts key", "Will attempt key",
|
||||
"Authentications that can continue", "Authenticated to", "Load key", "no such identity",
|
||||
"Skipping", "agent", "Next authentication method")
|
||||
|
||||
|
||||
def failure(what: str, code, stderr: bytes) -> OSError:
|
||||
"""The error for a failed OpenSSH run: OpenSSH's own messages (all non-debug lines: warnings such as
|
||||
"UNPROTECTED PRIVATE KEY FILE" included) plus the -v lines about keys; OpenSSHAuthFailed for a refused login."""
|
||||
text = stderr.decode(errors="replace").replace("\r\n", "\n")
|
||||
lines = [line for line in text.splitlines() if line.strip() and (
|
||||
not line.startswith("debug") or any(k in line for k in AUTH_DEBUG))]
|
||||
detail = "\n".join(lines)
|
||||
if len(detail) > 4000:
|
||||
detail = "…" + detail[-4000:]
|
||||
refused = code == 255 and any(marker in text for marker in AUTH_REFUSED)
|
||||
return (OpenSSHAuthFailed if refused else OSError)(f"{what} failed ({code}): {detail}")
|
||||
|
||||
|
||||
def login_modes(frame) -> list[bool]:
|
||||
"""How OpenSSH logs in, in order (False = FramePort's keys only, True = also this PC's keys and agent).
|
||||
|
||||
A Frame connection that logged in with this PC's own keys/agent (Paramiko's "agent" attempt) goes straight to
|
||||
relaxed: FramePort's key isn't authorized there. Else strict first, relaxed once more after a refused login.
|
||||
A mode that worked is kept for the rest of the connection. FramePort doesn't add its key to the Frame for
|
||||
this: it only does that after a password login, where the user typed the Frame's password for it."""
|
||||
known = getattr(frame, "openssh_relaxed", None)
|
||||
if known is not None:
|
||||
return [bool(known)]
|
||||
if getattr(frame, "auth", "") == "agent":
|
||||
return [True]
|
||||
return [False, True]
|
||||
|
||||
|
||||
def with_login_modes(frame, reporter, run):
|
||||
"""run(relaxed) in each login mode until one isn't refused (see login_modes)."""
|
||||
modes = login_modes(frame)
|
||||
if modes == [True] and getattr(frame, "openssh_relaxed", None) is None:
|
||||
reporter.log("the Frame connection logged in with this PC's own SSH keys or agent: OpenSSH uses them too")
|
||||
for n, relaxed in enumerate(modes):
|
||||
try:
|
||||
result = run(relaxed)
|
||||
except OpenSSHAuthFailed as exc:
|
||||
if n == len(modes) - 1:
|
||||
raise
|
||||
reporter.log(f"OpenSSH login with FramePort's SSH keys was refused; trying this PC's own SSH keys and "
|
||||
f"agent too. {exc}")
|
||||
continue
|
||||
with contextlib.suppress(AttributeError):
|
||||
frame.openssh_relaxed = relaxed
|
||||
return result
|
||||
|
||||
|
||||
def hidden():
|
||||
return {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
|
||||
|
||||
@@ -99,6 +215,11 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
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.
|
||||
"""
|
||||
return with_login_modes(frame, reporter, lambda relaxed: _upload(
|
||||
frame, items, remote_root, reporter, total, sent, workers, relaxed))
|
||||
|
||||
|
||||
def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
|
||||
executable = shutil.which("sftp")
|
||||
if not executable:
|
||||
raise OSError("system OpenSSH SFTP is unavailable")
|
||||
@@ -111,7 +232,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
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_local(path)
|
||||
quote(remote)
|
||||
offset = size_of(frame.sftp, remote + ".part")
|
||||
if offset > size:
|
||||
@@ -127,7 +248,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
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:
|
||||
with credentials(frame, relaxed=relaxed) 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")
|
||||
@@ -139,7 +260,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
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.write(f"{command} {quote_local(path)} {quote(remote)}\n".encode())
|
||||
batch.seek(0)
|
||||
errors = stack.enter_context(tempfile.TemporaryFile())
|
||||
process = subprocess.Popen([executable, *args, "-b", "-",
|
||||
@@ -152,8 +273,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
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}")
|
||||
raise failure("OpenSSH SFTP", process.returncode, errors.read())
|
||||
for rel, size in sizes.items():
|
||||
reporter.check_cancel()
|
||||
progress[rel] = max(progress[rel], min(size, size_of(
|
||||
@@ -187,11 +307,16 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
|
||||
def put_tar(frame, files, remote_root, reporter, progress):
|
||||
"""Stream a tar batch over paired OpenSSH; supervise writes so Cancel also stops a blocked pipe."""
|
||||
return with_login_modes(frame, reporter,
|
||||
lambda relaxed: _put_tar(frame, files, remote_root, reporter, progress, relaxed))
|
||||
|
||||
|
||||
def _put_tar(frame, files, remote_root, reporter, progress, relaxed):
|
||||
executable = shutil.which("ssh")
|
||||
if not executable:
|
||||
raise OSError("system OpenSSH SSH is unavailable")
|
||||
reporter.check_cancel()
|
||||
with credentials(frame, port_flag="-p") as args, tempfile.TemporaryFile() as errors:
|
||||
with credentials(frame, port_flag="-p", relaxed=relaxed) as args, tempfile.TemporaryFile() as errors:
|
||||
command = f"mkdir -p {sh_quote(remote_root)} && tar -xf - -C {sh_quote(remote_root)}"
|
||||
process = subprocess.Popen([executable, *args, f"{frame.target.user}@{frame.target.host}", command],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errors,
|
||||
@@ -222,12 +347,14 @@ def put_tar(frame, files, remote_root, reporter, progress):
|
||||
if not writer.is_alive() and process.poll() is None:
|
||||
time.sleep(0.1)
|
||||
reporter.check_cancel()
|
||||
if failures and process.poll() is None: # a refused login shows as a broken pipe first
|
||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||
process.wait(timeout=2)
|
||||
if process.poll() not in (None, 0):
|
||||
errors.seek(0)
|
||||
raise failure("OpenSSH tar", process.returncode, errors.read())
|
||||
if failures:
|
||||
raise failures[0]
|
||||
if process.poll() != 0:
|
||||
errors.seek(0)
|
||||
raise OSError(f"OpenSSH tar failed ({process.returncode}): "
|
||||
+ errors.read().decode(errors="replace")[-1500:])
|
||||
finally:
|
||||
stop_process(process)
|
||||
writer.join()
|
||||
|
||||
@@ -220,6 +220,28 @@ class PcvrPlan:
|
||||
art_lookup: str | None = None # package whose store art to use
|
||||
dest: str | None = None
|
||||
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
|
||||
source_id: str | None = None # which version of the game folder (GitHub #161: a newer one was added)
|
||||
changed_since: float | None = None # a newer version: game files changed after this are sent again (see below)
|
||||
|
||||
|
||||
def changed_since(path: Path, since: float | None) -> bool:
|
||||
"""A newer version of a Linux app / PC VR game (GitHub #161): uploads skip files the Frame has with the same
|
||||
size, so a changed file of the same size would stay old. Files written (or unpacked: ctime) after the last
|
||||
install are sent again; since=None (no newer version) never, 0 (last install unknown) always."""
|
||||
if since is None:
|
||||
return False
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return True
|
||||
return max(st.st_mtime, st.st_ctime) > since
|
||||
|
||||
|
||||
def _recipe_record(recipe: dict | None, source_id: str | None) -> dict | None:
|
||||
"""The deployment's recipe record, with the source version it was installed from (ui.components.source_replaced)."""
|
||||
if not source_id:
|
||||
return recipe
|
||||
return {**(recipe or {}), "source_id": source_id}
|
||||
|
||||
|
||||
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
@@ -259,7 +281,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
manifests["helpers"] = {p.name: p.stat().st_size for p in sorted(helpers.iterdir()) if p.is_file()}
|
||||
sources["helpers"] = helpers
|
||||
to_send = [(t, rel) for t, m in manifests.items() for rel, size in m.items()
|
||||
if prep["existing"].get(t, {}).get(rel) != size]
|
||||
if prep["existing"].get(t, {}).get(rel) != size
|
||||
or t == "game" and changed_since(plan.game_dir / rel, plan.changed_since)]
|
||||
need = sum(manifests[t][rel] for t, rel in to_send)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
@@ -290,7 +313,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
|
||||
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
|
||||
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, overlay=plan.overlay,
|
||||
recipe=_recipe_record({"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, plan.source_id),
|
||||
overlay=plan.overlay,
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
|
||||
log_overlay(result.get("overlay"), reporter)
|
||||
@@ -325,6 +349,8 @@ class LinuxPlan:
|
||||
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
|
||||
desktop_icon: bool = True # with it, a copy on Desktop Mode's desktop (GitHub #212)
|
||||
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
|
||||
source_id: str | None = None # which version of the app (GitHub #161: a newer one was added)
|
||||
changed_since: float | None = None # a newer version: files changed after this are sent again
|
||||
|
||||
|
||||
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
@@ -339,7 +365,8 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
else:
|
||||
manifest = local_data_manifest(plan.root)
|
||||
manifest = {k: v for k, v in manifest.items() if not k.startswith(".unpacked")}
|
||||
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size]
|
||||
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size
|
||||
or changed_since(plan.root / rel, plan.changed_since)]
|
||||
need = sum(manifest[rel] for rel in to_send)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
@@ -358,7 +385,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
|
||||
executables=executables, tags=_tags(plan.package), dest=plan.dest,
|
||||
desktop_entry=plan.desktop_entry, desktop_icon=plan.desktop_icon, overlay=plan.overlay,
|
||||
timeout=900)
|
||||
recipe=_recipe_record(None, plan.source_id), timeout=900)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
log_overlay(result.get("overlay"), reporter)
|
||||
apply_app_icon(frame, plan.package, prep.get("anchor"), result, reporter)
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"AC Nexus: no AppSW at 72 Hz": "",
|
||||
"AC Nexus: no AppSW at 90 Hz": "",
|
||||
"Activity": "",
|
||||
"Add a key file…": "",
|
||||
"Add a Linux app folder…": "",
|
||||
"Add a Linux app…": "",
|
||||
"Add a PC game folder…": "",
|
||||
@@ -557,6 +558,7 @@
|
||||
"Frame OpenXR compatibility layer": "",
|
||||
"FrameBridge OpenXR adapter": "",
|
||||
"FramePort": "",
|
||||
"FramePort also logs in with {name} from the next connection.": "",
|
||||
"FramePort can't update itself in {value} (no permission) — opening the release page to download it": "",
|
||||
"FramePort changelog": "",
|
||||
"FramePort checks GitHub for a new version every few hours. Update now downloads and checks it, then restarts FramePort; your games and settings stay.": "",
|
||||
@@ -568,6 +570,7 @@
|
||||
"FramePort isn't sure which program starts this game. Open the game and use … → Change program… to pick it.": "",
|
||||
"FramePort keeps its own copies; nothing is installed system-wide": "",
|
||||
"FramePort live view": "",
|
||||
"FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH agent instead.": "",
|
||||
"FramePort looked at your last session of {n} game and has something to try.": [
|
||||
"",
|
||||
""
|
||||
@@ -635,6 +638,7 @@
|
||||
"Getting FramePort ready": "",
|
||||
"GiB": "",
|
||||
"GitHub issue opened": "",
|
||||
"Gives a controller action whose name already exists in its action set a free name (\"turn\" -> \"turn_2\"). The Frame's runtime accepts duplicate names but then can't load the game's input manifest and treats the game as closed: black, then the home environment while the game keeps running with sound (for example Scared By Squares, whose Unreal project has an action and an axis both called Turn; GitHub #323).": "",
|
||||
"Gives older Quest games a newer version of Meta's VR plugin that can be converted.": "",
|
||||
"GL shim (frame.gl_shim): shader inputs declared as integers (int/uint, ivecN/uvecN) whose vertex data the game set up with glVertexAttribPointer (as floats) are set up again with glVertexAttribIPointer before each draw. Qualcomm's Quest driver read the raw integers anyway; the Frame's GL driver converts them to floats, so skinned models using bone indices vanish (for example BlazeRush's car bodies and controllers; Klownicle's guide, GitHub #57). Comes from a game's recipe.": "",
|
||||
"GL shim (frame.gl_shim): vertex shaders whose source contains every '|'-separated text compute in highp: whole-word mediump becomes highp on every line without the words in, out or uniform (the shader's interface keeps its precision). For effects that draw streaks or flashes from mediump rounding on the Frame (for example BlazeRush's particles: in uvec2 inBoneIndices;|uniform InstanceData|void main()). Pair it with a new shader cache name if the game caches compiled programs. Comes from a game's recipe.": "",
|
||||
@@ -664,6 +668,7 @@
|
||||
"Hardware video decoding (H.264, HEVC, VP9)": "",
|
||||
"Hardware video decoding on the Frame": "",
|
||||
"Heads-up: that APK is an older version than the one before.": "",
|
||||
"Heads-up: {name} looks like a different app than {title}.": "",
|
||||
"headset found": "",
|
||||
"Hi there!": "",
|
||||
"Hide Android's navigation bar": "",
|
||||
@@ -941,6 +946,8 @@
|
||||
"New recipe applied. Click Update on Frame to install it.": "",
|
||||
"Newer APK for {title}": "",
|
||||
"Newer APK of {title}": "",
|
||||
"Newer version for {title}": "",
|
||||
"Newer version of {title}": "",
|
||||
"Next": "",
|
||||
"Nice, it works! FramePort receives install links. (Demo: nothing was installed.)": "",
|
||||
"No": "",
|
||||
@@ -1026,6 +1033,7 @@
|
||||
"On your Frame": "",
|
||||
"on: {names}": "",
|
||||
"One PC game folder": "",
|
||||
"Only FramePort's own key, and your SSH agent's keys.": "",
|
||||
"Only if 360° pictures show wrongly.": "",
|
||||
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
|
||||
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
|
||||
@@ -1138,6 +1146,7 @@
|
||||
"Pretty smooth": "",
|
||||
"Prevents a start-up crash in some Unreal games.": "",
|
||||
"Previous": "",
|
||||
"Private SSH key file (not the .pub file)": "",
|
||||
"Problems and feedback": "",
|
||||
"Processes": "",
|
||||
"Processor": "",
|
||||
@@ -1173,6 +1182,7 @@
|
||||
"Reading the file list from the Frame…": "",
|
||||
"Reading the install link": "",
|
||||
"Reading the new APK": "",
|
||||
"Reading the new version": "",
|
||||
"ready": "",
|
||||
"Ready": "",
|
||||
"Ready to play": "",
|
||||
@@ -1412,6 +1422,7 @@
|
||||
"Sound on": "",
|
||||
"Source": "",
|
||||
"square, 256 × 256": "",
|
||||
"SSH keys": "",
|
||||
"Stable (default)": "",
|
||||
"Stand in for cube layers": "",
|
||||
"Standard for every game": "",
|
||||
@@ -1462,6 +1473,7 @@
|
||||
"Stereo (3D) 360° layers: 0 = as the game sends them, 2 = flat (the left image to both eyes).": "",
|
||||
"Stop": "",
|
||||
"Stop objects popping in and out": "",
|
||||
"Stop using this key (the file stays)": "",
|
||||
"Stopped: {why}": "",
|
||||
"Stops a black picture in Minecraft launchers (for example QuestCraft).": "",
|
||||
"Stops a crash a few seconds after start in some Unreal games with anti-aliasing (for example Star Wars Pinball VR).": "",
|
||||
@@ -1472,6 +1484,7 @@
|
||||
"Stops a start-up crash in Stress Level Zero games (for example BONELAB).": "",
|
||||
"Stops bHaptics from asking for a Bluetooth permission the Frame doesn't have (the game would freeze on its loading screen).": "",
|
||||
"Stops Epic Online Services from retrying its login keychain forever (the game would never finish starting).": "",
|
||||
"Stops games from dropping back to the home environment at start because two of their controller actions share a name (for example Scared By Squares). Leave it on.": "",
|
||||
"Stops games from hanging when their eye picture is a little too big (for example PowerWash Simulator). Leave it on.": "",
|
||||
"Stops Meta's audio library from reporting to Meta (only matters on PC emulators).": "",
|
||||
"Stops Meta's old VR library from closing the game at start (for example Jurassic World Aftermath).": "",
|
||||
@@ -1513,6 +1526,7 @@
|
||||
"Test Proton on the Frame": "",
|
||||
"Text and layout size": "",
|
||||
"That APK is a different app, not {title}.": "",
|
||||
"That key can't be used: {error}": "",
|
||||
"That theme file can't be used: {error}": "",
|
||||
"That's me!": "",
|
||||
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
|
||||
@@ -1660,6 +1674,7 @@
|
||||
"Uninstall. ": "",
|
||||
"Uninstalled {title}": "",
|
||||
"Uninstalled {title} and deleted its files on this PC ({size})": "",
|
||||
"Unique controller action names": "",
|
||||
"Unity frame loop: wait for skipped frames": "",
|
||||
"Unity's built-in Oculus frame loop (frame.unity_oculus_check): before each frame wait, wait up to 50 ms for the last waited frame to begin and skip the wait if it doesn't. Fixes a freeze at the first scene switch (for example Sniper Elite VR); in other games it can make loading screens stutter.": "",
|
||||
"Unity's built-in Oculus frame loop (frame.unity_oculus_check): don't pass Unity's physics-step pose update on, so the hands use the frame's display-time poses. Fixes hands trailing the controllers in some games (for example Sniper Elite VR); in others it makes the hands lag (for example BattleSisters).": "",
|
||||
@@ -1711,6 +1726,7 @@
|
||||
"Update the app only?": "",
|
||||
"Update tools": "",
|
||||
"Update with a newer APK…": "",
|
||||
"Update with a newer version…": "",
|
||||
"Updates": "",
|
||||
"Updates every game marked \"Update ready\", one after another.": "",
|
||||
"Updates the game, frame rate and battery every 5 seconds. Turn off to save the Frame's power.": "",
|
||||
@@ -1971,6 +1987,8 @@
|
||||
"{title}: name updated in Steam": "",
|
||||
"{title}: newer APK added": "",
|
||||
"{title}: newer APK added. Press Update on Frame to install it.": "",
|
||||
"{title}: newer version added": "",
|
||||
"{title}: newer version added. Press Update to install it.": "",
|
||||
"{title}: ready": "",
|
||||
"{title}: Steam artwork updated on the Frame": "",
|
||||
"{title}: {line}": "",
|
||||
|
||||
@@ -174,6 +174,11 @@ SETTINGS = [
|
||||
"Keeps every submitted image rect inside its swapchain. Unity can size the eye area a few pixels past the image "
|
||||
"on some Frames; SteamVR then rejects every frame (xrEndFrame -25, XR_ERROR_SWAPCHAIN_RECT_INVALID) and the game "
|
||||
"stops drawing (for example PowerWash Simulator stuck at \"Waiting\")."),
|
||||
("action_dedup", "int", 1, "Unique controller action names",
|
||||
"Gives a controller action whose name already exists in its action set a free name (\"turn\" -> \"turn_2\"). "
|
||||
"The Frame's runtime accepts duplicate names but then can't load the game's input manifest and treats the game "
|
||||
"as closed: black, then the home environment while the game keeps running with sound (for example Scared By "
|
||||
"Squares, whose Unreal project has an action and an axis both called Turn; GitHub #323)."),
|
||||
("swap_eyes", "int", 0, "Swap eyes", "Swap left/right views (diagnostic)."),
|
||||
("strip_depth", "int", 0, "Strip depth layers", "Remove XR_KHR_composition_layer_depth chains (diagnostic)."),
|
||||
("mutable_fix", "int", 0, "Mutable swapchain fix", "Experimental Vulkan mutable-format workaround."),
|
||||
@@ -395,6 +400,10 @@ UI: dict[str, dict] = {
|
||||
"rect_clamp": dict(group="troubleshooting", level="advanced", label="Keep pictures in bounds",
|
||||
help="Stops games from hanging when their eye picture is a little too big (for example "
|
||||
"PowerWash Simulator). Leave it on.", control=("switch",)),
|
||||
"action_dedup": dict(group="troubleshooting", level="advanced", label="Unique controller action names",
|
||||
help="Stops games from dropping back to the home environment at start because two of their "
|
||||
"controller actions share a name (for example Scared By Squares). Leave it on.",
|
||||
control=("switch",)),
|
||||
"gl_hide_msrtt": dict(group="troubleshooting", level="advanced", label="Hide fast anti-aliasing (OpenGL ES)",
|
||||
help="For Unity games that crash when they turn on anti-aliasing (for example The Room "
|
||||
"VR).", control=("switch",)),
|
||||
|
||||
+123
-13
@@ -137,18 +137,25 @@ def _existing_rift(folder: Path) -> dict | None:
|
||||
|
||||
|
||||
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = None,
|
||||
force: bool = False, art: bool = False) -> dict | None:
|
||||
force: bool = False, art: bool = False, replace: dict | None = None) -> dict | None:
|
||||
"""Analyze one Rift game folder and store it. Unchanged folders (same program, size, date) are not analyzed again;
|
||||
the user's exe choice, recipe and tags survive rescans. Returns None for folders without a VR runtime (unless
|
||||
force)."""
|
||||
force). replace: the library entry this folder is a newer version of (replace_rift_game): it keeps its package and
|
||||
name."""
|
||||
from .analysis import rift
|
||||
from .recommend import catalog
|
||||
|
||||
folder = Path(folder)
|
||||
old = _existing_rift(folder)
|
||||
if replace is None and not force and any(str(folder) in (g.get("replaced_dirs") or []) for g in library.games()
|
||||
if g.get("kind") == "rift"):
|
||||
if reporter: # the older copy of a game updated from another folder: a rescan must not bring it back
|
||||
reporter.log(f"skipped {folder.name}: an older version of a game that was updated from another folder")
|
||||
return None
|
||||
old = replace or _existing_rift(folder)
|
||||
old_extra = ((old or {}).get("analysis") or {}).get("extra") or {}
|
||||
forced = exe is not None # set_exe: always analyze with the user's choice
|
||||
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
|
||||
forced = exe is not None or replace is not None # set_exe / a newer version: always analyze
|
||||
if replace is None:
|
||||
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
|
||||
if old and not forced and old_extra.get("fingerprint") and \
|
||||
old_extra["fingerprint"] == rift.fingerprint(folder, old.get("exe") or ""):
|
||||
if reporter:
|
||||
@@ -177,7 +184,9 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
|
||||
suggested=library.recipe_to_dict(recipe))
|
||||
if not old or (old.get("recipe") or {}).get("source") != "user":
|
||||
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
|
||||
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
|
||||
if replace is not None:
|
||||
pass # a newer version of the game: its name stays
|
||||
elif not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
|
||||
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
|
||||
elif old.get("title_locked") and old.get("art_source") not in ("oculusdb", "meta", "steam"):
|
||||
fields["auto_title"] = recipe.title or a.label # the user's name stays; a reset brings this one
|
||||
@@ -400,14 +409,20 @@ def missing_obb(entry: dict) -> bool:
|
||||
return bool(extra.get("expects_obb")) and not (entry.get("data_dir") and entry.get("data_bytes") != 0)
|
||||
|
||||
|
||||
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None) -> dict:
|
||||
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None,
|
||||
replace: str | None = None) -> dict:
|
||||
"""Add a Linux app to the library (no conversion: it's installed as it is): arm64, or x86_64 (run through FEX on
|
||||
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder)."""
|
||||
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder). replace: the package of
|
||||
the library entry this is a newer version of (replace_linux_app): it keeps that package, its name and its art."""
|
||||
from .analysis import linux
|
||||
from .core.models import Recipe
|
||||
|
||||
path = Path(path)
|
||||
info = linux.inspect(path)
|
||||
if replace and not exe and not info["files"]: # a folder: the program the app started before, if it's still there
|
||||
before = (library.game(replace) or {}).get("exe")
|
||||
if before and before != info["exe"] and (Path(info["root"]) / before).is_file():
|
||||
exe = before
|
||||
if exe:
|
||||
info["exe"] = exe
|
||||
x86 = info["machine"] == linux.EM_X86_64
|
||||
@@ -417,7 +432,7 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
|
||||
if x86 and reporter:
|
||||
reporter.log(f"{path.name} is an x86_64 build: the Frame runs it through FEX (x86 translation; an arm64 "
|
||||
"build runs faster if there is one)")
|
||||
package = f"linux.{linux.slug(info['title'])}"
|
||||
package = replace or f"linux.{linux.slug(info['title'])}"
|
||||
if reporter:
|
||||
reporter.log(f"{info['title']}: program {info['exe']}{' (AppImage)' if info['appimage'] else ''}"
|
||||
f"{', OpenXR (VR)' if info['openxr'] else ''}")
|
||||
@@ -439,6 +454,8 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
|
||||
if not old:
|
||||
fields.update(title=info["title"], recipe=library.recipe_to_dict(recipe), status="unknown")
|
||||
library.upsert_game(package, **fields)
|
||||
if replace: # a newer version: the artwork stays as it is
|
||||
return library.game(package)
|
||||
try:
|
||||
from .artwork import fetch
|
||||
|
||||
@@ -580,7 +597,7 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
|
||||
reporter, x86_64=bool(extra.get("x86_64")),
|
||||
desktop_entry=entry.get("desktop_entry", True) is not False,
|
||||
desktop_icon=entry.get("desktop_icon", True) is not False,
|
||||
overlay=linux_overlay(entry))
|
||||
overlay=linux_overlay(entry), **_source_args(entry, target.label))
|
||||
if extra.get("x86_64"):
|
||||
reporter.check("x86 translation", True, "runs through FEX on SteamOS's x86 system (its libraries come from "
|
||||
"there; not checked ahead)")
|
||||
@@ -591,10 +608,23 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
|
||||
reporter.check("Libraries on the Frame", True, "everything the app needs is there")
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
|
||||
"source_id": entry.get("source_id")})
|
||||
return result
|
||||
|
||||
|
||||
def _source_args(entry: dict, label: str) -> dict:
|
||||
"""source_id (recorded with the install) and changed_since for a Linux app / PC VR game install: after a newer
|
||||
version was added (replace_linux_app/replace_rift_game), files changed since the last install there are sent
|
||||
again even when the Frame has a file of the same size (0 = the last install is unknown: every file)."""
|
||||
sid = entry.get("source_id")
|
||||
if not sid:
|
||||
return {}
|
||||
rec = (entry.get("installs") or {}).get(label) or {}
|
||||
return {"source_id": sid,
|
||||
"changed_since": None if rec.get("source_id") == sid else float(rec.get("time") or 0)}
|
||||
|
||||
|
||||
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {src.apk.name}")
|
||||
@@ -662,6 +692,85 @@ def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> di
|
||||
"new_version": new_vc}
|
||||
|
||||
|
||||
def _looks_different(before: str | None, now: str | None) -> bool:
|
||||
"""A newer version whose name has little in common with the old one's (maybe another app): the caller warns."""
|
||||
import difflib
|
||||
|
||||
a, b = _norm_title(before or ""), _norm_title(now or "")
|
||||
if not a or not b or a in b or b in a:
|
||||
return False
|
||||
return difflib.SequenceMatcher(None, a, b).ratio() < 0.5
|
||||
|
||||
|
||||
def _mark_source_replaced(package: str, **fields) -> dict:
|
||||
"""A Linux app or PC VR game got a newer version: a new source id, which installs record (in the deployment's
|
||||
recipe); an install without it shows the game as outdated (ui.components.source_replaced)."""
|
||||
import uuid
|
||||
|
||||
return library.upsert_game(package, source_id=uuid.uuid4().hex[:12], source_updated=time.time(), **fields)
|
||||
|
||||
|
||||
def replace_linux_app(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of a Linux app already in the library (GitHub #161): an AppImage/archive or a folder, analysed
|
||||
like "Add a Linux app…" (program, arm64/x86_64, AppImage, OpenXR). The app keeps its package (so its Steam
|
||||
shortcut on the Frame), name, artwork, tags and switches (Desktop Mode entry, overlay app); a folder keeps the
|
||||
program chosen before if it's still there. Returns {"entry", "different": its name looks like another app's,
|
||||
"name": the new version's own name}."""
|
||||
entry = library.game(package)
|
||||
if entry is None or not is_linux(entry):
|
||||
raise ValueError("only Linux apps can get a newer version this way")
|
||||
before = ((entry.get("analysis") or {}).get("label")) or entry.get("title")
|
||||
old_source = ((entry.get("analysis") or {}).get("extra") or {}).get("source")
|
||||
if reporter:
|
||||
reporter.log(f"reading {Path(path).name}")
|
||||
stored = add_linux_app(path, reporter, replace=package)
|
||||
name = (stored.get("analysis") or {}).get("label")
|
||||
stored = _mark_source_replaced(package, replaced_source=old_source)
|
||||
return {"entry": stored, "different": _looks_different(before, name), "name": name}
|
||||
|
||||
|
||||
def replace_rift_game(package: str, folder: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of a PC VR (Rift) game already in the library (GitHub #161): its new game folder, analysed like
|
||||
a scanned one (programs, VR runtimes, fingerprint). The game keeps its package, name, artwork, tags and the user's
|
||||
own recipe; a program the user confirmed stays if the new folder has it at the same place, else FramePort picks
|
||||
one again (exe_confirmed False when that's unclear: the caller asks). The old folder is left out of later scans.
|
||||
Returns {"entry", "different", "name", "exe_changed"}."""
|
||||
entry = library.game(package)
|
||||
if entry is None or not is_rift(entry):
|
||||
raise ValueError("only PC VR games can get a newer version folder")
|
||||
folder = Path(folder)
|
||||
if not folder.is_dir():
|
||||
raise ValueError(f"{folder} isn't a folder")
|
||||
other = _existing_rift(folder)
|
||||
if other and other["package"] != package:
|
||||
raise ValueError(f"{folder.name} is already in the library as {other.get('title') or other['package']}")
|
||||
before = (entry.get("analysis") or {}).get("label") or entry.get("title")
|
||||
old_dir, old_exe = entry.get("game_dir"), entry.get("exe")
|
||||
keep = old_exe if entry.get("exe_confirmed") and old_exe and (folder / old_exe).is_file() else None
|
||||
stored = add_rift_game(folder, reporter, exe=keep, force=True, replace=entry)
|
||||
if stored is None:
|
||||
raise ValueError(f"no game program found in {folder.name}")
|
||||
replaced = [d for d in (entry.get("replaced_dirs") or []) + [old_dir] if d and d != str(folder)]
|
||||
# build=None: the next install checks the new folder first (prepare_rift); the exe's checksum comes from there
|
||||
stored = _mark_source_replaced(package, build=None, replaced_dirs=list(dict.fromkeys(replaced)))
|
||||
name = (stored.get("analysis") or {}).get("label")
|
||||
return {"entry": stored, "different": _looks_different(before, name), "name": name,
|
||||
"exe_changed": stored.get("exe") != old_exe}
|
||||
|
||||
|
||||
def replace_source(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of any game in the library: an APK (Quest/Android), an app file or folder (Linux) or a game
|
||||
folder (PC VR). See replace_apk, replace_linux_app, replace_rift_game."""
|
||||
entry = library.game(package)
|
||||
if entry is None:
|
||||
raise ValueError(f"{package} isn't in the library")
|
||||
if is_linux(entry):
|
||||
return replace_linux_app(package, path, reporter)
|
||||
if is_rift(entry):
|
||||
return replace_rift_game(package, path, reporter)
|
||||
return replace_apk(package, Path(path), reporter)
|
||||
|
||||
|
||||
def _analyze_entry(entry: dict, reporter: Reporter | None = None):
|
||||
src = source_of(entry)
|
||||
if reporter:
|
||||
@@ -1275,10 +1384,11 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
|
||||
result = target.install_pcvr(package, title, Path(entry["game_dir"]), entry["exe"], recipe, reporter,
|
||||
revive_dir=rdir, revive_version=revive.installed_version() if rdir else None,
|
||||
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"),
|
||||
overlay=rift_overlay(entry, recipe))
|
||||
overlay=rift_overlay(entry, recipe), **_source_args(entry, target.label))
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
|
||||
"source_id": entry.get("source_id")})
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ class Target(ABC):
|
||||
raise NotImplementedError(f"{self.label} can't launch games")
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True, overlay=None, desktop_icon=True) -> dict:
|
||||
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None,
|
||||
changed_since=None) -> dict:
|
||||
"""Install a Linux app (only the Frame runs them; x86_64 ones through FEX)."""
|
||||
raise NotImplementedError(f"{self.label} can't install Linux apps")
|
||||
|
||||
|
||||
@@ -86,14 +86,15 @@ class FrameLeptonTarget(Target):
|
||||
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
|
||||
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
|
||||
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
|
||||
dest=self.install_dest(), overlay=extra.get("overlay"))
|
||||
dest=self.install_dest(), overlay=extra.get("overlay"),
|
||||
source_id=extra.get("source_id"), changed_since=extra.get("changed_since"))
|
||||
return installer.install_pcvr(self.connect().frame, plan, reporter)
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True, overlay=None, desktop_icon=True):
|
||||
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None, changed_since=None):
|
||||
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
|
||||
dest=self.install_dest(), desktop_entry=desktop_entry, overlay=overlay,
|
||||
desktop_icon=desktop_icon)
|
||||
desktop_icon=desktop_icon, source_id=source_id, changed_since=changed_since)
|
||||
return installer.install_linux(self.connect().frame, plan, reporter)
|
||||
|
||||
def drives(self) -> list[dict]:
|
||||
|
||||
@@ -191,7 +191,9 @@ class PcReviveTarget(Target):
|
||||
"tuning": dict(recipe.params("pcvr.steamvr_tuning"), enabled=True)
|
||||
if "pcvr.steamvr_tuning" in recipe.patches else None,
|
||||
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
|
||||
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
|
||||
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source,
|
||||
**({"source_id": extra["source_id"]} if extra.get("source_id") else {})},
|
||||
"time": time.time()}
|
||||
exe_field, _, _ = shortcut_fields(dep)
|
||||
d = pc_dir() / package
|
||||
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
|
||||
|
||||
+58
-2
@@ -880,7 +880,7 @@ class FramePortApp:
|
||||
self.toast(msg, error=True, action=tr("Details"),
|
||||
on_action=lambda e: self.show_activity(True))
|
||||
self.refresh_view()
|
||||
if job.kind == "scan" and self.exe_queue:
|
||||
if job.kind in ("scan", "task") and self.exe_queue: # (a task: a newer PC VR game version)
|
||||
self.page.run_thread(self.next_exe_choice)
|
||||
elif job and job.active and job.package and self.route[:2] == ("game", job.package):
|
||||
pkg, transit = self.hero_transit or (None, None)
|
||||
@@ -1152,6 +1152,7 @@ class FramePortApp:
|
||||
"move": lambda e: self.move_game(pkg),
|
||||
"update_app": lambda e: self.update_app_only(pkg),
|
||||
"new_apk": lambda e: self.page.run_task(self.update_with_apk, pkg),
|
||||
"new_version": lambda e: self.page.run_task(self.update_with_version, pkg),
|
||||
"uninstall_frame": lambda e: self.uninstall(pkg, "frame"),
|
||||
"uninstall_pc": lambda e: self.uninstall(pkg, "pc"),
|
||||
"remove": lambda e: self.remove_from_library(pkg),
|
||||
@@ -1168,7 +1169,8 @@ class FramePortApp:
|
||||
settings=self.has_game_settings(g), programs=rift or (linux and bool(self.linux_programs(g))),
|
||||
selectable=lv is not None,
|
||||
media_button=C.is_media_player(g) and not rift,
|
||||
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux)
|
||||
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux,
|
||||
new_version=rift or linux)
|
||||
return menu_sections(g, st, handlers.__getitem__)
|
||||
|
||||
def open_game_menu(self, pkg: str) -> None:
|
||||
@@ -1621,6 +1623,60 @@ class FramePortApp:
|
||||
return tr("{title}: newer APK added").format(title=self._title(pkg))
|
||||
self.submit(tr("Newer APK for {title}").format(title=self._title(pkg)), run, pkg, "task")
|
||||
|
||||
async def update_with_version(self, pkg: str) -> None:
|
||||
"""Game menu "Update with a newer version…" for Linux apps and PC VR games (GitHub #161): pick the new app
|
||||
file or folder (how the app was added) / the new game folder; the game keeps its name, art, tags and switches,
|
||||
and its page then offers Update on Frame (or on this PC)."""
|
||||
from ..analysis import linux as linux_analysis
|
||||
|
||||
if self.jobs.busy_with(pkg):
|
||||
return
|
||||
g = library.game(pkg) or {}
|
||||
rift = g.get("kind") == "rift"
|
||||
title = tr("Newer version of {title}").format(title=self._title(pkg))
|
||||
if rift or self._linux_folder_app(g):
|
||||
path = await C.pick_dir(self.page, dialog_title=title)
|
||||
if not path:
|
||||
return
|
||||
else:
|
||||
paths = await C.pick_files(self.page, dialog_title=title)
|
||||
if not paths:
|
||||
return
|
||||
path = paths[0]
|
||||
if not linux_analysis.looks_like_linux_app(Path(path)):
|
||||
self.toast(tr("{name} isn't a Linux app (AppImage, Linux program or .zip/.tar archive)")
|
||||
.format(name=Path(path).name), error=True)
|
||||
return
|
||||
on_frame = self.frame_state == "connected" and \
|
||||
C.install_state(g, self.frame_info) in ("installed", "outdated")
|
||||
on_pc = rift and pkg in self.pc_installs()
|
||||
|
||||
def run(job: Job):
|
||||
job.reporter.stage(tr("Reading the new version"))
|
||||
res = pipeline.replace_source(pkg, Path(path), job.reporter)
|
||||
if res.get("different"):
|
||||
self.toast(tr("Heads-up: {name} looks like a different app than {title}.").format(
|
||||
name=res.get("name") or Path(path).name, title=self._title(pkg)))
|
||||
if rift and res["entry"].get("exe_confirmed") is False: # which program starts it? (asked after the job)
|
||||
self.exe_queue.append(pkg)
|
||||
self.open_game(pkg)
|
||||
if on_frame or on_pc:
|
||||
return tr("{title}: newer version added. Press Update to install it.").format(
|
||||
title=self._title(pkg))
|
||||
return tr("{title}: newer version added").format(title=self._title(pkg))
|
||||
self.submit(tr("Newer version for {title}").format(title=self._title(pkg)), run, pkg, "task")
|
||||
|
||||
@staticmethod
|
||||
def _linux_folder_app(g: dict) -> bool:
|
||||
"""A Linux app added from a folder (its newer version is a folder too), not from an AppImage/archive."""
|
||||
from ..analysis import linux as linux_analysis
|
||||
|
||||
extra = (g.get("analysis") or {}).get("extra") or {}
|
||||
source = Path(extra.get("source") or g.get("game_dir") or "")
|
||||
if source.exists():
|
||||
return source.is_dir()
|
||||
return not extra.get("files") and not source.name.lower().endswith(linux_analysis.ARCHIVES)
|
||||
|
||||
def updatable(self) -> list[tuple[str, str]]:
|
||||
"""[(package, "frame" | "pc")] installs with an update ready (a newer build or changed patch settings)."""
|
||||
out = []
|
||||
|
||||
@@ -178,6 +178,8 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
# still installed (GitHub #90)
|
||||
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
|
||||
return "missing"
|
||||
if source_replaced(game, dep):
|
||||
return "outdated" # a newer version of the Linux app / PC VR game was added since this install (GitHub #161)
|
||||
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
|
||||
@@ -194,6 +196,16 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
return "outdated" if settings_diff(game, frame_info) else "installed"
|
||||
|
||||
|
||||
def source_replaced(game: dict, dep: dict | None) -> bool:
|
||||
"""A Linux app or PC VR game got a newer version ("Update with a newer version…", GitHub #161) that this install
|
||||
(the Frame's or the PC's deployment record) doesn't have yet."""
|
||||
sid = game.get("source_id")
|
||||
if not sid or game.get("kind") not in ("linux", "rift"):
|
||||
return False
|
||||
rec = (dep or {}).get("recipe")
|
||||
return (rec.get("source_id") if isinstance(rec, dict) else None) != sid
|
||||
|
||||
|
||||
def recipe_changed(game: dict) -> bool:
|
||||
from ..patches.base import recipe_fingerprint, revised_since_unrecorded
|
||||
|
||||
@@ -216,7 +228,7 @@ def pc_outdated(g: dict, dep: dict) -> bool:
|
||||
args = game_args(recipe_from_dict(g["recipe"]))
|
||||
except Exception: # noqa: BLE001
|
||||
args = dep.get("game_args") or []
|
||||
return installed != want or list(dep.get("game_args") or []) != args
|
||||
return installed != want or list(dep.get("game_args") or []) != args or source_replaced(g, dep)
|
||||
|
||||
|
||||
def one_choice():
|
||||
|
||||
@@ -62,6 +62,9 @@ HELP: dict[str, str] = _Translated({
|
||||
"hw_video_decode": "Games with the Hardware video decoding patch play their videos on the Frame's video hardware. "
|
||||
"Turn it off if videos misbehave; games pick it up at their next start. For one game only, put "
|
||||
"FRAMEPORT_NO_HW_VIDEO=1 before %command% in its Steam launch options.",
|
||||
"ssh_keys": "FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, "
|
||||
"add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH "
|
||||
"agent instead.",
|
||||
"launch_test": "Starts the game on the Frame while nobody wears the headset and reads the log: did it start and "
|
||||
"draw frames. It can't check the picture.",
|
||||
"last_session": "After you play a game on the Frame, FramePort reads that session's log: crashes, frame rate and "
|
||||
|
||||
@@ -35,6 +35,7 @@ class MenuState:
|
||||
media_button: bool = False # the game page shows "Add videos" already
|
||||
movable: bool = False # the Frame knows which drive it's on (agent v63+, microSD): Move to…
|
||||
android: bool = False # a Quest/Android game (not PC VR, not a Linux app): it can get a newer APK
|
||||
new_version: bool = False # a Linux app or PC VR game: it can get a newer version (file or folder)
|
||||
|
||||
|
||||
def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> list:
|
||||
@@ -55,6 +56,8 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
|
||||
top.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_ROUNDED, act("move")))
|
||||
if st.android and not job: # GitHub #161: the game keeps its name, art and recipe
|
||||
top.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
|
||||
if st.new_version and not job: # the same for Linux apps and PC VR games
|
||||
top.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
|
||||
if st.on_frame and not rift and not linux and not job:
|
||||
# the new build only; the Frame's game data stays (e.g. changed OBBs a normal update would replace)
|
||||
top.append((tr("Update app only…"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED, act("update_app")))
|
||||
@@ -103,6 +106,8 @@ def quick_menu(st: MenuState, act: Callable[[str], Callable]) -> list:
|
||||
out += st.installs[:1]
|
||||
if st.android:
|
||||
out.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
|
||||
if st.new_version:
|
||||
out.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
|
||||
if st.settings:
|
||||
out.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
|
||||
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")))
|
||||
|
||||
@@ -131,9 +131,55 @@ class SettingsView:
|
||||
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."))],
|
||||
tooltip=tr("Your own game files aren't touched.")),
|
||||
ft.Container(height=T.S2), self.ssh_keys()],
|
||||
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def ssh_keys(self) -> ft.Control:
|
||||
"""Settings → Installing: private key files FramePort logs in with besides its own (frame.connection
|
||||
EXTRA_KEYS_SETTING; the Frame connection and OpenSSH uploads both use them, GitHub #242)."""
|
||||
from ...frame import connection
|
||||
|
||||
app = self.app
|
||||
rows = ft.Column(spacing=T.S1)
|
||||
|
||||
def fill():
|
||||
items = []
|
||||
for path, problem in connection.extra_key_status():
|
||||
items.append(ft.Row([
|
||||
C.as_icon(ft.Icons.KEY_ROUNDED if not problem else ft.Icons.WARNING_AMBER_ROUNDED, T.px(18),
|
||||
T.TEXT_2 if not problem else T.WARN),
|
||||
ft.Column([C.body(path.name, T.TEXT), C.meta(problem or str(path), selectable=True)],
|
||||
spacing=0, expand=True),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Stop using this key (the file stays)"),
|
||||
on_click=lambda e, p=path: remove(p)),
|
||||
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER))
|
||||
rows.controls = items or [C.meta(tr("Only FramePort's own key, and your SSH agent's keys."))]
|
||||
C.update(rows)
|
||||
|
||||
def remove(path):
|
||||
connection.remove_extra_key(path)
|
||||
fill()
|
||||
|
||||
async def add(e):
|
||||
for path in await C.pick_files(app.page, dialog_title=tr("Private SSH key file (not the .pub file)"),
|
||||
multiple=True):
|
||||
try:
|
||||
stored = connection.add_extra_key(path)
|
||||
except connection.KeyFileError as exc:
|
||||
app.toast(tr("That key can't be used: {error}").format(error=exc), error=True)
|
||||
continue
|
||||
app.toast(tr("FramePort also logs in with {name} from the next connection.").format(name=stored.name))
|
||||
fill()
|
||||
|
||||
fill()
|
||||
return ft.Column([
|
||||
ft.Row([C.body(tr("SSH keys"), T.TEXT, weight=ft.FontWeight.W_500), C.help_icon("ssh_keys")],
|
||||
spacing=T.px(4), vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
rows,
|
||||
C.secondary(tr("Add a key file…"), ft.Icons.VPN_KEY_ROUNDED, add),
|
||||
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def updates_card(self) -> ft.Control:
|
||||
"""Settings → Updates: FramePort's own updates (ui/updater.py, frameport/updates.py)."""
|
||||
import time
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// action_dedup (native/adapter/action_names.h, GitHub #323): free names for duplicate controller actions.
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
#include "action_names.h"
|
||||
|
||||
static const char *used[8][2];
|
||||
static int nused;
|
||||
|
||||
static int taken(void *ctx, const char *name, const char *localized) {
|
||||
(void)ctx;
|
||||
for (int i = 0; i < nused; ++i)
|
||||
if (!strcmp(used[i][0], name) || !strcmp(used[i][1], localized)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
char name[64], localized[128];
|
||||
used[nused][0] = "turn"; used[nused++][1] = "Turn"; // Scared By Squares: action mapping "Turn"...
|
||||
assert(!action_unique_names("jump", "Jump", taken, NULL, name, sizeof(name), localized, sizeof(localized)));
|
||||
// ...and an axis mapping "Turn": same action name and localized name
|
||||
assert(action_unique_names("turn", "Turn", taken, NULL, name, sizeof(name), localized, sizeof(localized)));
|
||||
assert(!strcmp(name, "turn_2") && !strcmp(localized, "Turn 2"));
|
||||
// only the localized name clashes: still renamed (the runtime's manifest needs both unique)
|
||||
assert(action_unique_names("turn_axis", "Turn", taken, NULL, name, sizeof(name), localized, sizeof(localized)));
|
||||
assert(!strcmp(name, "turn_axis_2") && !strcmp(localized, "Turn 2"));
|
||||
// the next free number
|
||||
used[nused][0] = "turn_2"; used[nused++][1] = "Turn 2";
|
||||
assert(action_unique_names("turn", "Turn", taken, NULL, name, sizeof(name), localized, sizeof(localized)));
|
||||
assert(!strcmp(name, "turn_3") && !strcmp(localized, "Turn 3"));
|
||||
// a name at the length limit is cut so the suffix fits
|
||||
char longname[64];
|
||||
memset(longname, 'a', 63);
|
||||
longname[63] = 0;
|
||||
used[nused][0] = longname; used[nused++][1] = "Long";
|
||||
assert(action_unique_names(longname, "Long", taken, NULL, name, sizeof(name), localized, sizeof(localized)));
|
||||
assert(strlen(name) == 63 && !strcmp(name + 61, "_2") && !strcmp(localized, "Long 2"));
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""action_dedup (FrameBridge, GitHub #323): duplicate controller action names get a free name."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.patches import base
|
||||
from frameport.patches.settings import UI, adapter_settings
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_duplicate_action_names_get_free_names(tmp_path):
|
||||
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
||||
if not cc or os.name == "nt":
|
||||
pytest.skip("needs a POSIX host C compiler")
|
||||
binary = tmp_path / "action-names-test"
|
||||
subprocess.run([cc, "-std=c11", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "native/adapter"),
|
||||
str(ROOT / "tests/fixtures/src/action_names_test.c"), "-o", str(binary)], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
|
||||
|
||||
def test_action_dedup_is_on_by_default():
|
||||
base.load_all()
|
||||
p = base.REGISTRY["adapter.action_dedup"]
|
||||
assert p.default == 1 # the Frame's runtime drops a game with duplicate names: always fix it
|
||||
assert adapter_settings({"adapter.action_dedup": {"value": 0}})["action_dedup"] == 0
|
||||
assert UI["action_dedup"]["control"] == ("switch",)
|
||||
src = (ROOT / "native/adapter/frame_adapter.c").read_text()
|
||||
assert 'sscanf(line, "action_dedup=%f"' in src
|
||||
assert "if (action_dedup || proximity_emul || input_diag) HOOK_AS(xrCreateAction, hook_xrCreateAction)" in src
|
||||
+18
-2
@@ -31,9 +31,11 @@ def _states():
|
||||
("quest not installed", QUEST, MenuState(connected=True, installs=[_install("Install on Frame")],
|
||||
settings=True, selectable=True, android=True)),
|
||||
("rift on PC", RIFT, MenuState(on_pc=True, plays=[_play("this PC")], programs=True, selectable=True,
|
||||
installs=[_install("Reinstall on this PC")])),
|
||||
installs=[_install("Reinstall on this PC")], new_version=True)),
|
||||
("linux on Frame", LINUX, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
|
||||
selectable=True)),
|
||||
selectable=True, new_version=True)),
|
||||
("linux job running", LINUX, MenuState(connected=True, on_frame=True, job=True, selectable=True,
|
||||
new_version=True)),
|
||||
("job running", QUEST, MenuState(connected=True, on_frame=True, job=True, settings=True, selectable=True,
|
||||
android=True)),
|
||||
]
|
||||
@@ -172,3 +174,17 @@ def test_newer_apk_for_quest_games_in_both_menus():
|
||||
want = g is QUEST and not st.job
|
||||
assert ("Update with a newer APK…" in quick) == want, name
|
||||
assert ("Update with a newer APK…" in full) == want, name
|
||||
|
||||
|
||||
def test_newer_version_for_linux_apps_and_pc_vr_games():
|
||||
"""GitHub #161: Linux apps and PC VR games get "Update with a newer version…" (a file or folder), not the APK
|
||||
entry, in both menus; never while a job runs."""
|
||||
for name, g, st in _states():
|
||||
quick = [e[0] for e in _items(menu_sections(g, st, _key))]
|
||||
st.quick = False
|
||||
full = [e[0] for e in _items(menu_sections(g, st, _key))]
|
||||
want = g is not QUEST and not st.job
|
||||
assert ("Update with a newer version…" in quick) == want, name
|
||||
assert ("Update with a newer version…" in full) == want, name
|
||||
if g is not QUEST:
|
||||
assert "Update with a newer APK…" not in quick + full, name
|
||||
@@ -38,8 +38,36 @@ def test_quote_refuses_batch_injection(value):
|
||||
native.quote(value)
|
||||
|
||||
|
||||
def test_quote_protects_spaces_quotes_globs():
|
||||
assert native.quote('a *?["b') == '"a \\*\\?\\[\\"b"'
|
||||
def test_quote_leaves_globs_to_sftp():
|
||||
"""Inside double quotes sftp escapes * ? [ itself: an extra backslash became part of the name (GitHub #324)."""
|
||||
assert native.quote('a *?["b') == '"a *?[\\"b"'
|
||||
assert native.quote("/x/te*s?t [ENG] \\b+(1).apk") == '"/x/te*s?t [ENG] \\\\b+(1).apk"'
|
||||
|
||||
|
||||
def test_quote_windows_local_path_uses_forward_slashes():
|
||||
"""Win32-OpenSSH turns a local path's backslashes into '/', escapes included ("C://Users", "/[ENG]")."""
|
||||
path = r"C:\Users\user_name\AppData\Local\FramePort\output\All-In-One - Sports VR [ENG] v100+1.9.0 (x)\a.apk"
|
||||
assert native.quote(path, windows=True) == (
|
||||
'"C:/Users/user_name/AppData/Local/FramePort/output/All-In-One - Sports VR [ENG] v100+1.9.0 (x)/a.apk"')
|
||||
assert native.quote(r"\\server\share\*?[a].apk", windows=True) == '"//server/share/*?[a].apk"'
|
||||
assert native.quote('C:\\odd "name"\\a.apk', windows=True) == '"C:/odd \\"name\\"/a.apk"'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("os_name,expected", [("nt", '"C:/games/[ENG] a/x.apk"'),
|
||||
("posix", '"C:\\\\games\\\\[ENG] a\\\\x.apk"')])
|
||||
def test_quote_local_follows_the_platform(monkeypatch, os_name, expected):
|
||||
monkeypatch.setattr(native.os, "name", os_name)
|
||||
assert native.quote_local(r"C:\games\[ENG] a\x.apk") == expected
|
||||
|
||||
|
||||
def test_batch_line_for_bracketed_folder(tmp_path, monkeypatch):
|
||||
folder = tmp_path / "All-In-One - Sports VR [ENG] v100+1.9.0"
|
||||
folder.mkdir()
|
||||
frame, items, files, commands, processes = setup_batch(folder, monkeypatch)
|
||||
monkeypatch.setattr(native.os, "name", "posix")
|
||||
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
|
||||
assert sorted(commands[0].splitlines()) == [f'put "{folder}/patch.obb" "/incoming/patch.obb.part"',
|
||||
f'reput "{folder}/main.obb" "/incoming/main.obb.part"']
|
||||
|
||||
|
||||
def test_size_lookup_does_not_hide_connection_errors():
|
||||
@@ -65,10 +93,11 @@ def setup_batch(tmp_path, monkeypatch, code=0):
|
||||
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, "", "")))
|
||||
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")), modes_seen=[])
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
|
||||
@contextlib.contextmanager
|
||||
def creds(frame):
|
||||
def creds(frame, relaxed=False):
|
||||
frame.modes_seen.append(relaxed)
|
||||
yield ["-o", "StrictHostKeyChecking=yes"]
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
commands, processes = [], []
|
||||
@@ -210,7 +239,7 @@ def setup_tar(tmp_path, monkeypatch, code=0):
|
||||
|
||||
path = tmp_path / "small.bin"
|
||||
path.write_bytes(b"payload")
|
||||
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222))
|
||||
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222), modes_seen=[])
|
||||
pipe = io.BytesIO()
|
||||
pipe.close = lambda: None
|
||||
process = Mock(stdin=pipe, returncode=code)
|
||||
@@ -219,8 +248,9 @@ def setup_tar(tmp_path, monkeypatch, code=0):
|
||||
monkeypatch.setattr(native.subprocess, "Popen", popen)
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
|
||||
@contextlib.contextmanager
|
||||
def creds(frame, port_flag):
|
||||
def creds(frame, port_flag, relaxed=False):
|
||||
assert port_flag == "-p"
|
||||
frame.modes_seen.append(relaxed)
|
||||
yield ["-o", "StrictHostKeyChecking=yes", port_flag, "2222"]
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
return frame, [(path, "nested/small.bin")], process, popen, pipe
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""GitHub #161: a newer version of a Linux app or a PC VR game already in the library (game menu "Update with a newer
|
||||
version…", CLI `frameport new-version`)."""
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from test_install_drives import FakeFrame
|
||||
from test_linux_apps import elf
|
||||
from test_rift import unity_game
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.analysis import linux
|
||||
from frameport.core import library
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.install import installer
|
||||
from frameport.ui import components as C
|
||||
|
||||
|
||||
def _appimage(path, extra=b""):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(elf(linux.EM_AARCH64, appimage=True, extra=extra))
|
||||
return path
|
||||
|
||||
|
||||
def _frame(package, recipe=None):
|
||||
return {"installed": [{"package": package, "sha256": None, "recipe": recipe}]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Linux apps
|
||||
def test_newer_appimage_keeps_name_art_and_switches(tmp_path):
|
||||
old = _appimage(tmp_path / "v1" / "Venera-Prime-2.4.2-aarch64.AppImage")
|
||||
pkg = pipeline.add_linux_app(old)["package"]
|
||||
library.upsert_game(pkg, title="My Venera", title_locked=True, tags=["mine"], desktop_entry=False,
|
||||
desktop_icon=False, vr_overlay=True)
|
||||
assert C.install_state(library.game(pkg), _frame(pkg)) == "installed" # never updated: no source id needed
|
||||
new = _appimage(tmp_path / "v2" / "Venera-Prime-2.5.0-aarch64.AppImage", extra=b"newer")
|
||||
res = pipeline.replace_linux_app(pkg, new)
|
||||
g = library.game(pkg)
|
||||
assert g["package"] == pkg and g["exe"] == new.name and g["game_dir"] == str(new.parent)
|
||||
assert g["analysis"]["extra"]["source"] == str(new) and g["analysis"]["extra"]["files"] == [new.name]
|
||||
assert g["data_bytes"] == new.stat().st_size
|
||||
assert (g["title"], g["title_locked"], g["tags"]) == ("My Venera", True, ["mine"])
|
||||
assert (g["desktop_entry"], g["desktop_icon"], g["vr_overlay"]) == (False, False, True)
|
||||
assert g["replaced_source"] == str(old) and g["source_id"] and res["different"] is False
|
||||
# the Frame's copy (installed before) is outdated until an install records this version
|
||||
assert C.install_state(g, _frame(pkg)) == "outdated"
|
||||
assert C.install_state(g, _frame(pkg, {"source_id": "older"})) == "outdated"
|
||||
assert C.install_state(g, _frame(pkg, {"source_id": g["source_id"]})) == "installed"
|
||||
|
||||
|
||||
def test_newer_linux_folder_keeps_the_chosen_program(tmp_path):
|
||||
def app(root, size):
|
||||
(root / "bin").mkdir(parents=True)
|
||||
(root / "Tool").write_bytes(elf(linux.EM_AARCH64, extra=b"\0" * size)) # bigger: FramePort's pick
|
||||
(root / "bin" / "tool-vr").write_bytes(elf(linux.EM_AARCH64))
|
||||
return root
|
||||
pkg = pipeline.add_linux_app(app(tmp_path / "v1" / "Tool", 4096), exe="bin/tool-vr")["package"]
|
||||
assert library.game(pkg)["exe"] == "bin/tool-vr"
|
||||
pipeline.replace_linux_app(pkg, app(tmp_path / "v2" / "Tool", 8192))
|
||||
g = library.game(pkg)
|
||||
assert g["exe"] == "bin/tool-vr" and g["game_dir"] == str(tmp_path / "v2" / "Tool")
|
||||
# the program isn't there any more: FramePort's pick
|
||||
(tmp_path / "v3" / "Tool").mkdir(parents=True)
|
||||
(tmp_path / "v3" / "Tool" / "Tool").write_bytes(elf(linux.EM_AARCH64))
|
||||
pipeline.replace_linux_app(pkg, tmp_path / "v3" / "Tool")
|
||||
assert library.game(pkg)["exe"] == "Tool"
|
||||
|
||||
|
||||
def test_another_linux_app_is_added_with_a_warning(tmp_path):
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Venera-Prime-1.0-aarch64.AppImage"))["package"]
|
||||
res = pipeline.replace_linux_app(pkg, _appimage(tmp_path / "Blender-4.2-aarch64.AppImage"))
|
||||
assert res["different"] and res["name"] == "Blender" and library.game(pkg)["exe"].startswith("Blender")
|
||||
assert not library.game("linux.blender") # no second entry: the user's pick is the newer version
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_linux_app(pkg, tmp_path / "missing.AppImage")
|
||||
|
||||
|
||||
def test_only_the_right_kind(tmp_path):
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Tool-aarch64.AppImage"))["package"]
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_rift_game(pkg, tmp_path)
|
||||
library.upsert_game("com.x.quest", title="Quest", apk="/x.apk")
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_linux_app("com.x.quest", tmp_path / "Tool-aarch64.AppImage")
|
||||
|
||||
|
||||
def test_linux_update_sends_changed_files_of_the_same_size(tmp_path, monkeypatch):
|
||||
(tmp_path / "app").mkdir()
|
||||
for name in ("tool", "data.txt"):
|
||||
(tmp_path / "app" / name).write_bytes(b"x" * 10)
|
||||
old = time.time() - 3600
|
||||
os.utime(tmp_path / "app" / "data.txt", (old, old))
|
||||
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
|
||||
monkeypatch.setattr(installer, "apply_app_icon", lambda *a, **k: None)
|
||||
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
|
||||
sent = []
|
||||
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
|
||||
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
|
||||
frame = FakeFrame({"prepare_linux": {"existing": {"app": {"tool": 10, "data.txt": 10}}, "free_bytes": 1 << 40,
|
||||
"incoming": "/x/incoming", "base": "/b"},
|
||||
"finalize_linux": {"base": "/b", "appid": 1}})
|
||||
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool")
|
||||
installer.install_linux(frame, plan, Reporter())
|
||||
assert sent == [] and dict(frame.calls)["finalize_linux"]["recipe"] is None # same sizes: nothing sent
|
||||
# (st_ctime can't be set: the file's own ctime is now) a newer version: files changed since the last install
|
||||
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool", source_id="v2",
|
||||
changed_since=time.time() + 60)
|
||||
installer.install_linux(frame, plan, Reporter())
|
||||
assert sent == []
|
||||
plan.changed_since = time.time() - 60
|
||||
installer.install_linux(frame, plan, Reporter())
|
||||
assert sorted(sent) == ["app/data.txt", "app/tool"]
|
||||
assert frame.calls[-1][1]["recipe"] == {"source_id": "v2"}
|
||||
|
||||
|
||||
class _Null:
|
||||
def __enter__(self):
|
||||
return None
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def test_install_records_the_version(tmp_path, monkeypatch):
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-aarch64.AppImage"))["package"]
|
||||
got = {}
|
||||
|
||||
class T:
|
||||
label = "Frame"
|
||||
|
||||
def install_linux(self, *a, **k):
|
||||
got.clear()
|
||||
got.update(k)
|
||||
return {}
|
||||
monkeypatch.setattr(pipeline, "steam_title", lambda e: "Tool")
|
||||
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
|
||||
assert "source_id" not in got # never updated: installs as before
|
||||
pipeline.replace_linux_app(pkg, _appimage(tmp_path / "v2" / "Tool-aarch64.AppImage"))
|
||||
sid = library.game(pkg)["source_id"]
|
||||
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
|
||||
assert got["source_id"] == sid and 0 < got["changed_since"] <= time.time() # since the last install there
|
||||
assert library.game(pkg)["installs"]["Frame"]["source_id"] == sid
|
||||
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
|
||||
assert got["changed_since"] is None # this version is there already: resumes as usual
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ PC VR (Rift) games
|
||||
def _rift(tmp_path):
|
||||
first = unity_game(tmp_path / "v1")
|
||||
[g] = pipeline.add_path(tmp_path / "v1")
|
||||
pkg = g["package"]
|
||||
r = library.recipe_from_dict(g["recipe"])
|
||||
r.source = "user"
|
||||
r.patches["pcvr.no_crash_reporter"] = {}
|
||||
pipeline.set_recipe(pkg, r)
|
||||
library.upsert_game(pkg, title="My Space", tags=["mine"], exe_confirmed=True,
|
||||
build={"ok": True, "sha256": "old", "kind": "rift"})
|
||||
return pkg, first
|
||||
|
||||
|
||||
def test_newer_rift_folder_keeps_name_recipe_and_program(tmp_path):
|
||||
pkg, first = _rift(tmp_path)
|
||||
new = unity_game(tmp_path / "v2")
|
||||
res = pipeline.replace_rift_game(pkg, new)
|
||||
g = library.game(pkg)
|
||||
assert g["game_dir"] == str(new) and g["exe"] == "Space Game.exe" and g["exe_confirmed"] is True
|
||||
assert (g["title"], g["tags"]) == ("My Space", ["mine"])
|
||||
assert "pcvr.no_crash_reporter" in g["recipe"]["patches"] and g["recipe"]["source"] == "user"
|
||||
assert g["build"] is None and g["replaced_dirs"] == [str(first)] and not res["different"]
|
||||
from frameport.patches.pcvr import game_args
|
||||
|
||||
dep = {"package": pkg, "sha256": "old", "recipe": {"patches": sorted(g["recipe"]["patches"])},
|
||||
"game_args": game_args(library.recipe_from_dict(g["recipe"]))}
|
||||
assert C.install_state(g, {"installed": [dep]}) == "outdated"
|
||||
assert C.pc_outdated(g, dep)
|
||||
dep["recipe"]["source_id"] = g["source_id"]
|
||||
assert C.install_state(g, {"installed": [dep]}) == "installed" and not C.pc_outdated(g, dep)
|
||||
# a rescan of the folders doesn't bring the old copy back
|
||||
assert pipeline.add_path(tmp_path / "v1") == [] and library.game(pkg)["game_dir"] == str(new)
|
||||
|
||||
|
||||
def test_newer_rift_folder_without_the_program_picks_again(tmp_path):
|
||||
pkg, _ = _rift(tmp_path)
|
||||
new = unity_game(tmp_path / "v2", name="Space Game VR")
|
||||
res = pipeline.replace_rift_game(pkg, new)
|
||||
assert res["exe_changed"] and library.game(pkg)["exe"] == "Space Game VR.exe"
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_rift_game(pkg, tmp_path / "nothing")
|
||||
other = unity_game(tmp_path / "other", name="Moon Game")
|
||||
pipeline.add_path(tmp_path / "other")
|
||||
with pytest.raises(ValueError, match="already in the library"):
|
||||
pipeline.replace_rift_game(pkg, other)
|
||||
|
||||
|
||||
def test_rift_update_resends_changed_game_files(tmp_path, monkeypatch):
|
||||
game = unity_game(tmp_path)
|
||||
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
|
||||
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
|
||||
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
|
||||
sent = []
|
||||
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
|
||||
monkeypatch.setattr("frameport.artwork.sources.has_art", lambda pkg: True)
|
||||
manifest = installer.local_data_manifest(game)
|
||||
frame = FakeFrame({"prepare_pcvr": {"proton": {"display_name": "Proton"}, "openxr": None,
|
||||
"existing": {"game": dict(manifest)}, "free_bytes": 1 << 40,
|
||||
"incoming": "/i", "base": "/b"},
|
||||
"finalize_pcvr": {"base": "/b", "proton": "p", "appid": 1}})
|
||||
from frameport.core.models import Recipe
|
||||
|
||||
plan = installer.PcvrPlan("rift.space_game", "Space", game, "Space Game.exe", Recipe(package="rift.space_game"),
|
||||
None, source_id="v2", changed_since=time.time() - 60)
|
||||
installer.install_pcvr(frame, plan, Reporter())
|
||||
assert sorted(sent) == sorted(f"game/{rel}" for rel in manifest)
|
||||
assert frame.calls[-1][1]["recipe"]["source_id"] == "v2"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ CLI
|
||||
def test_cli_new_version(tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-1.0-aarch64.AppImage"))["package"]
|
||||
new = _appimage(tmp_path / "v2" / "Tool-1.1-aarch64.AppImage")
|
||||
out = CliRunner().invoke(cli.app, ["new-version", pkg, str(new)])
|
||||
assert out.exit_code == 0, out.output
|
||||
assert library.game(pkg)["exe"] == new.name and "program Tool-1.1-aarch64.AppImage" in out.output
|
||||
(tmp_path / "empty").mkdir()
|
||||
bad = CliRunner().invoke(cli.app, ["new-version", pkg, str(tmp_path / "empty")]) # no program in it
|
||||
assert bad.exit_code == 2
|
||||
@@ -111,6 +111,14 @@ def test_space_warp_is_a_question_not_an_automatic_fix():
|
||||
assert "space-warp-used" not in {f.id for f in triage(off + log, "RUNNING", PKG).findings}
|
||||
|
||||
|
||||
def test_eye_depth_swapchain_is_not_space_warp():
|
||||
# GitHub #323: Unreal's OpenXR plugin submits eye depth (XR_KHR_composition_layer_depth) in a D24S8 (129)
|
||||
# swapchain of the eye size; only space warp's motion-vector swapchain (97) shows that space warp is used
|
||||
log = START + fb("17:39:02", "xrCreateSwapchain 1728x1728 format=129 samples=1 array=2 faces=1 usage=0x22 "
|
||||
"flags=0x0 result=0")
|
||||
assert "space-warp-used" not in {f.id for f in triage(log, "RUNNING", PKG).findings}
|
||||
|
||||
|
||||
def test_gpu_hang_from_kernel_lines():
|
||||
kernel = "kernel: 1790000000.0 steamos kernel: msm_dpu: [drm:a6xx_hangcheck] hangcheck detected gpu lockup\n"
|
||||
r = triage(START, "UNKNOWN", PKG, kernel=kernel)
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""SSH keys beyond FramePort's own (GitHub #242): key files from Settings, the login a connection used, and OpenSSH
|
||||
uploads that fall back to this PC's own keys/agent when the Frame refuses FramePort's key."""
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import paramiko
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.frame import connection as C
|
||||
from frameport.frame import native_upload as native
|
||||
|
||||
REFUSED = (b"debug1: Reading configuration data nul\r\n"
|
||||
b"debug1: identity file C:/Temp/frameport-upload-x/key0 type 3\r\n"
|
||||
b"debug1: Connecting to 10.0.0.2 [10.0.0.2] port 22.\r\n"
|
||||
b"@ WARNING: UNPROTECTED PRIVATE KEY FILE! @\r\n"
|
||||
b"debug1: Offering public key: C:/Temp/frameport-upload-x/key0 ED25519 SHA256:abc explicit\r\n"
|
||||
b"debug1: Authentications that can continue: publickey,password\r\n"
|
||||
b"debug1: kex: algorithm: curve25519-sha256\r\n"
|
||||
b"steamos@10.0.0.2: Permission denied (publickey,password).\r\n")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_real_home_keys(tmp_path, monkeypatch):
|
||||
"""Never read the real ~/.ssh (relaxed mode looks there)."""
|
||||
empty = tmp_path / "empty-home"
|
||||
empty.mkdir()
|
||||
monkeypatch.setattr(native.Path, "home", lambda: empty)
|
||||
|
||||
|
||||
def write_key(path: Path, password: bytes | None = None) -> Path:
|
||||
enc = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
|
||||
key = ed25519.Ed25519PrivateKey.generate()
|
||||
path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, enc))
|
||||
path.with_name(path.name + ".pub").write_bytes(key.public_key().public_bytes(
|
||||
serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH))
|
||||
return path
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- key files
|
||||
def test_key_file_validation(tmp_path):
|
||||
good = write_key(tmp_path / "id_ed25519")
|
||||
assert isinstance(C.load_key_file(good), paramiko.PKey)
|
||||
with pytest.raises(C.KeyFileError, match="passphrase"):
|
||||
C.load_key_file(write_key(tmp_path / "locked", b"secret"))
|
||||
with pytest.raises(C.KeyFileError, match="public half"):
|
||||
C.load_key_file(tmp_path / "id_ed25519.pub")
|
||||
with pytest.raises(C.KeyFileError, match="not found"):
|
||||
C.load_key_file(tmp_path / "missing")
|
||||
(tmp_path / "notes.txt").write_text("hello")
|
||||
with pytest.raises(C.KeyFileError, match="isn't a private SSH key"):
|
||||
C.load_key_file(tmp_path / "notes.txt")
|
||||
|
||||
|
||||
def test_extra_keys_come_from_settings_and_skip_unusable_ones(tmp_path):
|
||||
good = write_key(tmp_path / "mine")
|
||||
locked = write_key(tmp_path / "locked", b"secret")
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, [str(good), str(good), str(locked), str(tmp_path / "gone"), "", 3])
|
||||
assert C.extra_key_paths() == [good, locked, tmp_path / "gone"]
|
||||
assert [path for path, _ in C.extra_keys()] == [good]
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, str(good)) # a single path, e.g. edited by hand
|
||||
assert C.extra_key_paths() == [good]
|
||||
|
||||
|
||||
class _Client:
|
||||
"""paramiko.SSHClient stand-in: accepts the key `accepted` (or the agent when accepted == "agent")."""
|
||||
accepted = None
|
||||
tried: list = []
|
||||
|
||||
def load_host_keys(self, path):
|
||||
pass
|
||||
|
||||
def set_missing_host_key_policy(self, policy):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def save_host_keys(self, path):
|
||||
pass
|
||||
|
||||
def get_transport(self):
|
||||
return Mock()
|
||||
|
||||
def connect(self, pkey=None, password=None, allow_agent=False, look_for_keys=False, **kw):
|
||||
kind = "password" if password else "agent" if allow_agent else pkey
|
||||
_Client.tried.append(kind)
|
||||
if kind != _Client.accepted:
|
||||
raise paramiko.AuthenticationException("Authentication failed.")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("accepted,auth", [("app", "app_key"), ("user", "user_key"), ("agent", "agent")])
|
||||
def test_connection_records_the_login_that_worked(monkeypatch, accepted, auth):
|
||||
monkeypatch.setattr(C.paramiko, "SSHClient", _Client)
|
||||
monkeypatch.setattr(C, "app_keys", lambda: ["app"])
|
||||
monkeypatch.setattr(C, "extra_keys", lambda: [(Path("/k"), "user")])
|
||||
monkeypatch.setattr(C.Frame, "run", lambda self, cmd: (0, "/home/steamos", ""))
|
||||
_Client.accepted, _Client.tried = accepted, []
|
||||
frame = C.Frame(C.FrameTarget("10.0.0.2")).connect()
|
||||
assert frame.auth == auth
|
||||
assert _Client.tried == ["app", "user", "agent"][:["app", "user", "agent"].index(accepted) + 1]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- OpenSSH arguments
|
||||
def frame_with_key():
|
||||
key = SimpleNamespace(get_name=lambda: "ssh-ed25519", get_base64=lambda: "PAIRED_KEY")
|
||||
client = SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key))
|
||||
return C.Frame(C.FrameTarget("paired.example"), client=client)
|
||||
|
||||
|
||||
def test_credentials_offer_own_and_settings_keys_strict_or_relaxed(tmp_path, monkeypatch):
|
||||
own = tmp_path / "ssh"
|
||||
own.mkdir()
|
||||
write_key(own / "id_ed25519")
|
||||
mine = write_key(tmp_path / "id_ed25519") # same file name as FramePort's: copies get their own names
|
||||
monkeypatch.setattr(native, "ssh_dir", lambda: own)
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, [str(mine)])
|
||||
with native.credentials(frame_with_key()) as args:
|
||||
copies = [Path(args[i + 1]) for i, a in enumerate(args) if a == "-i"]
|
||||
assert [c.read_bytes() for c in copies] == [(own / "id_ed25519").read_bytes(), mine.read_bytes()]
|
||||
assert "IdentitiesOnly=yes" in args and "BatchMode=yes" in args and "-v" in args
|
||||
assert all(oct(c.stat().st_mode & 0o777) == "0o600" for c in copies)
|
||||
assert not any(c.exists() for c in copies)
|
||||
with native.credentials(frame_with_key(), relaxed=True) as args:
|
||||
assert "IdentitiesOnly=yes" not in args and args.count("-i") == 2
|
||||
assert "StrictHostKeyChecking=yes" in args # host key still pinned
|
||||
|
||||
|
||||
def test_relaxed_credentials_work_without_any_key_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(native, "ssh_dir", lambda: tmp_path)
|
||||
with pytest.raises(OSError, match="no FramePort key"):
|
||||
with native.credentials(frame_with_key()):
|
||||
pass
|
||||
with native.credentials(frame_with_key(), relaxed=True) as args:
|
||||
assert "-i" not in args
|
||||
|
||||
|
||||
def test_windows_sid_survives_a_non_ascii_user_name(monkeypatch):
|
||||
out = '"desktop-1\\josé müller","S-1-5-21-1004336348-1177238915-682003330-1001"\r\n'.encode("cp850")
|
||||
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: out)
|
||||
assert native.current_sid() == "S-1-5-21-1004336348-1177238915-682003330-1001"
|
||||
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: b"nothing")
|
||||
with pytest.raises(OSError):
|
||||
native.current_sid()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- failures and modes
|
||||
def test_failure_keeps_openssh_messages_and_key_lines():
|
||||
exc = native.failure("OpenSSH SFTP", 255, REFUSED)
|
||||
assert isinstance(exc, native.OpenSSHAuthFailed)
|
||||
text = str(exc)
|
||||
assert "UNPROTECTED PRIVATE KEY FILE" in text and "Offering public key" in text
|
||||
assert "identity file" in text and "Permission denied (publickey,password)" in text
|
||||
assert "kex: algorithm" not in text and "Reading configuration" not in text
|
||||
other = native.failure("OpenSSH SFTP", 1, b"stat x: No such file or directory\n")
|
||||
assert type(other) is OSError and "No such file" in str(other)
|
||||
assert type(native.failure("OpenSSH tar", 255, b"ssh: connect to host x port 22: Connection timed out")) is OSError
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth,known,modes", [("app_key", None, [False, True]), ("user_key", None, [False, True]),
|
||||
("password", None, [False, True]), ("agent", None, [True]),
|
||||
("app_key", True, [True]), ("agent", False, [False])])
|
||||
def test_login_modes(auth, known, modes):
|
||||
assert native.login_modes(SimpleNamespace(auth=auth, openssh_relaxed=known)) == modes
|
||||
assert native.login_modes(SimpleNamespace()) == [False, True] # stand-ins without the fields
|
||||
|
||||
|
||||
def sftp_setup(tmp_path, monkeypatch, refuse_strict=True, refuse_relaxed=False, auth="app_key"):
|
||||
"""Two .part-less files; strict/relaxed logins refused as asked; returns the frame and the Popen calls."""
|
||||
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 = {}
|
||||
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
|
||||
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor), auth=auth,
|
||||
openssh_relaxed=None, target=C.FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
|
||||
mode = {}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def creds(frame, port_flag="-P", relaxed=False):
|
||||
mode["relaxed"] = relaxed
|
||||
yield ["relaxed" if relaxed else "strict"]
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
calls = []
|
||||
|
||||
def popen(args, **kwargs):
|
||||
relaxed = mode["relaxed"]
|
||||
calls.append(relaxed)
|
||||
refused = refuse_relaxed if relaxed else refuse_strict
|
||||
if refused:
|
||||
kwargs["stderr"].write(REFUSED)
|
||||
code = 255
|
||||
else:
|
||||
for _, rel, size in items:
|
||||
files["/incoming/" + rel + ".part"] = size
|
||||
code = 0
|
||||
process = Mock(returncode=code)
|
||||
process.poll.return_value = code
|
||||
return process
|
||||
monkeypatch.setattr(native.subprocess, "Popen", popen)
|
||||
return frame, items, calls
|
||||
|
||||
|
||||
def test_refused_strict_login_retries_with_this_pcs_keys(tmp_path, monkeypatch):
|
||||
frame, items, calls = sftp_setup(tmp_path, monkeypatch)
|
||||
logs = []
|
||||
reporter = Reporter()
|
||||
reporter.log = lambda message, **k: logs.append(message)
|
||||
assert native.upload(frame, items, "/incoming", reporter, 20, 0, 2) == 20
|
||||
assert calls == [False, False, True, True] # both strict workers are refused, then both run relaxed
|
||||
assert frame.openssh_relaxed is True
|
||||
assert any("trying this PC's own SSH keys" in m and "UNPROTECTED" in m for m in logs)
|
||||
assert native.login_modes(frame) == [True] # the next upload starts in the mode that worked
|
||||
|
||||
|
||||
def test_agent_login_goes_straight_to_relaxed(tmp_path, monkeypatch):
|
||||
frame, items, calls = sftp_setup(tmp_path, monkeypatch, auth="agent")
|
||||
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1) == 20
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
def test_refused_in_both_modes_raises_the_auth_failure(tmp_path, monkeypatch):
|
||||
frame, items, calls = sftp_setup(tmp_path, monkeypatch, refuse_relaxed=True)
|
||||
with pytest.raises(native.OpenSSHAuthFailed, match="Permission denied"):
|
||||
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
|
||||
assert calls == [False, True] and frame.openssh_relaxed is None
|
||||
assert not frame.run.called # nothing promoted
|
||||
|
||||
|
||||
def test_tar_retries_a_refused_login(tmp_path, monkeypatch):
|
||||
path = tmp_path / "small.bin"
|
||||
path.write_bytes(b"abc")
|
||||
frame = SimpleNamespace(target=C.FrameTarget("paired"), auth="app_key", openssh_relaxed=None)
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
|
||||
mode = {}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def creds(frame, port_flag="-P", relaxed=False):
|
||||
mode["relaxed"] = relaxed
|
||||
yield []
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
|
||||
class Pipe:
|
||||
def write(self, data):
|
||||
if not mode["relaxed"]:
|
||||
raise BrokenPipeError(32, "Broken pipe") # ssh already exited
|
||||
return len(data)
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
calls = []
|
||||
|
||||
def popen(args, **kwargs):
|
||||
calls.append(mode["relaxed"])
|
||||
code = 0 if mode["relaxed"] else 255
|
||||
if code:
|
||||
kwargs["stderr"].write(REFUSED)
|
||||
process = Mock(stdin=Pipe(), returncode=code)
|
||||
process.poll.return_value = code
|
||||
return process
|
||||
monkeypatch.setattr(native.subprocess, "Popen", popen)
|
||||
native.put_tar(frame, [(path, "small.bin")], "/incoming", Reporter(), Mock())
|
||||
assert calls == [False, True] and frame.openssh_relaxed is True
|
||||
|
||||
|
||||
def test_azure_ad_sid(monkeypatch):
|
||||
out = b'"azuread\\jmueller","S-1-12-1-1234567890-1234567890-1234567890-1234567890"\r\n'
|
||||
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: out)
|
||||
assert native.current_sid() == "S-1-12-1-1234567890-1234567890-1234567890-1234567890"
|
||||
|
||||
|
||||
def test_relaxed_mode_names_this_pcs_default_keys(tmp_path, monkeypatch):
|
||||
"""OpenSSH skips its default keys once any -i is given, so relaxed mode names the ones Paramiko tries."""
|
||||
home = tmp_path / "home"
|
||||
(home / ".ssh").mkdir(parents=True)
|
||||
rsa = home / ".ssh" / "id_rsa"
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa as rsa_keys
|
||||
|
||||
rsa.write_bytes(rsa_keys.generate_private_key(65537, 2048).private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()))
|
||||
write_key(home / ".ssh" / "id_ecdsa", b"secret") # passphrase: only usable through the agent
|
||||
(home / ".ssh" / "id_ed25519").write_text("garbage")
|
||||
monkeypatch.setattr(native.Path, "home", lambda: home)
|
||||
own = tmp_path / "own"
|
||||
own.mkdir()
|
||||
write_key(own / "id_ed25519")
|
||||
monkeypatch.setattr(native, "ssh_dir", lambda: own)
|
||||
assert native.default_key_files() == [rsa]
|
||||
assert native.key_files() == [own / "id_ed25519"]
|
||||
assert native.key_files(relaxed=True) == [own / "id_ed25519", rsa]
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, [str(rsa)]) # a default key also named in Settings: offered once
|
||||
assert native.key_files(relaxed=True) == [own / "id_ed25519", rsa]
|
||||
|
||||
|
||||
def test_add_remove_and_status_of_settings_keys(tmp_path):
|
||||
good = write_key(tmp_path / "frame key")
|
||||
with pytest.raises(C.KeyFileError, match="public half"):
|
||||
C.add_extra_key(tmp_path / "frame key.pub")
|
||||
with pytest.raises(C.KeyFileError, match="passphrase"):
|
||||
C.add_extra_key(write_key(tmp_path / "locked", b"pw"))
|
||||
assert C.add_extra_key(good) == good.resolve()
|
||||
C.add_extra_key(good) # once only
|
||||
assert C.extra_key_paths() == [good.resolve()]
|
||||
good.unlink()
|
||||
[(path, problem)] = C.extra_key_status()
|
||||
assert path == good.resolve() and "not found" in problem
|
||||
assert C.remove_extra_key(good) is True and C.remove_extra_key(good) is False
|
||||
assert C.extra_key_paths() == []
|
||||
|
||||
|
||||
def test_cli_ssh_keys(tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport.cli import app
|
||||
|
||||
good = write_key(tmp_path / "mine")
|
||||
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--add", str(good)])
|
||||
assert run.exit_code == 0 and f"added {good.resolve()}" in run.output
|
||||
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--add", str(tmp_path / "nope")])
|
||||
assert run.exit_code == 1 and str(good.resolve()) in run.output
|
||||
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--remove", str(good.resolve())])
|
||||
assert run.exit_code == 0 and "no extra keys" in run.output
|
||||
Reference in new issue
Block a user