Compare commits

...
Author SHA1 Message Date
SAPHID 4967b13dca Version Framey sample settings without losing old data 2026-10-26 22:10:25 +00:00
Alex Southwell 04cde721c8 Merge pull request #338 from saphid/feat/framey-mpris-runtime
Repair Music Control for Decky's real legacy runtime
2026-10-26 22:03:56 +00:00
Alex Southwell 1ddb6278b8 Merge pull request #337 from saphid/docs/framey-native-port-blockers
Clarify native blockers for Emuchievements and Cloud Save
2026-10-26 21:44:14 +00:00
SAPHID 4252d22975 Clarify native blockers for Emuchievements and Cloud Save 2026-10-26 16:13:44 +00:00
6 changed files with 117 additions and 13 deletions

No files matched your search

@@ -0,0 +1,44 @@
# Native plugin packaging blockers, 26 October 2026
This is a static, digest-scoped follow-up. Neither plugin was installed or
run on the Frame. Their compatibility statuses remain **broken**.
## Decky Cloud Save 1.4.2, store ID 49
The official ZIP SHA-256 is
`b311d967ddf1b0f0046d36929645855891a40173e3a93f6f0cd8b1576b04b411`.
It was downloaded and hash-checked without executing any member. Of its 27
members, `decky-cloud-save/bin/rclone` is a regular executable of **60,776,600
bytes** with ELF machine **62 (x86_64)**. The installer permits at most 30 MiB
per member, so this exact ZIP fails before native architecture validation.
Raising that limit alone would still leave an x86_64 executable on the ARM64
Frame. No cloud credentials, saves, reads or writes were exercised.
## Emuchievements 2.0.5, store ID 28
The official ZIP SHA-256 is
`98dcb443e81b9f9011cfe77c69eb659869c8e2df2df8b0a443df66cbfc2f5a89`.
The [earlier helper check](framey-emuchievements-arm-2026-10-26.md) proved
one synthetic ROM hash on ARM64, without installing the plugin. The upstream
source commit `8567e94befffc943434b2adb842487274815f8bb` and its pinned
libarchive, libchdr and rcheevos submodules were checked out separately for
this audit. The source is GPL-3.0. An isolated Debian ARM64 cross-build made a
2.6 MB helper (`c8794558a0d75294930f969a426e7d2ac13a27e3c1ff9bd8aae093e31596b9bd`),
but it was **not shipped or run on ROM archives**. A complete source bundle
without Git metadata compressed to about 8 MB; source and license distribution
would be part of any release-ready port.
The proposed source has an additional blocker. `backend/src/util.cpp` builds
an extraction destination by appending each raw archive member pathname to
an owned `/tmp/tmpdir.*` prefix, then passes it to libarchive's disk writer.
It neither rejects `..`/absolute paths nor enables libarchive's secure path
flags. `backend/src/main.cpp` later removes the extracted file's parent path.
With an untrusted archive, those paths may leave the intended temporary
directory. The plugin's Python backend also invokes `bin/hash` through a
shell with a path argument. A port needs bounded path validation, secure
extraction/cleanup, safe subprocess arguments, format coverage and actual
Decky/backend tests before its official ZIP can be transformed or installed.
The official archive still contains an x86_64 helper. RetroAchievements
login, real ROM scanning, a packaged ARM64 backend, wearer-visible VR and
physical controller use remain unverified.
+3 -3
View File
@@ -65,7 +65,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 44: DeckMTP 1.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: DeckMTP/bin/umtprd. | static archive, local browser, fake-Frame |
| 46: Steamback 1.0.0-1 | legacy / legacy | partial | Panel opens using the real Frame current-user/library snapshot. Account setup and empty save-list reads pass through actual Decky 3.3.0 amd64 in isolation. ARM64 backend, supported-save discovery, automatic backups and restore remain unverified; no backup/restore ran. | static archive, local browser, fake-Frame, real Frame SSH |
| 48: Quick Launch 1.2.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | The exact official ZIP contains a 60,776,600-byte x86_64 rclone member, above the 30 MiB member limit and unusable on ARM64. Installation remains blocked. | static archive, local browser, fake-Frame |
| 50: Storage Cleaner 1.4.0 | 1 / 2 | partial | Empty-data panel rendered; cleanup was deliberately not run because it can remove local data. | static archive, local browser, fake-Frame |
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | partial | An inert local component lets the exact frontend initialize and render its Settings view in opaque Chromium. Backend get_setting and plugin network were denied in the audit; Steam game-page theme injection has no verified target in Framey. Music search, playback, downloads, settings persistence and worn VR remain unverified. | static archive, local browser, fake-Frame |
| 53: Shotty 0.1.0 | legacy / legacy | partial | The frontend starts with the read-only Frame library snapshot. Frame Control adds a digest-scoped click.get_app_dir compatibility module to the managed install; the exact backend then imports and copies a synthetic Steam screenshot into a temporary Pictures folder in isolated Python. Actual Decky 3.3.0 ARM64 loading, live screenshots/rescue, in-VR panel and physical controller use remain unverified. | static archive, local browser, fake-Frame, real Frame SSH, isolated Python backend |
@@ -288,11 +288,11 @@ All published Linux native payloads in this snapshot are x86_64; no published aa
| Fantastic | bin/backend | Fan backend/driver is Deck-specific; an ARM64 rebuild would not provide compatible Frame hardware. |
| PowerTools | bin/libryzenadj.so, bin/ryzenadj, bin/backend | RyzenAdj and AMD power backend cannot control an ARM64 Frame SoC. |
| Memory Deck | bin/libscanmem.so.1.0.0 | libscanmem must be rebuilt for aarch64 and validated against target process architecture; memory writes were not tested. |
| Emuchievements | bin/hash | A source-matched ARM64 bin/hash helper matched the official x86_64 helper on one synthetic ROM on the real Frame (docs/evidence/framey-emuchievements-arm-2026-10-26.md). The official ZIP still bundles x86_64 bytes and remains blocked; format coverage, packaging, plugin backend/UI, login and worn VR are unverified. |
| Emuchievements | bin/hash | A source-matched ARM64 helper matched one synthetic ROM on the real Frame (docs/evidence/framey-emuchievements-arm-2026-10-26.md). The pinned upstream source proposed for packaging also has unsafe archive extraction and cleanup paths (docs/evidence/framey-native-port-blockers-2026-10-26.md); fix and test those before shipping an ARM64 binary. The official x86_64 ZIP remains blocked; format coverage, plugin backend/UI, login and worn VR are unverified. |
| MoonDeck | python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_parser.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/reader_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/mask.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_writer.cpython-313-x86_64-linux-gnu.so, … 26 total; full list in JSON | Rebuild vendored CPython 3.13 extension wheels for the headset Python ABI and ARM64; buddy/helper dependencies also need ports. |
| Controller Tools | bin/backend | Native backend needs an ARM64 build and controller/HID dependencies; its direct websocket also bypasses the Framey bridge. |
| DeckMTP | bin/umtprd | umtprd needs ARM64 libusb/MTP dependencies and USB gadget permissions; root user-service blocker remains. |
| Decky Cloud Save | bin/rclone | rclone needs an ARM64 artifact; this ZIP also fails safe member validation. Cloud writes were not exercised. |
| Decky Cloud Save | bin/rclone | An ARM64 rclone artifact and safe package policy are needed; the official 60,776,600-byte x86_64 member fails archive validation. No cloud writes were run (docs/evidence/framey-native-port-blockers-2026-10-26.md). |
| DeckyFileServer | bin/backend | Native file-server backend needs ARM64 dependencies; root requirement also blocks the user service. |
| TS3 QuickAccess | bin/ts3-qs4sd.so | TeamSpeak client plugin must match an ARM64 TeamSpeak client and SDK ABI, not merely rebuild the .so. |
| Syncthing | bin/decky-syncthing-watchdog | The watchdog needs an ARM64 build, plus a supported Syncthing runtime/package and safe path validation. |
+6
View File
@@ -14,6 +14,12 @@ action calls the backend, so it reads the persisted setting rather than a
browser cache. A missing, damaged, or old non-boolean value reads as the
caller's default;
the next save atomically replaces the sample's settings file.
The current file is `{"schema": 1, "enabled": true|false}`. An unversioned
`{"enabled": true|false}` file from the first sample release still reads, and
the next save writes schema 1. A file with another explicit schema returns a
visible read or save error and remains untouched, so downgrading the sample
cannot erase settings written by a newer version. Add a deliberate migration
for each future schema instead of changing this guard silently.
The panel also guards its own read and write calls synchronously. If two
controller events arrive before React redraws a disabled control, the second
event is ignored; after the request settles, a new change can be saved.
+21 -6
View File
@@ -5,26 +5,41 @@ from pathlib import Path
import tempfile
import decky
SETTINGS_SCHEMA = 1
def settings_file():
# Decky provides this per-plugin path through its official Python module.
return Path(decky.DECKY_PLUGIN_SETTINGS_DIR) / 'settings.json'
def read_settings():
try:
stored = json.loads(settings_file().read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return {}
if not isinstance(stored, dict):
return {}
# The first sample release wrote {"enabled": bool} without a schema.
# Keep reading it; the next save writes the current format.
schema = stored.get('schema', SETTINGS_SCHEMA)
if type(schema) is not int or schema != SETTINGS_SCHEMA:
raise ValueError('Settings were written by an unsupported sample version')
return stored
class Plugin:
async def get_setting(self, key: str, default=False):
if key != 'enabled':
raise ValueError('Unknown setting')
try:
stored = json.loads(settings_file().read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return default
value = stored.get(key) if isinstance(stored, dict) else None
value = read_settings().get(key)
return value if type(value) is bool else default
async def set_setting(self, key: str, value: bool):
if key != 'enabled' or type(value) is not bool:
raise ValueError('Expected an enabled boolean')
# Refuse to erase settings from a newer plugin after a downgrade.
read_settings()
target = settings_file()
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = None
@@ -32,7 +47,7 @@ class Plugin:
with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=target.parent,
prefix='.settings-', delete=False) as output:
temporary = Path(output.name)
json.dump({'enabled': value}, output)
json.dump({'schema': SETTINGS_SCHEMA, 'enabled': value}, output)
output.write('\n')
temporary.chmod(0o600)
os.replace(temporary, target)
+40 -1
View File
@@ -1,6 +1,7 @@
"""The author example stores settings as a normal Decky user plugin."""
import asyncio
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
@@ -36,7 +37,8 @@ class SamplePlugin(unittest.TestCase):
self.assertTrue(asyncio.run(first.set_setting('enabled', True)))
second = backend(folder)
self.assertTrue(asyncio.run(second.get_setting('enabled', False)))
self.assertTrue((Path(folder) / 'settings.json').is_file())
self.assertEqual(json.loads((Path(folder) / 'settings.json').read_text()),
{'schema': 1, 'enabled': True})
def test_backend_rejects_unknown_keys_and_non_boolean_values(self):
with tempfile.TemporaryDirectory() as folder:
@@ -57,6 +59,43 @@ class SamplePlugin(unittest.TestCase):
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
def test_legacy_boolean_survives_restart_and_migrates_on_save(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
path.write_text('{"enabled": true}\n')
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
self.assertEqual(json.loads(path.read_text()), {'enabled': True},
'Reading must not rewrite a user setting')
self.assertTrue(asyncio.run(backend(folder).set_setting('enabled', False)))
self.assertEqual(json.loads(path.read_text()), {'schema': 1, 'enabled': False})
self.assertFalse(asyncio.run(backend(folder).get_setting('enabled', True)))
def test_newer_schema_is_preserved_on_read_and_save(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
original = '{"schema": 2, "enabled": true, "future": "keep"}\n'
path.write_text(original)
plugin = backend(folder)
with self.assertRaisesRegex(ValueError, 'unsupported sample version'):
asyncio.run(plugin.get_setting('enabled', False))
with self.assertRaisesRegex(ValueError, 'unsupported sample version'):
asyncio.run(plugin.set_setting('enabled', False))
self.assertEqual(path.read_text(), original)
self.assertEqual(list(Path(folder).glob('.settings-*')), [])
def test_failed_atomic_replace_keeps_previous_setting(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
plugin = backend(folder)
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
previous = path.read_bytes()
with patch('os.replace', side_effect=OSError('storage unavailable')):
with self.assertRaisesRegex(OSError, 'storage unavailable'):
asyncio.run(plugin.set_setting('enabled', False))
self.assertEqual(path.read_bytes(), previous)
self.assertEqual(list(Path(folder).glob('.settings-*')), [])
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
if __name__ == '__main__':
unittest.main()
+3 -3
View File
@@ -1794,7 +1794,7 @@
},
"backend": "Python + native payload",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"nativePort": "A source-matched ARM64 bin/hash helper matched the official x86_64 helper on one synthetic ROM on the real Frame (docs/evidence/framey-emuchievements-arm-2026-10-26.md). The official ZIP still bundles x86_64 bytes and remains blocked; format coverage, packaging, plugin backend/UI, login and worn VR are unverified.",
"nativePort": "A source-matched ARM64 helper matched one synthetic ROM on the real Frame (docs/evidence/framey-emuchievements-arm-2026-10-26.md). The pinned upstream source proposed for packaging also has unsafe archive extraction and cleanup paths (docs/evidence/framey-native-port-blockers-2026-10-26.md); fix and test those before shipping an ARM64 binary. The official x86_64 ZIP remains blocked; format coverage, plugin backend/UI, login and worn VR are unverified.",
"uiSurfaces": [
"local/Steam routes",
"toasts",
@@ -3597,7 +3597,7 @@
"https://github.com/GedasFX/decky-cloud-save.git"
],
"status": "broken",
"reason": "Unsafe plugin archive member.",
"reason": "The exact official ZIP contains a 60,776,600-byte x86_64 rclone member, above the 30 MiB member limit and unusable on ARM64. Installation remains blocked.",
"blockInstall": true,
"verification": [
"static archive",
@@ -3632,7 +3632,7 @@
},
"backend": "Python + native payload",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"nativePort": "rclone needs an ARM64 artifact; this ZIP also fails safe member validation. Cloud writes were not exercised.",
"nativePort": "An ARM64 rclone artifact and safe package policy are needed; the official 60,776,600-byte x86_64 member fails archive validation. No cloud writes were run (docs/evidence/framey-native-port-blockers-2026-10-26.md).",
"uiSurfaces": [
"local/Steam routes",
"toasts",