mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Inject the OpenXR compatibility layer into VR APKs on install
VR apps with an arm64 OpenXR loader get frame/openxr-compat's layer unless --no-xr-compat; the app bundle ships the layer. Verified on the Frame: Wolvic's Quest build gets through OpenXR start-up, Open Brush still reaches FOCUSED. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fd15f1de4c
commit
50d5e14539
4 files changed
+99
-8
No files matched your search
@@ -64,6 +64,14 @@
|
|||||||
"*.py"
|
"*.py"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "../frame/openxr-compat",
|
||||||
|
"to": "frame/openxr-compat",
|
||||||
|
"filter": [
|
||||||
|
"XrApiLayer_FRAME_compat.json",
|
||||||
|
"prebuilt/**/*.so"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "../frame/devkit-utils",
|
"from": "../frame/devkit-utils",
|
||||||
"to": "frame/devkit-utils",
|
"to": "frame/devkit-utils",
|
||||||
|
|||||||
@@ -109,6 +109,30 @@ in Frame Control's per-user app-data directory as `apk-signing-key.json`
|
|||||||
updates. A re-signed APK cannot update an installation signed by its original
|
updates. A re-signed APK cannot update an installation signed by its original
|
||||||
publisher; Android also treats it as a different signer for signature checks.
|
publisher; Android also treats it as a different signer for signature checks.
|
||||||
|
|
||||||
|
VR apps with an arm64 OpenXR loader also get the OpenXR compatibility layer
|
||||||
|
([frame/openxr-compat](../frame/openxr-compat/README.md)): an implicit API
|
||||||
|
layer in the APK's `assets/openxr/1/api_layers/implicit.d/`, which the app's
|
||||||
|
own loader picks up next to Valve's layer. It asks SteamVR for OpenXR 1.0 when
|
||||||
|
the app wants 1.1 and enables the extensions that became 1.1 core; maps
|
||||||
|
`xrLocateSpaces` to `xrLocateSpacesKHR` and `grip_surface` to `palm_ext`; drops
|
||||||
|
1.1 controller profiles SteamVR doesn't know; stubs
|
||||||
|
`XR_KHR_android_thread_settings` and `XR_OCULUS_android_session_state_enable`;
|
||||||
|
and keeps the current refresh rate when SteamVR refuses a requested one.
|
||||||
|
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
|
||||||
|
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
|
||||||
|
|
||||||
|
Verified on the headset (2026-09-28):
|
||||||
|
|
||||||
|
- **Wolvic 1.9, Quest build**, installed as downloaded: Frame Control added
|
||||||
|
`LAUNCHER` and the layer. The layer turned OpenXR 1.1.48 into 1.0.63, the
|
||||||
|
instance and session were created, and a 144 Hz refresh request that SteamVR
|
||||||
|
refused was kept at the current rate. The session reached `SYNCHRONIZED`;
|
||||||
|
then Wolvic's Gecko engine crashed (null SIGSEGV on its Gecko thread, the
|
||||||
|
same crash its Lynx build has), which is Wolvic's, not OpenXR's.
|
||||||
|
- **Open Brush, Quest build**, with the layer: 1.1.54 → 1.0.63, the thread
|
||||||
|
settings stub in use, `bytedance/pico4_controller` bindings dropped, and the
|
||||||
|
session reached `FOCUSED`, the same as without the layer.
|
||||||
|
|
||||||
Inspect or prepare an APK without contacting the headset:
|
Inspect or prepare an APK without contacting the headset:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ class VRTests(unittest.TestCase):
|
|||||||
def test_install_auto_and_override(self):
|
def test_install_auto_and_override(self):
|
||||||
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
|
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
|
||||||
'vr': True, 'vr_activity': True, 'launchable': False}
|
'vr': True, 'vr_activity': True, 'launchable': False}
|
||||||
with patch.object(frame_android, 'apk_info', return_value=base), patch.object(frame_android, 'patch') as repair, patch.object(frame_android, '_install', return_value={}) as install:
|
with patch.object(frame_android, 'apk_info', return_value=base), \
|
||||||
|
patch.object(frame_android, 'xr_compat_files', return_value={}), \
|
||||||
|
patch.object(frame_android, 'patch', return_value={'patched': ['launcher']}) as repair, \
|
||||||
|
patch.object(frame_android, '_install', return_value={}) as install:
|
||||||
frame_android.install('original.apk')
|
frame_android.install('original.apk')
|
||||||
repair.assert_called_once()
|
repair.assert_called_once()
|
||||||
self.assertFalse(install.call_args.args[3])
|
self.assertFalse(install.call_args.args[3])
|
||||||
@@ -182,6 +185,38 @@ class VRTests(unittest.TestCase):
|
|||||||
frame_android.install('original.apk', flatscreen=True)
|
frame_android.install('original.apk', flatscreen=True)
|
||||||
self.assertTrue(install.call_args.args[3])
|
self.assertTrue(install.call_args.args[3])
|
||||||
|
|
||||||
|
def test_xr_compat_layer(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
apk = Path(d) / 'a.apk'
|
||||||
|
with zipfile.ZipFile(apk, 'w') as z:
|
||||||
|
z.writestr('lib/arm64-v8a/libopenxr_loader.so', b'')
|
||||||
|
add = frame_android.xr_compat_files(str(apk))
|
||||||
|
self.assertEqual(set(add), set(frame_android.XR_COMPAT_FILES))
|
||||||
|
self.assertIn(b'XR_APILAYER_FRAME_compat', add['assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json'])
|
||||||
|
self.assertTrue(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\x7fELF'))
|
||||||
|
with zipfile.ZipFile(apk, 'a') as z: # already injected: nothing more to add
|
||||||
|
z.writestr('lib/arm64-v8a/libXrApiLayer_FRAME_compat.so', b'')
|
||||||
|
self.assertEqual(frame_android.xr_compat_files(str(apk)), {})
|
||||||
|
flat = Path(d) / 'flat.apk'
|
||||||
|
with zipfile.ZipFile(flat, 'w') as z:
|
||||||
|
z.writestr('classes.dex', b'')
|
||||||
|
self.assertEqual(frame_android.xr_compat_files(str(flat)), {})
|
||||||
|
|
||||||
|
def test_install_adds_layer_to_vr_apps(self):
|
||||||
|
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
|
||||||
|
'vr': True, 'vr_activity': True, 'launchable': True}
|
||||||
|
layer = {'x': b''}
|
||||||
|
with patch.object(frame_android, 'apk_info', return_value=dict(base)), \
|
||||||
|
patch.object(frame_android, 'xr_compat_files', return_value=layer), \
|
||||||
|
patch.object(frame_android, 'patch', return_value={'patched': ['openxr-compat']}) as repair, \
|
||||||
|
patch.object(frame_android, '_install', return_value={}) as install:
|
||||||
|
frame_android.install('game.apk')
|
||||||
|
self.assertIs(repair.call_args.args[2], layer)
|
||||||
|
self.assertEqual(install.call_args.args[1]['patched'], ['openxr-compat'])
|
||||||
|
repair.reset_mock()
|
||||||
|
frame_android.install('game.apk', xr_compat=False) # launchable, no layer: install as is
|
||||||
|
repair.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+31
-7
@@ -7,7 +7,7 @@ in the Steam library and gets its own SteamVR panel. Nothing goes through
|
|||||||
Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||||
|
|
||||||
Python stdlib only. CLI: python3 ui/frame_android.py
|
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||||
install APK [--vr|--flat] | info APK | patch SRC DST [--add NAME=PATH ...]
|
install APK [--vr|--flat] [--no-xr-compat] | info APK | patch SRC DST [--add NAME=PATH ...]
|
||||||
list | launch PKG | stop PKG | remove PKG | probe PKG
|
list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||||
"""
|
"""
|
||||||
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
|
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
|
||||||
@@ -26,6 +26,13 @@ COMPAT = '.local/share/Steam/steamapps/compatdata'
|
|||||||
SHADERS = '.local/share/Steam/steamapps/shadercache'
|
SHADERS = '.local/share/Steam/steamapps/shadercache'
|
||||||
LAUNCHER = os.path.join(ROOT, 'frame', 'android', 'lepton-app.sh')
|
LAUNCHER = os.path.join(ROOT, 'frame', 'android', 'lepton-app.sh')
|
||||||
SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
|
SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
|
||||||
|
# OpenXR API layer that lets OpenXR 1.1 apps run on SteamVR's 1.0-only Android
|
||||||
|
# runtime (frame/openxr-compat, docs/vr-apks.md). Injected into VR APKs.
|
||||||
|
XR_COMPAT = os.path.join(ROOT, 'frame', 'openxr-compat')
|
||||||
|
XR_COMPAT_FILES = {
|
||||||
|
'assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json': 'XrApiLayer_FRAME_compat.json',
|
||||||
|
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so',
|
||||||
|
}
|
||||||
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
|
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
|
||||||
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
|
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
|
||||||
|
|
||||||
@@ -71,6 +78,19 @@ def apk_info(path):
|
|||||||
raise FrameError(f'{os.path.basename(path)}: {e}')
|
raise FrameError(f'{os.path.basename(path)}: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
def xr_compat_files(apk_path):
|
||||||
|
"""The layer's files to add, or {} if the APK has no OpenXR loader or already has the layer."""
|
||||||
|
with zipfile.ZipFile(apk_path) as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
if 'lib/arm64-v8a/libopenxr_loader.so' not in names or names & set(XR_COMPAT_FILES):
|
||||||
|
return {}
|
||||||
|
add = {}
|
||||||
|
for entry, rel in XR_COMPAT_FILES.items():
|
||||||
|
with open(os.path.join(XR_COMPAT, rel), 'rb') as f:
|
||||||
|
add[entry] = f.read()
|
||||||
|
return add
|
||||||
|
|
||||||
|
|
||||||
def check_installable(info):
|
def check_installable(info):
|
||||||
if info['min_sdk'] and info['min_sdk'] > 30:
|
if info['min_sdk'] and info['min_sdk'] > 30:
|
||||||
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
|
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
|
||||||
@@ -112,7 +132,7 @@ def _write_meta(d, meta):
|
|||||||
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
|
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
|
||||||
|
|
||||||
|
|
||||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None):
|
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None):
|
||||||
info = apk_info(apk_path)
|
info = apk_info(apk_path)
|
||||||
if icon_png:
|
if icon_png:
|
||||||
info['icon_png'] = icon_png
|
info['icon_png'] = icon_png
|
||||||
@@ -122,12 +142,14 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None):
|
|||||||
raise FrameError(f'unexpected package name {pkg!r}')
|
raise FrameError(f'unexpected package name {pkg!r}')
|
||||||
if flatscreen is None:
|
if flatscreen is None:
|
||||||
flatscreen = not info['vr']
|
flatscreen = not info['vr']
|
||||||
|
# VR apps get the OpenXR compatibility layer unless told otherwise; it only
|
||||||
|
# changes calls SteamVR would otherwise reject.
|
||||||
|
add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {}
|
||||||
with _install_lock:
|
with _install_lock:
|
||||||
if not info['launchable'] and info['vr_activity']:
|
if add or (not info['launchable'] and info['vr_activity']):
|
||||||
with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
|
with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
|
||||||
patched = os.path.join(tmp, 'app.apk')
|
patched = os.path.join(tmp, 'app.apk')
|
||||||
patch(apk_path, patched)
|
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||||
info['patched'] = ['launcher']
|
|
||||||
info['launchable'] = True
|
info['launchable'] = True
|
||||||
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
|
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
|
||||||
return _install(apk_path, info, pkg, flatscreen, name, source)
|
return _install(apk_path, info, pkg, flatscreen, name, source)
|
||||||
@@ -301,7 +323,8 @@ def patch(src, dst, add=None):
|
|||||||
repack(src, dst, replace={'AndroidManifest.xml': manifest}, add=add)
|
repack(src, dst, replace={'AndroidManifest.xml': manifest}, add=add)
|
||||||
result = apk_info(dst)
|
result = apk_info(dst)
|
||||||
result.pop('icon_png', None)
|
result.pop('icon_png', None)
|
||||||
result['patched'] = ['launcher'] if manifest != original else []
|
result['patched'] = (['launcher'] if manifest != original else []) + \
|
||||||
|
(['openxr-compat'] if add and set(XR_COMPAT_FILES) <= set(add) else [])
|
||||||
return result
|
return result
|
||||||
except (OSError, ValueError, zipfile.BadZipFile, frame_apk.ApkError) as e:
|
except (OSError, ValueError, zipfile.BadZipFile, frame_apk.ApkError) as e:
|
||||||
raise FrameError(str(e)) from e
|
raise FrameError(str(e)) from e
|
||||||
@@ -311,7 +334,8 @@ def main():
|
|||||||
cmd, *args = sys.argv[1:] or ['help']
|
cmd, *args = sys.argv[1:] or ['help']
|
||||||
try:
|
try:
|
||||||
if cmd == 'install':
|
if cmd == 'install':
|
||||||
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None)
|
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
|
||||||
|
xr_compat=False if '--no-xr-compat' in args else None)
|
||||||
elif cmd in ('info', 'describe'):
|
elif cmd in ('info', 'describe'):
|
||||||
r = apk_info(args[0])
|
r = apk_info(args[0])
|
||||||
r.pop('icon_png', None)
|
r.pop('icon_png', None)
|
||||||
|
|||||||
Reference in new issue
Block a user