A Ctrl latched or locked on Arrow Pad (or Alt, Super) is down on ydotoold's keyboard, the one Dictation types with, so dictated letters went out as shortcuts: Ctrl+W closed the tab, Ctrl+Q quit. 0.6.13 fixed this only for Arrow Pad's 🎤; started by its shortcut or the menu, Dictation still typed that way. - lib/xkb.py let-go: the ydotool events that release the modifiers held down on ydotoold's keyboard, read from its device (EVIOCGKEY), and only those (no stray Shift taps; both Shifts is a search in some programs). Alt and Super go up around F24, so their release isn't a tap. Never Right Ctrl. Where the state can't be read, all of them. - dictate runs it right before typing. - Arrow Pad turns its latched and locked modifiers off when it sees Dictation start or end (and on 🎤), with the same F24 wrap, so it doesn't show a Ctrl that isn't down. One a laser holds stays. - Tests never read this machine's input devices (FRAME_TWEAKS_INPUT).
33 KiB
Frame Tweaks: design notes
How Frame Tweaks' core works and why it is built the way it is. This page is for contributors
and reviewers. What each tweak does, and how, is in its own README under
packs/core/; how to write a tweak or a pack is in mods.md.
Goal
Someone with a Valve Steam Frame picks fixes and app setups from a checklist, and each one
installs, checks and removes on its own. Nothing should need steamos-readonly disable,
nothing should break at the next SteamOS update, and what is installed should keep working if
nobody maintains the project any more.
Constraints
The design follows from what the Frame is:
- ARM64 SteamOS. Binaries must be aarch64.
lib/common.shwarns when it runs on anything else. Arch Linux ARM packages run as-is, because SteamOS is Arch-based. - A read-only system partition that updates replace. Anything installed into
/usr(withsteamos-readonly disableand pacman) is gone after the next SteamOS update. So every tweak installs into$HOME:~/.local/bin,~/.local/share,~/.config, user systemd units and the app menu in~/.local/share/applications. The README lists the exceptions: Homebrew needs sudo once to create/home/linuxbrew, Battery charge limit needs it once for a udev rule in/etc(listed in/etc/atomic-update.conf.d, which SteamOS updates keep), NordVPN adds NetworkManager connections, and the Flathub apps (KWalletManager) go into the system-wide Flatpak folder. - Two sessions at once. Steam's VR session runs on X display
:0, where floating VR windows live. Desktop mode is a Plasma session nested inside it, on Wayland, with its own D-Bus bus andXDG_RUNTIME_DIR. Programs started from the desktop, from shortcuts and from VR can each see a different environment, clipboard and wallet. Several tweaks exist only because of this: clipboard syncs the two clipboards, wallet makes both sessions use one KDE wallet, andlib/browser.pysends a web link to the session where the browser is already running.sysctl_userinlib/common.shrunssystemctl --userwith the outer session's runtime folder and bus, so it also works from a desktop-mode shell. - The nested desktop is a KWin window. A Right Ctrl press toggles KWin's input grab, and then every key and click goes to the desktop. Nothing in Frame Tweaks sends Right Ctrl (see Safety rules).
- SteamVR's internals are not an API. The VR environment tweaks patch SteamVR's dashboard, a web page whose code changes with SteamVR updates. They must turn themselves off when that code changes (see Failing closed).
Layout
| Path | Role |
|---|---|
install.sh |
The commands: install, uninstall, reinstall, status, update, hold, check-system, debug-info, list, and so on. It decides what runs in which order, and takes the lock and the log. |
lib/common.sh |
Helpers that tweak scripts source: messages, fetch_pinned, alarm_fetch, pip_pinned, backups, status_ok/status_missing/status_warn, dashboard-patch helpers. mods.md lists what a pack can rely on. |
lib/sft.py |
Loads the packs and their tweaks, and holds everything the installer window and install.sh share: the tweak list (registry), hidden rows and groups, the Run label, and tweak settings. Both front ends call it, so they can't drift apart. |
lib/manifest.py |
Reads (and writes) the YAML front matter of manifests, with no dependencies. |
gui/ |
The installer window (PyQt6): the list, details pages, Packs and Wallet apps pages. |
packs/core/<id>/ |
One folder per tweak: README.md (its manifest, then its documentation), install.sh, uninstall.sh, status.sh and optional scripts. pack.md sets the order and categories. |
dev/ |
Maintainer tools: docs generation and checks, pin checks and bumps, the mirror, the test harness, git hooks. |
tests/ |
Unit tests (dev/run-tests.py runs them several at a time; one file: python3 -m unittest tests/test_x.py). |
Manifests: the README is the source of truth
A tweak's README.md starts with YAML front matter. That block is all the installer, the
window, the generated docs and the tests know about the tweak: title, category, tier,
dependencies, the processes it runs, settings, what it changes and how to undo it. Before
packs, this lived in separate files (components.tsv, meta, processes, # label: lines);
the CHANGELOG records their removal. One file per tweak means a tweak folder is
self-contained, and can be copied into a pack or out of one.
dev/gen-docs.py builds the README tables, SECURITY.md's "What each
tweak changes" rows and each tweak README's intro and "What it changes" section (and a 3rd Party
tweak's copy of its upstream README) from the manifests, between
<!-- generated:NAME --> markers. CI runs it with --check, so the docs can't fall behind
the manifests.
Why a strict YAML subset
lib/manifest.py reads a subset of YAML, written so that a file means the same thing to this
reader, to PyYAML and to a person:
- No dependency. SteamOS's Python may not have PyYAML, and the installer must run before anything is installed.
- No guessing. Values that YAML 1.1 readers turn into something else (
yes,no,on,off, dates,010,1_000,.5) are refused with the line number and must be quoted. Tabs, anchors, aliases, tags, duplicate keys and values that continue onto the next line without>or|are refused too. - Checked.
tests/test_manifest.pyreads every official manifest with both readers and compares the results; CI installs PyYAML for this test only.
Packs and trust
Every tweak comes from a pack: a folder of tweak folders with an optional pack.md.
lib/sft.py pack_dirs looks in three places, in this order: the official packs
(packs/core/, packs/integrations/, then packs/third-party/; other folders in packs/ are local packs that
need trusting), the folders in ~/.local/share/frame-tweaks/packs/, and the folders
listed in FRAME_TWEAKS_PACKS.
- The official packs load like any other. They go through the same
read_packandread_tweakchecks. The only differences: they don't need trusting, and they're on unless turned off (./install.sh mods off NAME, kept in~/.config/frame-tweaks/installer/off-packs). Core'spack.mdsets the category order; 3rd Party lists the same categories. - Other packs need trusting. A pack's scripts run as the user, with their files and
passwords in reach.
./install.sh mods trust PACKrecords the pack's folder and a SHA256 over its file names and contents (.gitand__pycache__left out) in~/.config/frame-tweaks/installer/trusted-packs. If any file changes, the hash no longer matches and the pack is shown as "changed" and not used until it is trusted again. Trust covers content, not a name or a location. - Ids are folder names. If a trusted pack has a tweak with an id that is already taken,
it is left out with a problem message, unless its manifest says
replaces: true. Then it takes the earlier tweak's place in the list, and the window says which one it replaced. api.pack.mdcan say which version of the pack format it uses. A pack with a newerapithan this Frame Tweaks knows (APIinlib/sft.py) is skipped with a message, rather than misread. Unknown manifest keys are warnings and are ignored, so packs can use optional fields that a newer version adds.- Problems don't stop the others. A tweak with a bad manifest or a missing script is left
out. A tweak whose dependency no pack in use provides is left out too, and this repeats until
nothing is missing.
./install.sh modslists every problem;./install.sh mods check FOLDERchecks a pack without using it.install.shstops only if no tweak at all can be loaded.
Lifecycle of a tweak
Install
./install.sh install ID... (and the window's tick boxes, which run the same command):
- Takes the lock (
flockon~/.local/state/frame-tweaks/install.lock) and starts a log. Only one install, update or removal runs at a time, from the window or a terminal. - Runs migrations for the named tweaks (below).
- Adds dependencies, dependencies first, without duplicates (
with_deps). - For each tweak, stops its running programs (
stop_processes, below), so a reinstall can't leave the old copy running, then runs itsinstall.sh. If that succeeds, it records the Frame Tweaks version that installed it (record_version). - Prints the status of what was asked for.
Tweak scripts run with the lock's file descriptor closed, so a background program they start
can't keep the lock held. Every install.sh must be safe to run again: updates and
reinstalls simply run it again.
Status
status.sh prints one line and exits 0 (installed and working), 1 (not installed) or 2
(installed, needs attention), through status_ok, status_missing and status_warn. It must
not change anything: the window runs it often, in the background. status_restart is a
needs-attention line of its own (restart …, exit 2) for something SteamVR loads only as it
starts: the window shows it with ⟳ and puts ⟳ Restart SteamVR (SteamOS's steamvr restart,
after asking) in place of the row's Run or Settings button.
Status is also how Frame Tweaks knows what is installed. uninstall all, update and
check-system all ask each tweak's status.sh, and count anything that doesn't exit 1 as
installed. Exit 1 means "not installed" only with status_missing's or status_unload's
line: a status.sh that stops at a failing command (set -e) says neither, and for a tweak
with a version record in installed/ that's "status check failed" (2), as is an exit code other
than 0, 1 or 2. ("Not installed" had updates skip such a tweak and forget-settings delete its
settings.) Otherwise the version records are used for migrations and for finishing an update
that stopped partway (below).
Uninstall
uninstall.sh removes what install.sh added, keeps the user's data, and says what it kept.
The tweak's settings (its manifest's config: list, and its settings.file) are left to
install.sh: kept and named, or removed when ⚙ Preferences → Uninstalling (in
~/.config/frame-tweaks/uninstall-settings) or --remove-settings says so. A reinstall always
keeps them. forget-settings removes those that tweaks no longer installed left, and
uninstall-self removes Frame Tweaks itself (every tweak first, unless --keep-tweaks, but
those that install another project's app, manifest app:, unless --remove-apps; its
folder only when nothing of the user's is in it: changes, their own packs, unpushed commits,
other worktrees). install.sh refuses to remove a tweak that an installed tweak depends on, and
uninstall all removes dependents first.
Before changing a file the user owns, a tweak calls backup_file, and restore_file on
uninstall. The first copy wins, and a file that didn't exist is recorded as absent, so
restoring removes it. KDE settings are recorded and restored key by key (save_kconfig,
restore_kconfig), because the rest of the file may have changed in the meantime. Files that
hold secrets in plain text are never backed up: a backup would bring back the leak the tweak
removed. CONTRIBUTING.md also asks for marked blocks that can be removed again, as the
Firefox tweak does in user.js.
reinstall uninstalls and then installs, with FRAME_TWEAKS_REINSTALL=1 set for the
uninstall. Then a tweak can keep what other tweaks left in its care; for example,
vr-dashboard keeps the other tweaks' dashboard patches.
Optional scripts
stopped.shruns after the programs listed underprocesses:in the manifest were stopped. Those patterns arepgrep -fpatterns; they are sent a kill, given 3 seconds, then killed with-9. Arrow Pad'sstopped.shreleases modifier keys that a stopped pad can no longer release itself.migrate.sh OLD-VERSIONruns beforeinstall.shwhen the version recorded ininstalled/<id>is older than the currentVERSION(going back to an older release runs none). A failed migration is reported and the install goes ahead. No official tweak needs one at the moment. Renames that affect Frame Tweaks as a whole, such as the oldsteam-frame-tweaksfolders and settings moved into~/.config/frame-tweaks/, are handled in code on every run (migrate_legacy_dirs), not by asking the user.check-system.shchecks that the SteamOS or SteamVR parts the tweak relies on are still there, prints one line and exits 0 or 1../install.sh check-systemruns it for every installed tweak, and so does every update.update-check.shprints the installed and latest upstream versions; the window offers an update when the second is newer.needed.shsays whether this Frame needs the tweak: exit 0, or exit 1 with one line saying why not (SteamOS now does it itself, say). It checks what the Frame has, a package's version or a file, not the SteamOS version, which the beta and stable branches number differently.lib/sft.py(not_needed) runs each once per process; one that isn't needed and isn't installed is hidden likehidden_by_default, its box disabled, and./install.sh installskips it. An installed one stays listed, updates and reinstalls install it again, and itsstatus.shshould say it can go.
Holds
./install.sh hold ID creates ~/.local/state/frame-tweaks/held/<id>. During an update
(FRAME_TWEAKS_UPDATING set), a held tweak is neither migrated nor reinstalled. An explicit
install or reinstall of it still runs.
Updating Frame Tweaks itself
Releases are git tags, vX.Y.Z. Pre-releases are vX.Y.Z-rc.N.
get.shfinds the newest release tag withgit ls-remoteand clones that tag into~/frame-tweaks(or checks it out, if a clone is already there). It never installs whatever is onmainat that moment../install.sh updateworks like this in a release checkout (a detached tag): fetch the tags, pick the newest one for the channel (or the one given with--to, to roll back), and check it out. Then it re-runs itself as./install.sh _reinstall, so the new version of the script does the rest: migrations, a reinstall of everything installed (holds respected) andcheck-system. In a developer checkout on a branch it usesgit pull --ff-onlyinstead.- Nothing to do. When the checkout didn't move, the second stage is skipped, unless a tweak
it reinstalls (not held, not linked, still installed) was last installed by another version
(
$STATE/installed/<id>, written only when its installer succeeds): an update whose second stage failed or was cut short is finished by running it again.--reinstallruns it anyway. - Channels. Stable takes only
vX.Y.Z. Beta (betain~/.config/frame-tweaks/channel, orFRAME_TWEAKS_CHANNEL=beta) also takes pre-releases, sorted below the release they lead up to:-is replaced by~beforesort -V.get.sh,lib/common.shnewest_tagand the window's update hint each implement this rule.get.shwrites a channel it's given into that file (./install.sh channeland ⚙ Preferences do too), and a new install reads it there when none is given. An update never moves to an older release unless asked (--to vX.Y.Z, or--to stablefor the newest stable one), so a tester back on stable stays on the test release until a newer stable one is out. - Migrations are keyed by
VERSION.installed/<id>holds theVERSIONthat last installed each tweak, and the tweak'smigrate.shreceives it. What people and logs see is the exact build instead (lib/common.shft_version,lib/sft.pybuild_version):0.5.0at a release tag,0.5.0+3.abc1234three commits later,-dirtywith local changes. Onlyv*tags count (themirrorrelease has a tag of its own). - Releases are made by the Release workflow
(
release.yml). It turns CHANGELOG.md's## Unreleasedsection into the release notes, bumpsVERSIONand the AppStream metadata, commits, tags and drafts a GitHub release. A pre-release takes its notes from the section and leaves it, so the release after it dates it with everything since the last release. The commit and the tag are pushed together or not at all (--atomic).
State on disk
| Where | What |
|---|---|
~/frame-tweaks/ |
The checkout (get.sh's default; FRAME_TWEAKS_DIR changes it). The menu entry points here. |
~/.local/state/frame-tweaks/ |
install.lock; logs/ (every changing run, the last 5 kept, used by debug-info); installed/<id> (version records); held/<id>; backups/<tweak>/ (originals of changed user files and KDE keys); alarm/<package> (last installed Arch Linux ARM version, for the downgrade check); vr-debugger.prev (the dashboard setting to restore). |
~/.config/frame-tweaks/ |
channel; installer/ (hidden and shown rows, folded and hidden groups, trusted-packs); tweak settings (*.conf, *.json), written only through the keys and values the manifest lists. |
~/.local/share/frame-tweaks/ |
packs/ (user packs); venv/ (shared Python venv for small D-Bus tools); vr-dashboard/ (the patcher and installed patches); launcher-originals/ (menu entries before wallet-launcher wrapped them) and launcher-built/ (the wrappers as it wrote them); menu-built/ (Frame Tweaks' own menu entries as written). What an entry in the menu says differently from its copy is your edit, kept when Frame Tweaks writes it again (lib/menuedits.py); some tweaks' data. |
~/.local/share/arrowpad/venv |
The PyQt6 venv shared by the installer window, Arrow Pad and the NordVPN tray. |
./install.sh debug-info writes a report from these (status, holds, check-system, failed
user services, the last two logs). User and host names, the home path, IP, MAC and e-mail
addresses and the phone's name are removed before it is written.
Supply chain
Downloads from other projects are checked against values recorded in this repository, never against a checksum fetched next to the file.
- Pins. A tweak's
pin(orNAME.pin) file names aversion=, an HTTPSurl=, and asha256=for a file or arev=for a git commit.fetch_pinneddownloads the URL and refuses it unless it matches. A git pin is cloned and checked out at the commit, and the install fails ifHEADisn't that commit. CI requires every pin to have these fields. Homebrew's installer is pinned the same way. - The mirror release. Each pin also has
mirror=, Frame Tweaks' own copy on this repo'smirrorrelease (uploaded bydev/mirror-pins.sh), with itslicense=, ornomirror=saying why it can't be copied. Piper isn't copied: it bundles espeak-ng (GPL-3.0), whose source would have to be hosted with it. When upstream's file is gone or changed,fetch_pinneduses the copy, checked against the same SHA256. A git commit's copy is a source archive with its ownmirror_sha256=in the pin. The mirror is therefore trusted no more than upstream: a changed copy is refused, just as a changed upstream file is. - Arch Linux ARM packages (
alarm_fetch): HTTPS mirrors only. Each package is verified withgpgvagainst ALARM's build key, shipped inlib/keys/. A signature proves only that ALARM built the file, so two more checks follow: the signed package's own.PKGINFOmust name the package that was asked for, and a version older than the last one installed on this device is refused (a mirror replaying an old build).ALARM_ALLOW_DOWNGRADE=1overrides this. - Homebrew packages (scrcpy, wl-clipboard, Tesseract and others) come from Homebrew as it is on the day of install; they aren't pinned.
- Python packages install through
pip_pinned, withlib/python-constraints.txtas a constraints file. CI checks that it has exact versions only. - Fallbacks favour working over exact. If the pinned Python versions won't install (say,
SteamOS moved to a newer Python),
pip_pinnedinstalls the newest ones with a warning. For adb and ydotool,runs_herechecks withlddthat a new ALARM build can load its libraries on this SteamOS; if it can't, the working copy already installed is kept.lib/tested-versionsrecords the scrcpy, android-tools and ydotool versions this release was tested with, and SteamOS's glibc and libstdc++. Android Mirror'sphone-touchenforces the scrcpy major version (SCRCPY_TESTED): with an untested one, its VR extras switch off and the mirror stays a plain window. - Upstream check. A weekly workflow (
upstream.yml,dev/check-upstream.sh) keeps one issue labelledupstream-checkup to date. It lists what needs attention (a pinned file gone or changed, a missing mirror copy, an ALARM package gone or needing a newer glibc than SteamOS has, a new scrcpy major version) and what is worth a look (newer versions). It re-enables its own schedule each run, because GitHub disables scheduled workflows in repositories without recent commits. - Linked projects. A daily workflow (
linked.yml,dev/check-links.py) keeps an issue labelledlinked-checkfor the projects Frame Tweaks runs the installers of: a release newer than the one last looked at (lib/tested-versions), or a README that no longer names the file the link's status checks. - Bumping.
dev/bump-pin.pymoves a pin, or the Python constraints, to upstream's newest version. It computes the SHA256 itself, cross-checks it against a published.sha256when there is one, and prints the upstream changes to read. Nothing is committed automatically; the bump is tested and committed on its own. - CI actions are pinned to commit SHAs.
Failing closed around SteamVR internals
Tweaks that rely on SteamVR's internals have an experimental: text in their manifest. They
are marked 🧪 in the README, in the window and in ./install.sh list, and the text says what
happens when SteamVR changes.
The dashboard tweaks share
vr-dashboard. Its sft-dashboard turns on SteamVR's
dashboard DevTools port (127.0.0.1:8087) through a steamvr.service drop-in, and only while
SteamVR is stopped, because SteamVR rewrites steamvr.vrsettings from memory. The previous
value is put back after SteamVR stops. While SteamVR runs, a watcher injects each installed
patch into the dashboard page every 5 seconds, again after a reload.
// requires:markers. Each patch, and the sharedlib.js, starts with lines like// requires: what | exact text, naming each piece of SteamVR's minified dashboard code it relies on. Before injecting, the watcher searches the installed dashboard files for every marker. If any is missing, that patch is not injected, and its status says "SteamVR changed (missing: …); off". A patch is never run against code it doesn't recognise.sft-dashboard checkruns the same comparison without SteamVR running.vr-dashboard'scheck-system.shuses it, and so does every update.tests/test_vr_dashboard.pyfinds every patch that an officialinstall.shinstalls and checks its markers against the installed SteamVR when one is present (skipped elsewhere).- Scope. The DevTools port has no password while it is open. SECURITY.md says so, and the installer window shows it before the tweak is ticked.
Other tweaks that rely on internals use check-system.sh the same way (for example
curvature, arrowpad, dictation).
Safety rules that shaped the design
- Dry runs for anything that removes. In the installer window, an unticked row means
"remove".
FRAME_TWEAKS_DRY_RUN=1makes the window (Apply, ▶ and the Wallet apps page's buttons) and the kdialog checklist print the plan instead of running it; what the window saves for itself (the language chosen in ⚙, a pack trusted or turned off) is still saved. CONTRIBUTING.md says never to test the window or uninstall paths against a real setup without it and a stubkonsole. The kdialog checklist also refuses to remove anything when nothing came back selected, so a dialog that fails can't wipe a setup, and it asks again before any removal. - Changes run where they can be seen. The window starts installs and removals in a Konsole
window through
./install.sh, the same path as the terminal, with its lock and log. - Pages, not dialogs, in VR. In VR every new window opens in the dashboard, away from the
installer. Details, settings, packs, messages and even crash reports are pages or
in-window messages of the main window (
gui/frame-tweaks-gui.py). The window runs on a copy of the venv's Python namedsft-python, because SteamVR keys controller bindings by program name and Arrow Pad also runs on Python. - Don't push the compositor. Arrow Pad's VR overlay sends its picture with
SetOverlayFromFile, notSetOverlayRaw: on the Frame, raw uploads never showed, and a 2.5 MB raw upload crashed vrcompositor (SIGBUS). Picture size and update rate are capped (vroverlay.py). - Wallet gates go through launchers. The wallet tweak protects apps by wrapping their menu
entries. So
run.shscripts start apps withgtk-launch <id>(through the menu entry), andinstall_desktopre-applies the wrapper when a tweak reinstalls an entry it guards. - Never send Right Ctrl. It toggles KWin's input grab on the nested desktop.
clear_stuck_modifiersinlib/common.shreleases Shift, Ctrl, Alt and Super, but never Right Ctrl; nor doesxkb.py let-go, which Dictation runs before typing to release only the modifiers held down on ydotoold's keyboard (read from its device,EVIOCGKEY). - Sandboxed services. Frame Tweaks' own user services use systemd sandboxing where it doesn't break them (see SECURITY.md).
Testing and CI
ci.yml runs on every push to main and on pull requests, in two
jobs side by side: the unit tests, and every other check. Each check is one of
dev/check.sh's, which a contributor runs the same way (on a Frame, VR first:
at the lowest priority, and on one core while the headset is worn, dev/run-tests.py):
shellcheck -x -S warningon every shell script (and the sourced libraries, by their# shellcheck shell=line),py_compileon every Python file, and Ruff's mistake-finding rules on every Python file (ruff.toml: names that don't exist, unused imports; no style).- Commit message format (
dev/check-commit-msg.sh, commitizen's Conventional Commits rule). The same check runs in thecommit-msghook (dev/install-hooks.shturns the hooks on), which can also refuse tool attribution lines in your own commits if you opt in (dev/check-trailers.sh). - Manifest and installer self-checks:
lib/sft.py mods checkon each official pack and the example,./install.sh list, dependency resolution, every pin's fields, mirror lines, and exact versions in the Python constraints. - Unit tests (
tests/), several at a time (dev/run-tests.py), with Qt headless (QT_QPA_PLATFORM=offscreen) and Xvfb for the click test. They cover the manifest reader against PyYAML, pack trust and replacement, settings shared with the VR panels, the window's logic (versions, channels, status parsing), shell helpers (including the mirror fallback andalarm_fetch's signature, name and downgrade checks with a throwaway key: these need Arch'svercmp, so they run on a Frame and are skipped on CI's Ubuntu), the dashboard patcher,lib/browser.pyand parts of individual tweaks. - AppStream metadata and desktop entry validation.
- Docs:
dev/check-docs.py(every tweak has a README row, relative links resolve) anddev/gen-docs.py --check. dev/test-tweaks.sh: install, status and uninstall for each tweak whose manifest saystest: {offline: true}, plus the example pack, each in a throwaway home. It uses stand-ins for systemctl, flatpak, brew, kdialog, gpgv and others, and a fakecurlthat serves fixtures and refuses every other URL; where user namespaces allow, the whole run has no network at all (unshare -cn). It fails if a step fails, if a tweak writes outside the fake home (or into its copy of the repo), or if uninstall leaves files other than those the manifest'stest: keeps:lists.
What isn't tested automatically:
- Anything in the headset: overlays, dashboard patches in a running SteamVR, controller input, the two-session behaviour. CONTRIBUTING.md asks for VR tweaks to be tested in the headset before a pin bump.
- Tweaks without
test: {offline: true}, about half of the official pack: those that need the network, Flatpak, a phone or SteamVR. - Real downloads in CI (the weekly upstream check covers availability).
- Only official and example tweaks run in CI; user packs are checked only by
mods check. - Everything has been tested on one Frame and one SteamOS and SteamVR build (README, Tested on).
Known limitations
- Single device. Other SteamOS devices and versions aren't tested;
lib/common.shonly warns off aarch64. - SteamVR updates switch off 🧪 tweaks until a Frame Tweaks update catches up. They fail closed, but they stop working.
- Local programs. While SteamVR runs, the dashboard DevTools port is open to any program on the Frame, Flatpak apps included. The same goes for ydotool's virtual keyboard while it runs. SECURITY.md's threat model is casual access, not malware running as the user.
- Homebrew and Python packages aren't hash-pinned. Homebrew formulae follow Homebrew; Python packages are pinned by version only, and may fall back to the newest versions.
- Untrusting a pack doesn't uninstall its tweaks, and
install.shonly knows tweaks from packs in use. To remove one, trust the pack again first. - Status is per tweak. "Installed" means
status.shdoesn't exit 1 (or does without saying "missing" or "restart", for a tweak with a version record). A tweak whosestatus.shis wrong is updated or skipped wrongly. - Not done yet (from roadmap.md): rate-limit messages for update hints.
Research notes
Some decisions rest on measurements on a Frame and on published research. Those are recorded in
docs/research/, with what was measured, what was read, and when to look again:
- Battery charge limit: why the charger's pause switch is the only control that works, why the battery still drains a little while paused, and why charging resumes 1% below the limit and limits go down to 74% (micro-cycling studies).
- Mouse cursor fix: why it isn't offered from gamescope 3.16.30 (SteamOS 0.5.6), where SteamVR draws desktop mode's pointer itself.
Considered and rejected
- Installing into the system (
steamos-readonly disableand pacman). SteamOS updates wipe/usr, so this would need re-doing after each update. Home-folder installs, Flatpaks or config in/etcare used instead (README, Good to know about the Frame; roadmap, Not adopting). - Nix. steam-frame-nix offers declarative Home Manager modules. Frame Tweaks chose a checklist installer without Nix; the wallet tweak adapts steam-frame-nix's launcher approach instead (README, How this compares).
- A root service and root-only tweaks (SDWEAK, Btrfs changes): nothing here runs as root apart from Homebrew's one-time folder setup (roadmap, Not adopting).
- Updates that reset the checkout (
git reset --hard): updates check out release tags, or fast-forward a developer branch, so local changes are never thrown away silently (roadmap, Not adopting). - A testing channel of pull-request builds (as Decky has): only tagged releases reach users; the beta channel takes tagged pre-releases (roadmap, Not adopting).
- Holding the battery still with the charger's current setting (Battery charge limit):
constant_charge_currentandcharging_enableare accepted but ignored on SteamOS 0.5.6, and the charging mode they don't apply in (QPH2) belongs to Valve's charger service (research). - PyYAML for manifests. SteamOS's Python may not have it;
lib/manifest.pyreads a strict subset with no dependency, and the tests compare it with PyYAML. - Following
maininget.shandupdate. Both install release tags since 0.3.0 (CHANGELOG). SetOverlayRawfor Arrow Pad's VR texture: invisible on the Frame, and it crashed the compositor (see Safety rules).- Dialogs in the installer window: they would open elsewhere in the VR dashboard (see Safety rules).
- Homebrew's Rust for building clipboard-sync: 3.4 GB with its LLVM. A pinned Rust toolchain in a temporary folder replaced it, and then a prebuilt binary: Frame Tweaks' fork of clipboard-sync builds it in CI (CHANGELOG).