Add Steam FEX launcher for Apple Silicon (aarch64)

Package the tested Asahi Steam launcher as omarchy-steam-fex, with
runtime-only dependencies and offline launcher checks in makepkg.
Preserve the original launcher behavior and document its runtime
requirements and downstream ownership handoff.

Co-authored-by: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com>
Co-authored-by: Santeri Hernejärvi <santeri@santeri.se>
This commit is contained in:
authored and Ryan Hughes committed 2026-09-20 13:45:47 -04:00
1 parent 3553c690a6
commit 534f007d6b
6 files changed
+371

No files matched your search

@@ -0,0 +1,3 @@
{
"source": "local"
}
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) David Heinemeier Hansson
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
pkgname=omarchy-steam-fex
pkgver=1.0.0
pkgrel=2
pkgdesc='Steam launcher with login workarounds for Apple Silicon using muvm and FEX'
arch=('aarch64')
url='https://github.com/omacom/omarchy-pkgs/tree/master/pkgbuilds/omarchy-steam-fex'
license=('MIT')
checkdepends=('python')
source=('omarchy-launch-steam' 'LICENSE' 'test-launcher.py')
sha256sums=('d89559ef88b3589e7d972b44a4a29e418605542f44f2756afd24ed3ce3227609'
'717ba1949502290f8e47688ae2e323acd06c8ca47aec9f7596b15f678c1af4a2'
'1d31b9ed09292b79dc86d9d4b3fff01d19e8210fdc5e3b13e78b846671808ed5')
check() {
python test-launcher.py
}
package() {
# These are runtime-only dependencies; the Asahi stack is not needed to
# assemble or test the scripts in a standard Arch Linux ARM builder.
depends=('bash' 'coreutils' 'python' 'steam' 'muvm' 'FEX-Emu')
install -Dm755 omarchy-launch-steam "$pkgdir/usr/bin/omarchy-launch-steam"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+17
View File
@@ -0,0 +1,17 @@
# Steam launcher for Apple Silicon
`omarchy-steam-fex` provides `omarchy-launch-steam` for the Asahi Linux `steam`, `muvm`, and `FEX-Emu` stack. Those runtime packages come from `asahi-alarm`; `FEX-Emu` provides `FEXBash`. The package is restricted to aarch64 and assumes that stack's `~/.local/share/fex-steam/steam-launcher/bin_steam.sh` layout.
The launcher runs Steam through `muvm` and `FEXBash` with the CEF occlusion workaround. Once Steam's client files are present, it also disables bootstrap verification and repair and patches the Steam UI network initialization block that can leave login waiting indefinitely. Initial bootstrap keeps the normal bootstrap flags. If the FEX launcher is unavailable, it falls back to `steam`.
`omarchy-launch-steam --prepare` writes the current user's desktop override with `Exec=omarchy-launch-steam %U` and applies the same UI patch. Each matching chunk is backed up as `.omarchy-bak` before its first patch; existing backups are preserved, and already-patched and unrecognized chunks are left unchanged. The regex depends on Valve's client code and may need updating when that code changes.
Only the launcher and license are installed. Steam's system desktop entry and downloaded client files remain outside this package's ownership; preparation runs as the desktop user, never in a package installation hook. A downstream Omarchy package that already owns the launcher must release that path before this package is installed, or in the same upgrade transaction.
Runtime dependencies are declared in `package()` so assembling the scripts does not require the Asahi stack in the build container. They remain required dependencies in the resulting package. `check()` runs the offline launcher tests using temporary homes and mocked Steam/muvm commands. To run them directly:
```sh
python pkgbuilds/omarchy-steam-fex/test-launcher.py
```
The launcher was extracted from [omarchy-mx-mac commit 5e8e1188](https://github.com/scottjones/omarchy-mx-mac/commit/5e8e1188e39fae70cf4f7bda1a1d85d66eccae51), credited to Scott Jones, dl-alexandre, and Santeri Hernejärvi. The extraction replaces `omarchy-cmd-present` with `command -v`, removing the dependency on Omarchy itself. The launcher matches the version published in [omarchy-pkgs-aarch64](https://github.com/omarchy-mac/omarchy-pkgs-aarch64/tree/main/pkgbuilds/omarchy-steam-fex).
+110
View File
@@ -0,0 +1,110 @@
#!/bin/bash
# omarchy:summary=Launch Steam, applying Asahi muvm login workarounds on Apple Silicon
# omarchy:group=launch
# omarchy:args=[--prepare|steam-args...]
set -euo pipefail
steam_root="$HOME/.local/share/Steam"
write_steam_desktop() {
local app_dir="$HOME/.local/share/applications"
mkdir -p "$app_dir"
cat >"$app_dir/steam.desktop" <<'EOF'
[Desktop Entry]
Name=Steam
Comment=Application for managing and playing games on Steam
Exec=omarchy-launch-steam %U
Icon=steam
Terminal=false
Type=Application
Categories=Network;FileTransfer;Game;
MimeType=x-scheme-handler/steam;x-scheme-handler/steamlink;
EOF
}
steam_client_ready() {
[[ -d $steam_root/steamui ]] && compgen -G "$steam_root/*/steamui.so" >/dev/null
}
launch_fex_steam() {
local launcher="$1"
shift
exec muvm -- FEXBash -c 'exec "$@"' omarchy-steam "$launcher" "$@"
}
patch_steam_ui() {
local steamui="$steam_root/steamui"
[[ -d $steamui ]] || return 0
python3 - "$steamui" <<'PY'
import pathlib
import re
import sys
steamui = pathlib.Path(sys.argv[1])
pattern = re.compile(
r"const t=\(0,(\w+)\.(\w+)\)\(\"System\.Network\.RegisterForDeviceChanges\"\);"
r"t&&SteamClient\.System\.Network\.RegisterForDeviceChanges\(this\.OnNetworkDevicesChanged\),"
r"\(0,\1\.\2\)\(\"System\.Network\.GetProxyInfo\"\)&&SteamClient\.System\.Network\.GetProxyInfo\(\)\.then\(e=>this\.m_proxyInfo=e\),"
r"\(0,\1\.\2\)\(\"System\.Network\.RegisterForConnectivityTestChanges\"\)&&SteamClient\.System\.Network\.RegisterForConnectivityTestChanges\(this\.OnConnectivityTestStateChanged\),"
r"t\|\|\(this\.m_bIsAwaitingInitialNetworkState=!1\)"
)
replacement = (
r'const t=(0,\1.\2)("System.Network.RegisterForDeviceChanges")'
r'&&!!(SteamClient.System&&SteamClient.System.Network&&"function"==typeof SteamClient.System.Network.RegisterForDeviceChanges);'
r'try{t&&SteamClient.System.Network.RegisterForDeviceChanges(this.OnNetworkDevicesChanged)}catch(e){}'
r'try{(0,\1.\2)("System.Network.GetProxyInfo")&&SteamClient.System.Network.GetProxyInfo().then(e=>this.m_proxyInfo=e)}catch(e){}'
r'try{(0,\1.\2)("System.Network.RegisterForConnectivityTestChanges")&&SteamClient.System.Network.RegisterForConnectivityTestChanges(this.OnConnectivityTestStateChanged)}catch(e){}'
r'this.m_bIsAwaitingInitialNetworkState=!1,this.m_bIsConnectedToANetwork=!0'
)
for path in sorted(steamui.glob("chunk~*.js")):
text = path.read_text(errors="replace")
if "m_bIsConnectedToANetwork=!0" in text and "RegisterForDeviceChanges" in text:
continue
updated, count = pattern.subn(replacement, text, count=1)
if count != 1:
continue
backup = path.with_suffix(path.suffix + ".omarchy-bak")
if not backup.exists():
backup.write_text(text)
path.write_text(updated)
PY
}
prepare_asahi() {
[[ $(uname -m) == aarch64 ]] || return 0
write_steam_desktop
patch_steam_ui
}
if [[ ${1:-} == --prepare ]]; then
prepare_asahi
exit 0
fi
if [[ $(uname -m) == aarch64 ]] && command -v muvm >/dev/null && command -v FEXBash >/dev/null; then
launcher="$HOME/.local/share/fex-steam/steam-launcher/bin_steam.sh"
if [[ -f $launcher ]]; then
steam_args=(-cef-force-occlusion)
if steam_client_ready; then
prepare_asahi
steam_args+=(
-noverifyfiles
-nobootstrapupdate
-skipinitialbootstrap
-norepairfiles
)
else
write_steam_desktop
fi
launch_fex_steam "$launcher" "${steam_args[@]}" "$@"
fi
fi
exec steam "$@"
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Offline launcher tests: fake Steam/muvm/FEX, real Bash and Python, temporary HOME."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
LAUNCHER = Path(os.environ.get(
'STEAM_LAUNCHER_TEST_SCRIPT',
Path(__file__).with_name('omarchy-launch-steam'),
))
# A representative minified Steam network initialization block, including
# surrounding code that must survive the patch. Deliberately not a regex.
ORIGINAL = (
'before();const t=(0,Ab.cd)("System.Network.RegisterForDeviceChanges");'
't&&SteamClient.System.Network.RegisterForDeviceChanges(this.OnNetworkDevicesChanged),'
'(0,Ab.cd)("System.Network.GetProxyInfo")&&SteamClient.System.Network.GetProxyInfo().then(e=>this.m_proxyInfo=e),'
'(0,Ab.cd)("System.Network.RegisterForConnectivityTestChanges")&&SteamClient.System.Network.RegisterForConnectivityTestChanges(this.OnConnectivityTestStateChanged),'
't||(this.m_bIsAwaitingInitialNetworkState=!1);after();'
)
SKIP_BOOTSTRAP = ['-noverifyfiles', '-nobootstrapupdate', '-skipinitialbootstrap', '-norepairfiles']
MOCK = '''
import json, os
from pathlib import Path
import shutil, sys
name = Path(sys.argv[0]).name
if name == 'uname':
print(os.environ.get('TEST_ARCH', 'aarch64'))
sys.exit(0)
with open(os.environ['TEST_CALLS'], 'a') as log:
log.write(json.dumps([name, *sys.argv[1:]]) + '\\n')
if name == 'muvm':
assert sys.argv[1:3] == ['--', 'FEXBash']
os.execv(shutil.which('FEXBash'), sys.argv[2:])
if name == 'FEXBash':
os.execv('/bin/bash', ['/bin/bash', *sys.argv[1:]])
sys.exit(int(os.environ.get('TEST_EXIT', '0')))
'''
class SteamLauncherTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='steam-fex-test-')
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.user_home = self.root / 'home with spaces'
self.user_home.mkdir()
self.tools = self.root / 'tools'
self.tools.mkdir()
self.calls_path = self.root / 'calls.jsonl'
self.env = dict(os.environ, HOME=str(self.user_home), PATH=str(self.tools),
TEST_CALLS=str(self.calls_path), TEST_ARCH='aarch64', TEST_EXIT='0')
for name in ['mkdir', 'cat', 'python3']:
(self.tools / name).symlink_to(shutil.which(name))
for name in ['uname', 'muvm', 'FEXBash', 'steam']:
self.mock(self.tools / name)
self.fex_launcher = self.user_home / '.local/share/fex-steam/steam-launcher/bin_steam.sh'
self.mock(self.fex_launcher)
self.steam_root = self.user_home / '.local/share/Steam'
self.ui = self.steam_root / 'steamui'
self.desktop = self.user_home / '.local/share/applications/steam.desktop'
def mock(self, path):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f'#!{sys.executable}\n' + MOCK)
path.chmod(0o755)
def run_launcher(self, *args, expected=0):
result = subprocess.run(['/bin/bash', str(LAUNCHER), *args], env=self.env,
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, expected, result.stderr)
return [json.loads(line) for line in self.calls_path.read_text().splitlines()] if self.calls_path.exists() else []
def chunk(self, content=ORIGINAL, name='chunk~network.js'):
self.ui.mkdir(parents=True, exist_ok=True)
path = self.ui / name
path.write_text(content)
return path
def client_ready(self):
self.ui.mkdir(parents=True, exist_ok=True)
binary = self.steam_root / 'ubuntu12_64/steamui.so'
binary.parent.mkdir(parents=True, exist_ok=True)
binary.touch()
def assert_desktop(self):
text = self.desktop.read_text()
self.assertIn('\nExec=omarchy-launch-steam %U\n', text)
self.assertIn('x-scheme-handler/steam;x-scheme-handler/steamlink;', text)
def assert_fex(self, calls, flags, user_args):
args = [str(self.fex_launcher), *flags, *user_args]
self.assertEqual(calls, [
['muvm', '--', 'FEXBash', '-c', 'exec "$@"', 'omarchy-steam', *args],
['FEXBash', '-c', 'exec "$@"', 'omarchy-steam', *args],
['bin_steam.sh', *flags, *user_args],
])
def test_prepare_without_client_only_writes_user_desktop(self):
self.assertEqual(self.run_launcher('--prepare'), [])
self.assert_desktop()
self.assertFalse(self.steam_root.exists())
def test_prepare_patches_matching_chunks_and_preserves_original(self):
for identifier in ['Ab.cd', '_A2.x9']:
with self.subTest(identifier=identifier):
original = ORIGINAL.replace('Ab.cd', identifier)
path = self.chunk(original, f'chunk~{identifier}.js')
self.assertEqual(self.run_launcher('--prepare'), [])
patched = path.read_text()
self.assertTrue(patched.startswith('before();'))
self.assertTrue(patched.endswith(';after();'))
self.assertIn(f'const t=(0,{identifier})("System.Network.RegisterForDeviceChanges")&&!!', patched)
self.assertEqual(patched.count('catch(e){}'), 3)
self.assertIn('this.m_bIsAwaitingInitialNetworkState=!1,this.m_bIsConnectedToANetwork=!0', patched)
self.assertEqual(path.with_suffix('.js.omarchy-bak').read_text(), original)
self.assert_desktop()
def test_repeated_prepare_is_idempotent_and_keeps_first_backup(self):
path = self.chunk()
self.run_launcher('--prepare')
patched, modified = path.read_bytes(), path.stat().st_mtime_ns
self.run_launcher('--prepare')
self.assertEqual(path.read_bytes(), patched)
self.assertEqual(path.stat().st_mtime_ns, modified)
path.write_text(ORIGINAL.replace('Ab.cd', 'New.api'))
self.run_launcher('--prepare')
self.assertIn('(0,New.api)', path.read_text())
self.assertEqual(path.with_suffix('.js.omarchy-bak').read_text(), ORIGINAL)
def test_unmatched_code_and_other_filenames_are_untouched(self):
for name, content in [('chunk~changed.js', 'otherNetworkCode();'), ('other.js', ORIGINAL)]:
path = self.chunk(content, name)
self.run_launcher('--prepare')
self.assertEqual(path.read_text(), content)
self.assertFalse(path.with_suffix('.js.omarchy-bak').exists())
def test_only_first_matching_block_is_patched(self):
path = self.chunk(ORIGINAL + ORIGINAL)
self.run_launcher('--prepare')
self.assertEqual(path.read_text().count('m_bIsConnectedToANetwork=!0'), 1)
self.assertIn(ORIGINAL, path.read_text())
def test_initial_launch_keeps_bootstrap_enabled_and_preserves_arguments(self):
args = ['steam://rungameid/123', 'argument with spaces', '$(touch unwanted)', '']
calls = self.run_launcher(*args)
self.assert_fex(calls, ['-cef-force-occlusion'], args)
self.assert_desktop()
self.assertFalse(self.steam_root.exists())
def test_ui_directory_alone_does_not_disable_bootstrap(self):
path = self.chunk()
self.assert_fex(self.run_launcher(), ['-cef-force-occlusion'], [])
self.assertEqual(path.read_text(), ORIGINAL)
def test_ready_launch_patches_and_disables_bootstrap(self):
path = self.chunk()
self.client_ready()
args = ['steam://open/main']
self.assert_fex(self.run_launcher(*args), ['-cef-force-occlusion', *SKIP_BOOTSTRAP], args)
self.assertIn('m_bIsConnectedToANetwork=!0', path.read_text())
self.assert_desktop()
def test_missing_fex_components_fall_back_without_modifying_user_files(self):
for missing in [self.tools / 'muvm', self.tools / 'FEXBash', self.fex_launcher]:
with self.subTest(missing=missing.name):
missing.unlink()
self.env['TEST_EXIT'] = '23'
self.assertEqual(self.run_launcher('arg with spaces', expected=23), [['steam', 'arg with spaces']])
self.assertFalse(self.desktop.exists())
self.calls_path.unlink()
self.mock(missing)
def test_x86_prepare_is_noop_and_launch_falls_back(self):
self.env['TEST_ARCH'] = 'x86_64'
path = self.chunk()
self.assertEqual(self.run_launcher('--prepare'), [])
self.assertFalse(self.desktop.exists())
self.assertEqual(path.read_text(), ORIGINAL)
self.assertEqual(self.run_launcher('steam://open/main'), [['steam', 'steam://open/main']])
def test_fex_exit_status_is_preserved(self):
self.env['TEST_EXIT'] = '29'
self.assert_fex(self.run_launcher(expected=29), ['-cef-force-occlusion'], [])
if __name__ == '__main__':
unittest.main()