diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b6a16cc..0070b1b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -46,4 +46,24 @@ Debian and Arch Linux ARM packages and additional Alpine packages are downloaded ## Source availability +The optional local SteamFrame image experiment preserves SteamOS and its +packages under their respective terms; the image is not included in the IPA. +Device preparation adds Arch Linux ARM x11vnc 0.9.17 and libvncserver 0.9.15 +(GPL-2.0-family terms, with per-file notices). Their upstream sources are +https://github.com/LibVNC/x11vnc/tree/0.9.17 and +https://github.com/LibVNC/libvncserver/tree/LibVNCServer-0.9.15. +The exact package hashes and helper build steps are retained in +`tools/steamos/prepare-device-image.py`. LinuxRunner's added guest helper +sources remain in `app/LinuxRunner/GUISupport.bundle`. Preserve component +notices and corresponding source when sharing any prepared guest image. + +The optional SteamOS graphics overlay contains a separate Mesa build at +`590bf21d918c86908d96d1f4590ecd25b9657171`, from +https://gitlab.freedesktop.org/mesa/mesa.git. It retains Mesa's license inventory +under `/opt/linuxrunner/steamos-gpu/share/licenses/mesa`, and its build identity +in `BUILD.json`. The graphics probe's source and repository licensing files +are included under `share/linuxrunner`. Packaging is defined by +`tools/steamos/package-gpu-overlay.py`; the source/build options and isolated +prefix are described in `docs/STEAMOS_EXPERIMENT.md`. + The application source, build scripts and dependency revisions are retained in the repository. [SOURCE_ORIGIN.txt](SOURCE_ORIGIN.txt) records the release lineage and exact dependency commits. Submodule contents are not included in GitHub's automatic source archives; initialize the pinned submodules when building from a clone. Source-distribution requirements for bundled binaries apply separately from the licenses of packages downloaded later by users. The release source asset must include the matching application snapshot, pinned build dependencies, graphics source/patches, matching Alpine recipes and checksum-verified inputs, and matching Ghostty Web/Ghostty source. The unchanged bundled Alpine root archive can use the already retained matching source inputs. Preserve the complete notices asset alongside the IPA. diff --git a/app/App.xcconfig b/app/App.xcconfig index f211d9d..6d9b280 100644 --- a/app/App.xcconfig +++ b/app/App.xcconfig @@ -1,12 +1,14 @@ #include "iOS.xcconfig" PRODUCT_NAME = LinuxKit +LINUXRUNNER_DISPLAY_NAME = $(PRODUCT_NAME) PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER) INFOPLIST_FILE = app/Info.plist INFOPLIST_PREPROCESS = YES INFOPLIST_PREFIX_HEADER = $(BUILT_PRODUCTS_DIR)/infoplisticons.h ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon -CODE_SIGN_ENTITLEMENTS = app/iSH.entitlements +LINUXRUNNER_CODE_SIGN_ENTITLEMENTS = app/iSH.entitlements +CODE_SIGN_ENTITLEMENTS = $(LINUXRUNNER_CODE_SIGN_ENTITLEMENTS) HEADER_SEARCH_PATHS = $(inherited) $(SRCROOT) $(SRCROOT)/deps/libarchive/libarchive // AccessibilityFixes.m contains a static constructor that we don't want removed diff --git a/app/AppDelegate.m b/app/AppDelegate.m index 1ddb3d1..158bd7e 100644 --- a/app/AppDelegate.m +++ b/app/AppDelegate.m @@ -291,7 +291,7 @@ - (int)boot { NSArray *command; command = UserPreferences.shared.bootCommand; #ifdef LINUXRUNNER - if ([LRRuntimeBridge activeEnvironmentIsDebian] || [LRRuntimeBridge activeEnvironmentIsArch]) command = @[@"/bin/sh", @"/usr/lib/linuxrunner/init.sh"]; + if ([LRRuntimeBridge activeEnvironmentIsDebian] || [LRRuntimeBridge activeEnvironmentIsArch] || [LRRuntimeBridge activeEnvironmentIsSteamOS]) command = @[@"/bin/sh", @"/usr/lib/linuxrunner/init.sh"]; #endif if (command.count == 0) { NSLog(@"boot command is empty"); diff --git a/app/Info.plist b/app/Info.plist index ce1d932..a759a5d 100644 --- a/app/Info.plist +++ b/app/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - $(PRODUCT_NAME) + $(LINUXRUNNER_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/app/LinuxRunner/GUISupport.bundle/desktop-components.sh b/app/LinuxRunner/GUISupport.bundle/desktop-components.sh index fd4d54a..3e62b9f 100644 --- a/app/LinuxRunner/GUISupport.bundle/desktop-components.sh +++ b/app/LinuxRunner/GUISupport.bundle/desktop-components.sh @@ -57,6 +57,39 @@ if [ "$environment" = plasma ]; then # password prompt over the desktop after a few idle minutes. First start of an # account only; a kscreenlockerrc the user already has is left as it is. plasma_config="${XDG_CONFIG_HOME:-$HOME/.config}" + if [ "$(sed -n 's/^ID=//p' /etc/os-release | tr -d '\"')" = steamos ]; then + export KWIN_COMPOSE=N + if [ "${LR_STEAMOS_GPU:-0}" = 1 ]; then + unset QT_QUICK_BACKEND QT_OPENGL LIBGL_ALWAYS_SOFTWARE + export QT_XCB_GL_INTEGRATION=xcb_egl QSG_RHI_BACKEND=opengl QSG_RHI_GL_USE_GLES=1 + else + export QT_XCB_GL_INTEGRATION=none QT_QUICK_BACKEND=software QT_OPENGL=software + fi + mkdir -p "$plasma_config/autostart" + /bin/sh ./steamos-fonts.sh || echo 'STEAMOS_FONTS: optional default unavailable; desktop startup continues' + if [ ! -e "$plasma_config/startkderc" ] && [ ! -L "$plasma_config/startkderc" ]; then + printf '[General]\nsystemdBoot=false\n' > "$plasma_config/startkderc" + fi + if [ ! -e "$plasma_config/autostart/steam.desktop" ] && [ ! -L "$plasma_config/autostart/steam.desktop" ]; then + printf '[Desktop Entry]\nType=Application\nHidden=true\n' > "$plasma_config/autostart/steam.desktop" + fi + # The milestone is a desktop and terminal, without system services or updates. + # Discover's Flatpak notifier crashes; Baloo reserves a 256 GiB index mapping; + # the image's Python firewall/IBus applets also fail and consume address space. + # XDG overrides apply to existing accounts too, preserving explicit choices. + # iPadOS owns power, display brightness and authentication; their Linux + # agents repeatedly query a system bus that this userspace does not provide. + for applet in org.kde.discover.notifier baloo_file firewall-applet ibus powerdevil polkit-kde-authentication-agent-1; do + if [ ! -e "$plasma_config/autostart/$applet.desktop" ] && [ ! -L "$plasma_config/autostart/$applet.desktop" ]; then + printf '[Desktop Entry]\nType=Application\nHidden=true\n' > "$plasma_config/autostart/$applet.desktop" + fi + done + # Tell KDE's Places model that indexing is disabled too. Merely suppressing + # the daemon leaves it trying to enumerate the unavailable tags:/ location. + if grep -qx 'Hidden=true' "$plasma_config/autostart/baloo_file.desktop" && command -v kwriteconfig6 >/dev/null; then + kwriteconfig6 --file baloofilerc --group 'Basic Settings' --key Indexing-Enabled false + fi + fi if mkdir -p "$plasma_config" && [ ! -e "$plasma_config/kscreenlockerrc" ]; then printf '[Daemon]\nAutolock=false\nLockOnResume=false\n' > "$plasma_config/kscreenlockerrc" && echo 'DESKTOP_STARTUP: plasma screen locker defaults written (autolock off; iOS locks the device)' fi diff --git a/app/LinuxRunner/GUISupport.bundle/desktop-profile.sh b/app/LinuxRunner/GUISupport.bundle/desktop-profile.sh index 2c3a3d0..9d8a0b3 100644 --- a/app/LinuxRunner/GUISupport.bundle/desktop-profile.sh +++ b/app/LinuxRunner/GUISupport.bundle/desktop-profile.sh @@ -19,6 +19,7 @@ cd "$(dirname "$0")" . ./support-common.sh mode=${1:-check} distro=$(sed -n 's/^ID=//p' /etc/os-release 2>/dev/null | tr -d '"') +if [ "$distro" = steamos ]; then exec /bin/sh ./steamos-profile.sh "$@"; fi have() { command -v "$1" >/dev/null 2>&1; } helper_key() { { cat support-version; sha256sum "$@"; } | sha256sum | awk '{print $1}'; } diff --git a/app/LinuxRunner/GUISupport.bundle/package-manager.sh b/app/LinuxRunner/GUISupport.bundle/package-manager.sh index 6700933..29c908a 100644 --- a/app/LinuxRunner/GUISupport.bundle/package-manager.sh +++ b/app/LinuxRunner/GUISupport.bundle/package-manager.sh @@ -23,6 +23,11 @@ if command -v apt-get >/dev/null; then distro=debian elif command -v pacman >/dev/null; then distro=archarm elif command -v apk >/dev/null; then distro=alpine else echo 'PACKAGE_OPERATION: unsupported distribution'; exit 2; fi +if [ "$(sed -n 's/^ID=//p' /etc/os-release | tr -d '\"')" = steamos ]; then + case "$operation" in installed|details|diagnose|dependencies) ;; + *) echo 'PACKAGE_OPERATION: The SteamOS image experiment supports installed-package inspection. Package changes and repository updates are not enabled for this prepared image.'; exit 2;; + esac +fi printf 'operation=%s\ndistribution=%s\nstarted=%s\n' "$operation" "$distro" "$(date +%s)" > package-interrupted printf 'running\n' > package-result rm -f package-state.tsv.new package-details.txt.new diff --git a/app/LinuxRunner/GUISupport.bundle/session.sh b/app/LinuxRunner/GUISupport.bundle/session.sh index d67fafa..3466df2 100755 --- a/app/LinuxRunner/GUISupport.bundle/session.sh +++ b/app/LinuxRunner/GUISupport.bundle/session.sh @@ -57,7 +57,24 @@ if [ "$(id -u)" = 0 ] && [ "$guest_user" != root ]; then [ -c /dev/dri/card1 ] || mknod -m 660 /dev/dri/card1 c 226 1 if (exec 3<>/dev/dri/card1) 2>/dev/null; then kms_desktop=wayfire-gpu; else rm -f /dev/dri/card1; fi fi - if [ "$mode" = desktop ] && { [ "$kms_desktop" = hyprland ] || [ "$kms_desktop" = wayfire-gpu ]; } && command -v seatd >/dev/null; then + if [ "$mode" = desktop ] && [ "$kms_desktop" = plasma ] && + [ "$(sed -n 's/^ID=//p' /etc/os-release | tr -d '\"')" = steamos ]; then + /bin/sh ./steamos-desktop.sh tags-policy "$guest_user" || echo 'STEAMOS_TAGS: optional workaround unavailable; custom paths preserved, desktop startup continues' + rm -f /dev/dri/card0 /dev/dri/renderD128 + mkdir -p /dev/dri + [ -c /dev/dri/card1 ] || mknod -m 660 /dev/dri/card1 c 226 1 + [ -c /dev/dri/renderD129 ] || mknod -m 660 /dev/dri/renderD129 c 226 129 + if (exec 3<>/dev/dri/card1) 2>/dev/null && [ -x /usr/lib/Xorg ] && + /bin/sh ./steamos-gpu.sh; then + chown "root:$gid" /dev/dri/card1 /dev/dri/renderD129 + chmod 660 /dev/dri/card1 /dev/dri/renderD129 + printf 'enabled\n' > "$session/steamos-gpu" + echo 'GUI_SESSION: SteamOS gpu=virgl-metal (Xorg glamor and Qt; KWin effects off)' + else + rm -f /dev/dri/card1 /dev/dri/renderD129 + echo 'GUI_SESSION: SteamOS gpu=software (GPU device or overlay unavailable)' + fi + elif [ "$mode" = desktop ] && { [ "$kms_desktop" = hyprland ] || [ "$kms_desktop" = wayfire-gpu ]; } && command -v seatd >/dev/null; then mkdir -p /dev/dri /run/udev/data [ -c /dev/dri/card0 ] || mknod -m 660 /dev/dri/card0 c 226 0 [ -c /dev/dri/renderD128 ] || mknod -m 666 /dev/dri/renderD128 c 226 128 @@ -166,6 +183,11 @@ export DISPLAY=:$display # Debian Mesa needs server GLX for real desktop OpenGL contexts. This is # software rendering, independent of optional Metal framebuffer presentation. distro=$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"') +if [ "$distro" = steamos ]; then + # Breeze's QWidget painter crashed in the supplied image. Fusion keeps + # both direct children and D-Bus-activated Qt applications on one style. + export QT_STYLE_OVERRIDE=Fusion +fi # Session graphics default chosen in Settings; a launcher's own # LR_GUI_GPU_MODE (command.sh) takes precedence for that application. gpu_mode=mesa @@ -177,6 +199,15 @@ desktop_environment=linuxrunner [ ! -f "$session/desktop-environment" ] || desktop_environment=$(cat "$session/desktop-environment") case "$desktop_environment" in linuxrunner|plasma|lxqt|enlightenment|wayfire|hyprland) ;; *) desktop_environment=linuxrunner;; esac export LR_DESKTOP_ENVIRONMENT="$desktop_environment" +steamos_gpu=0 +if [ "$distro" = steamos ] && [ "$mode" = desktop ] && [ "$desktop_environment" = plasma ] && + [ "$(cat "$session/steamos-gpu" 2>/dev/null || true)" = enabled ] && [ -c /dev/dri/card1 ]; then + steamos_gpu=1 + export LD_LIBRARY_PATH=/opt/linuxrunner/steamos-gpu/lib + export LIBGL_DRIVERS_PATH=/opt/linuxrunner/steamos-gpu/lib/dri + export __EGL_VENDOR_LIBRARY_FILENAMES=/opt/linuxrunner/steamos-gpu/share/glvnd/egl_vendor.d/50_mesa.json + export MESA_LOADER_DRIVER_OVERRIDE=virtio_gpu GALLIUM_DRIVER=virgl LR_STEAMOS_GPU=1 +fi glx_mode=off [ "$distro" != debian ] || glx_mode=software [ ! -f "$session/glx-mode" ] || glx_mode=$(cat "$session/glx-mode") @@ -204,6 +235,17 @@ export QT_QPA_PLATFORM=${QT_QPA_PLATFORM:-xcb} export QT_FORCE_STDERR_LOGGING=${QT_FORCE_STDERR_LOGGING:-1} export QT_WIDGETS_RHI=${QT_WIDGETS_RHI:-0} QT_QUICK_BACKEND=${QT_QUICK_BACKEND:-software} export QT_OPENGL=${QT_OPENGL:-software} LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1} +if [ "$steamos_gpu" = 1 ]; then + unset LIBGL_ALWAYS_SOFTWARE QT_QUICK_BACKEND QT_OPENGL + export QT_XCB_GL_INTEGRATION=xcb_egl QSG_RHI_BACKEND=opengl QSG_RHI_GL_USE_GLES=1 QSG_INFO=1 +fi +steamos_software_fallback() { + steamos_gpu=0 + unset LD_LIBRARY_PATH LIBGL_DRIVERS_PATH __EGL_VENDOR_LIBRARY_FILENAMES MESA_LOADER_DRIVER_OVERRIDE GALLIUM_DRIVER LR_STEAMOS_GPU + unset QSG_RHI_BACKEND QSG_RHI_GL_USE_GLES QSG_INFO + export QT_QUICK_BACKEND=software QT_OPENGL=software LIBGL_ALWAYS_SOFTWARE=1 QT_XCB_GL_INTEGRATION=none + echo 'GUI_SESSION: SteamOS GPU check failed; starting the software desktop (see graphics.log and xorg.log)' +} # A fresh X server belongs to this guest root; DISPLAY is session-scoped. width=640 height=480 if [ -f "$session/geometry" ]; then @@ -213,12 +255,24 @@ if [ -f "$session/geometry" ]; then width=${geometry%x*};height=${geometry#*x} if [ "$width" -lt 320 ] || [ "$width" -gt 2560 ] || [ "$height" -lt 240 ] || [ "$height" -gt 1600 ] || [ $((width % 2)) -ne 0 ] || [ $((height % 2)) -ne 0 ]; then echo 'GUI_SESSION: invalid display geometry'; exit 2; fi fi +if [ "$steamos_gpu" = 1 ] && ! /opt/linuxrunner/steamos-gpu/bin/graphics-check > "$session/graphics.log" 2>&1; then + steamos_software_fallback +fi export XAUTHORITY="$session/authority" umask 077 xauth -f "$XAUTHORITY" add "$DISPLAY" . "$(cat "$session/cookie")" -echo "GUI_SESSION: root=${LINUXRUNNER_ENVIRONMENT:-current} distribution=$distro DISPLAY=$DISPLAY geometry=${width}x${height} backend=xvfb-rfb interpreter-baseline" -echo "GUI_SESSION: starting X.Org software display, ${width}x${height}, no JIT" -Xvfb "$DISPLAY" -screen 0 "${width}x${height}x24" -nolisten tcp -auth "$XAUTHORITY" "$shm_switch" MIT-SHM "$glx_switch" GLX > "$session/xvfb.log" 2>&1 & xpid=$! +echo "GUI_SESSION: root=${LINUXRUNNER_ENVIRONMENT:-current} distribution=$distro DISPLAY=$DISPLAY geometry=${width}x${height} backend=$([ "$steamos_gpu" = 1 ] && echo xorg-virgl-rfb || echo xvfb-rfb) interpreter-baseline" +if [ "$steamos_gpu" = 1 ]; then + cp ./steamos-xorg.conf "$session/xorg.conf" + echo 'xorg-virgl' > "$session/display-backend" + echo "GUI_SESSION: starting Xorg modesetting/glamor on virgl/Metal, ${width}x${height}, no JIT" + /usr/lib/Xorg "$DISPLAY" -config "$session/xorg.conf" -logfile "$session/xorg.log" -noreset -nolisten tcp \ + -novtswitch -sharevts -auth "$XAUTHORITY" > "$session/xvfb.log" 2>&1 & xpid=$! +else + echo 'xvfb-software' > "$session/display-backend" + echo "GUI_SESSION: starting X.Org software display, ${width}x${height}, no JIT" + Xvfb "$DISPLAY" -screen 0 "${width}x${height}x24" -nolisten tcp -auth "$XAUTHORITY" "$shm_switch" MIT-SHM "$glx_switch" GLX > "$session/xvfb.log" 2>&1 & xpid=$! +fi i=0 while [ ! -S "/tmp/.X11-unix/X$display" ]; do [ ! -f "$session/stop" ] || exit 0 @@ -245,7 +299,7 @@ xmodmap -pm >> "$session/keymap.log" 2>&1 || true xkb=-xkb if [ -f "$session/xkb-mode" ] && [ "$(cat "$session/xkb-mode")" = noxkb ]; then xkb=-noxkb; fi focus_policy=managed; [ "$mode" != desktop ] || focus_policy=window-manager -echo "GUI_SESSION: keyboard=$xkb focus=$focus_policy encoding=Hextile/Raw software-rendering" +echo "GUI_SESSION: keyboard=$xkb focus=$focus_policy encoding=Hextile/Raw renderer=$([ "$steamos_gpu" = 1 ] && echo virgl-metal || echo software)" # A Linux sockaddr_un path holds at most 107 bytes. A support-directory UUID # path plus GVFS/Qt socket suffix exceeds it. Use a uniquely owned short path # for every GUI mode, with restrictive permissions and no preexisting reuse. @@ -271,6 +325,11 @@ if command -v dbus-daemon >/dev/null; then if dbus-update-activation-environment DISPLAY XAUTHORITY XDG_RUNTIME_DIR HOME PATH DBUS_SESSION_BUS_ADDRESS; then echo 'GUI_SESSION: dbus_activation_environment=PASS (explicit session variables, no systemd required)' else echo 'GUI_SESSION: dbus_activation_environment=FAIL; inspect bus.log'; fi + if [ "$steamos_gpu" = 1 ]; then + dbus-update-activation-environment LD_LIBRARY_PATH LIBGL_DRIVERS_PATH __EGL_VENDOR_LIBRARY_FILENAMES \ + MESA_LOADER_DRIVER_OVERRIDE GALLIUM_DRIVER LR_STEAMOS_GPU QT_STYLE_OVERRIDE \ + QT_XCB_GL_INTEGRATION QSG_RHI_BACKEND QSG_RHI_GL_USE_GLES || true + fi fi;; *) echo 'GUI_SESSION: dbus_session=unavailable; inspect bus.log (applications still attempted)';; esac @@ -313,7 +372,9 @@ echo "GUI_INPUT: mode=$input_mode experimental=$([ "$input_mode" = eager ] && ec # autocomplete splice typed URLs (2 of 5 gate runs), so it stays opt-in. # The others are latency experiments. vnc_timing=default +[ "$steamos_gpu" != 1 ] || vnc_timing=adaptive [ ! -f "$session/vnc-timing" ] || vnc_timing=$(cat "$session/vnc-timing") +if [ "$vnc_timing" = default ] && [ "$steamos_gpu" = 1 ]; then vnc_timing=adaptive; fi case "$vnc_timing" in default|legacy) vnc_timing_options="-noxdamage -defer 16 -wait 25";; threads) vnc_timing_options="-noxdamage -defer 16 -wait 25 -threads";; diff --git a/app/LinuxRunner/GUISupport.bundle/steamos-desktop.sh b/app/LinuxRunner/GUISupport.bundle/steamos-desktop.sh new file mode 100644 index 0000000..3b1e302 --- /dev/null +++ b/app/LinuxRunner/GUISupport.bundle/steamos-desktop.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# Reversible SteamOS-only workaround for KIO 6.14 enumerating tags even when +# Baloo is disabled. Retain the original plugin outside Qt's plugin search tree. +set -eu +[ "$(id -u)" = 0 ] || { echo 'STEAMOS_TAGS: root-required'; exit 1; } +[ "$(sed -n 's/^ID=//p' /etc/os-release | tr -d '\"')" = steamos ] || exit 0 +plugin=/usr/lib/qt6/plugins/kf6/kio/tags.so +backup=/opt/linuxrunner/steamos-disabled-plugins +action=${1:-} +case "$action" in + tags-policy) + user=${2:-} + case "$user" in ''|*[!a-z0-9_-]*|[0-9-]*) exit 2;; esac + home=$(awk -F: -v name="$user" '$1==name {print $6; count++} END {if(count!=1)exit 1}' /etc/passwd) + case "$home" in /*) ;; *) exit 2;; esac + # No override means the managed desktop's default (Baloo off). Preserve an + # explicit account autostart override and put back our saved worker for it. + override="$home/.config/autostart/baloo_file.desktop" + action=disable-tags + if [ -e "$override" ] || [ -L "$override" ]; then + grep -qx 'Hidden=true' "$override" || action=restore-tags + fi + ;; + disable-tags|restore-tags) ;; + *) echo 'Usage: steamos-desktop.sh tags-policy USER | disable-tags | restore-tags'; exit 2;; +esac +for path in /opt /opt/linuxrunner "$backup" "$plugin" "$backup/tags.so"; do + [ ! -L "$path" ] || { echo 'STEAMOS_TAGS: symlink refused'; exit 1; } +done +if [ "$action" = disable-tags ]; then + if [ -f "$plugin" ]; then + mkdir -p "$backup" + # An unexpected second copy can be a package update; keep both untouched. + [ ! -e "$backup/tags.so" ] || { echo 'STEAMOS_TAGS: plugin changed; retained copies, no override'; exit 0; } + mv "$plugin" "$backup/tags.so" + fi + [ ! -f "$backup/tags.so" ] || echo 'STEAMOS_TAGS: unavailable worker disabled; original retained (restore-tags to undo)' +elif [ -f "$backup/tags.so" ]; then + [ ! -e "$plugin" ] || { echo 'STEAMOS_TAGS: package worker already present; backup retained'; exit 0; } + mv "$backup/tags.so" "$plugin" + echo 'STEAMOS_TAGS: original worker restored' +fi diff --git a/app/LinuxRunner/GUISupport.bundle/steamos-fonts.sh b/app/LinuxRunner/GUISupport.bundle/steamos-fonts.sh new file mode 100644 index 0000000..dc85eb2 --- /dev/null +++ b/app/LinuxRunner/GUISupport.bundle/steamos-fonts.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Managed default for a scaled virtual display, run as the selected Linux user. +# RGB subpixel antialiasing assumes a fixed physical RGB stripe arrangement; +# it produces colored edges when the framebuffer is resized or rotated. +set -eu +config=${XDG_CONFIG_HOME:-$HOME/.config} +file="$config/kdeglobals" +if [ -L "$file" ] || { [ -e "$file" ] && [ ! -f "$file" ]; }; then + echo 'STEAMOS_FONTS: custom configuration path preserved' + exit 0 +fi +# KConfig handles the write. Conservatively preserve any explicit account key, +# including immutable/expanded forms, even when it is in another group. +if [ -f "$file" ] && grep -Eq '^[[:space:]]*XftSubPixel(\[\$[ie]\])*[[:space:]]*=' "$file"; then + echo 'STEAMOS_FONTS: explicit subpixel preference preserved' + exit 0 +fi +if ! command -v kwriteconfig6 >/dev/null; then + echo 'STEAMOS_FONTS: configuration tool unavailable; defaults unchanged' + exit 0 +fi +mkdir -p "$config" || { echo 'STEAMOS_FONTS: configuration directory unavailable; defaults unchanged'; exit 0; } +if kwriteconfig6 --file "$file" --group General --key XftSubPixel none; then + echo 'STEAMOS_FONTS: grayscale antialiasing default for scaled framebuffer' +else + echo 'STEAMOS_FONTS: configuration write unavailable; defaults unchanged' +fi diff --git a/app/LinuxRunner/GUISupport.bundle/steamos-gpu.sh b/app/LinuxRunner/GUISupport.bundle/steamos-gpu.sh new file mode 100755 index 0000000..fd1c8c5 --- /dev/null +++ b/app/LinuxRunner/GUISupport.bundle/steamos-gpu.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Install the app-pinned, isolated driver into an existing SteamOS root. +# Called as root before the desktop changes to its selected Linux account. +set -eu +. "$(dirname "$0")/support-common.sh" +[ "$(id -u)" = 0 ] || support_fail GPU_OVERLAY root-required +[ "$(sed -n 's/^ID=//p' /etc/os-release | tr -d '\"')" = steamos ] || exit 1 +[ -f ./steamos-gpu.tar.gz ] && [ -f ./steamos-gpu.sha256 ] || { echo 'GPU_OVERLAY: unavailable; using software'; exit 1; } +verify_resources +sha256sum -c steamos-gpu.sha256 || support_fail GPU_OVERLAY archive-checksum +expected=$(awk 'NR==1 {print $1}' steamos-gpu.sha256) +case "$expected" in ''|*[!0-9a-f]*) support_fail GPU_OVERLAY invalid-digest;; esac +[ "${#expected}" = 64 ] || support_fail GPU_OVERLAY invalid-digest +prefix=/opt/linuxrunner/steamos-gpu +for directory in /opt /opt/linuxrunner "$prefix"; do + [ ! -L "$directory" ] || support_fail GPU_OVERLAY symbolic-link-prefix + [ ! -e "$directory" ] || { [ -d "$directory" ] && [ "$(stat -c %u "$directory")" = 0 ]; } || support_fail GPU_OVERLAY unmanaged-prefix +done +if [ "$(cat "$prefix/archive.sha256" 2>/dev/null || true)" = "$expected" ] && + (cd "$prefix" && sha256sum -c payload.sha256 >/dev/null 2>&1); then + echo 'GPU_OVERLAY: ready (isolated Mesa virgl)'; exit 0 +fi +umask 022 +mkdir -p /opt/linuxrunner +stage=$(mktemp -d /opt/linuxrunner/.steamos-gpu.XXXXXXXX) +cleanup() { + if [ -d "$stage/previous" ] && [ ! -e "$prefix" ]; then mv "$stage/previous" "$prefix" || true; fi + rm -rf "$stage" +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM +tar -xzf ./steamos-gpu.tar.gz -C "$stage" +[ -d "$stage/steamos-gpu" ] && [ ! -L "$stage/steamos-gpu" ] || support_fail GPU_OVERLAY invalid-payload +(cd "$stage/steamos-gpu" && sha256sum -c payload.sha256) || support_fail GPU_OVERLAY payload-checksum +printf '%s\n' "$expected" > "$stage/steamos-gpu/archive.sha256" +[ ! -e "$prefix" ] || mv "$prefix" "$stage/previous" +mv "$stage/steamos-gpu" "$prefix" +echo 'GPU_OVERLAY: installed (system Mesa preserved)' diff --git a/app/LinuxRunner/GUISupport.bundle/steamos-gpu.sha256 b/app/LinuxRunner/GUISupport.bundle/steamos-gpu.sha256 new file mode 100644 index 0000000..92a432d --- /dev/null +++ b/app/LinuxRunner/GUISupport.bundle/steamos-gpu.sha256 @@ -0,0 +1 @@ +735a6caf4059821f61c71b09f7c99c9d9f05ec9bd473311fd299d36c54b6fe94 steamos-gpu.tar.gz diff --git a/app/LinuxRunner/GUISupport.bundle/steamos-gpu.tar.gz b/app/LinuxRunner/GUISupport.bundle/steamos-gpu.tar.gz new file mode 100644 index 0000000..e8dfc62 Binary files /dev/null and b/app/LinuxRunner/GUISupport.bundle/steamos-gpu.tar.gz differ diff --git a/app/LinuxRunner/GUISupport.bundle/steamos-profile.sh b/app/LinuxRunner/GUISupport.bundle/steamos-profile.sh new file mode 100644 index 0000000..c999ddc --- /dev/null +++ b/app/LinuxRunner/GUISupport.bundle/steamos-profile.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Offline checks for the explicitly prepared SteamFrame desktop image. +set -eu +. "$(dirname "$0")/support-common.sh" +verify_resources +mode=${1:-check} +prepared=/usr/lib/linuxrunner/prebuilt-gui +required='Xvfb x11vnc xauth fc-match readelf dbus-daemon startplasma-x11 kwin_x11 plasmashell konsole setxkbmap xmodmap' +missing= +for tool in $required; do command -v "$tool" >/dev/null || missing="${missing}${missing:+,}$tool"; done +[ -z "$missing" ] || { echo "PROFILE_FAILURE: category=STEAMOS_IMAGE_INCOMPLETE stage=desktop detail=missing:$missing; prepare and import the matching image again"; exit 1; } +case "$mode" in + repair|plasma) + [ "$(id -u)" = 0 ] || support_fail PROFILE root-required + echo 'PROFILE_STAGE: Preparing SteamOS display helpers' + (cd "$prepared" && sha256sum -c binaries.sha256) || support_fail PROFILE steamos-helper-integrity + # The prepared binaries must correspond to this build's helper sources. + sha256sum -c "$prepared/sources.sha256" || support_fail PROFILE steamos-helper-source-mismatch + for helper in gui-test focus-manager desktop-probe window-observer; do + verify_elf PROFILE "$prepared/$helper" + cp "$prepared/$helper" "$helper.new"; chmod 755 "$helper.new"; mv "$helper.new" "$helper" + done + { cat support-version; sha256sum gui-test.c focus-manager.c; pkg-config --modversion x11 xcb; } | sha256sum | awk '{print $1}' > gui-build.key + sha256sum desktop-probe.c | awk '{print $1}' > desktop-probe.key + sha256sum window-observer.c | awk '{print $1}' > window-observer.key + mkdir -p /var/lib/dbus + dbus-uuidgen --ensure + ;; + check) ;; + *) echo 'PROFILE_FAILURE: category=STEAMOS_EXPERIMENT stage=desktop detail=Only the prepared Plasma X11 profile is available'; exit 1;; +esac +missing= +for helper in gui-test focus-manager desktop-probe window-observer; do [ -x "$helper" ] || missing="${missing}${missing:+,}$helper"; done +if [ -z "$missing" ]; then + sha256sum -c "$prepared/sources.sha256" >/dev/null || missing=helper-source-mismatch +fi +{ + if [ -n "$missing" ]; then echo "missing=$missing"; else echo ready; fi + echo distribution=steamos + echo plasma=installed + echo optional-missing=audio +} > profile-check.result.tmp +mv profile-check.result.tmp profile-check.result +cat profile-check.result +[ -z "$missing" ] || [ "$mode" = check ] diff --git a/app/LinuxRunner/GUISupport.bundle/steamos-xorg.conf b/app/LinuxRunner/GUISupport.bundle/steamos-xorg.conf new file mode 100644 index 0000000..1b2b022 --- /dev/null +++ b/app/LinuxRunner/GUISupport.bundle/steamos-xorg.conf @@ -0,0 +1,20 @@ +Section "ServerFlags" + Option "AutoAddDevices" "false" + Option "AutoEnableDevices" "false" + Option "AutoAddGPU" "false" + Option "DontVTSwitch" "true" +EndSection +Section "Device" + Identifier "LinuxRunner" + Driver "modesetting" + Option "kmsdev" "/dev/dri/card1" + Option "AccelMethod" "glamor" + Option "SWcursor" "true" + Option "PageFlip" "false" + Option "UseGammaLUT" "false" +EndSection +Section "Screen" + Identifier "Screen0" + Device "LinuxRunner" + DefaultDepth 24 +EndSection diff --git a/app/LinuxRunner/GUISupport.bundle/support-manifest.json b/app/LinuxRunner/GUISupport.bundle/support-manifest.json index 2955b8a..9d25ed5 100644 --- a/app/LinuxRunner/GUISupport.bundle/support-manifest.json +++ b/app/LinuxRunner/GUISupport.bundle/support-manifest.json @@ -44,7 +44,7 @@ }, { "name": "desktop-components.sh", - "sha256": "70d3176a1d7b0ddc12a4df4906c05f5abaa5d4b3999a4bf66c60dbbb49e0f26c", + "sha256": "e93681d904bc1b4a9e1d05913e7a0dd68fbdf7ae19b959fe6c5e8c2fc83abf3f", "mode": "0755" }, { @@ -84,7 +84,7 @@ }, { "name": "desktop-profile.sh", - "sha256": "a5ca752fae6083c67680f5d194f2972c54971b982f2f04a4abe0c50bff8699c6", + "sha256": "fdec6a4c2523cf7b4c79e9d5156b709fe69675089f7c143877b206a872ce0d9e", "mode": "0755" }, { @@ -384,7 +384,7 @@ }, { "name": "package-manager.sh", - "sha256": "5c038bf464e587f3e98dc4618acba18ade6832baa76409191e30f92fba0fac1c", + "sha256": "a9787d2baeb7650e30202974abfa00b10c2a3a726f2b9997f052631dd0645cb6", "mode": "0755" }, { @@ -409,7 +409,7 @@ }, { "name": "session.sh", - "sha256": "7d33bc02a949ae221e24f9bf5861a8fb0238211b3c125d2f1d1a2bb46bd60ba0", + "sha256": "472bd8e4024788aed58cf7bc1411ffd92494854b4ff0f56130b40678cc4564ab", "mode": "0755" }, { @@ -422,6 +422,41 @@ "sha256": "ec262147b6c1ece5fd282b574b214764850f8995e7a1531507d0d9a8d5cb93d7", "mode": "0755" }, + { + "name": "steamos-desktop.sh", + "sha256": "3ee3207de0ed50a8fd4bc73d80c2212e8404af0ae0a7b8f58c47b8e1f2123395", + "mode": "0755" + }, + { + "name": "steamos-fonts.sh", + "sha256": "8bef9c042344898913611bc8ed9b6d8e4f8b38aa9dfc450f8173a52bd8e3ca3c", + "mode": "0755" + }, + { + "name": "steamos-gpu.sh", + "sha256": "972ff733f32ecf278044b5b072d84165ee5abae9dfab1abf897f091627f51102", + "mode": "0755" + }, + { + "name": "steamos-gpu.sha256", + "sha256": "ca61604c4811a024ef02659f8656a36326bc8f5502887f02d1d61b6d60a2db00", + "mode": "0644" + }, + { + "name": "steamos-gpu.tar.gz", + "sha256": "735a6caf4059821f61c71b09f7c99c9d9f05ec9bd473311fd299d36c54b6fe94", + "mode": "0644" + }, + { + "name": "steamos-profile.sh", + "sha256": "a452f14bf336dfafe5aa92aacfdba7a9a7be02aba0b4691890903c15aa3c2e59", + "mode": "0755" + }, + { + "name": "steamos-xorg.conf", + "sha256": "cc9c4f032d48cb50bcd28ef7aa9fa4e5ffe4c6c9ca085109ffc0400a08e6f9ea", + "mode": "0644" + }, { "name": "support-common.sh", "sha256": "9d4a856a31777ee7cd7ac6b2b0de9c97b86812616404d6d61e44edcabd167091", diff --git a/app/LinuxRunner/GUISupport.bundle/support.sha256 b/app/LinuxRunner/GUISupport.bundle/support.sha256 index f7500f7..97096c2 100644 --- a/app/LinuxRunner/GUISupport.bundle/support.sha256 +++ b/app/LinuxRunner/GUISupport.bundle/support.sha256 @@ -6,7 +6,7 @@ dfe7f6cdf71d6d9d28ac220c9f51afdf4bb00bda6c317f08a472c43d11a9e625 browser-launch 275c78611248479e6bace0043fea397bffd11b8240093e4f2f93925950df4094 browser-preflight.sh 0f7838c1c83aa92251bc94094c3dd90665c1b373268eb972178523a8d777ed82 browser-test.html 4431803d577dfa63f5932104b6aa30d78894953fd4b4aba97a8dee3a68ca94cc browser.cpp -70d3176a1d7b0ddc12a4df4906c05f5abaa5d4b3999a4bf66c60dbbb49e0f26c desktop-components.sh +e93681d904bc1b4a9e1d05913e7a0dd68fbdf7ae19b959fe6c5e8c2fc83abf3f desktop-components.sh e4d27e9ec745394525cab88950cd597013fbcd0b8964ba981aee0dc3083c8833 desktop-configure.sh 48d8ffa622aa05058d6379c0f7b298d6a69e44775510bab8c99dcb9da9df44e2 desktop-env.sh 74c0cec29f6e84ad8b3a8e5c184578e1f35be3b617ee0935d835ddcff8d5395b desktop-menu.sh @@ -14,7 +14,7 @@ e4d27e9ec745394525cab88950cd597013fbcd0b8964ba981aee0dc3083c8833 desktop-config 78e510fc71a2bd47a21014d11317251254e76d1a4115be38f3324392722889ac desktop-panel.conf 782e5db7fca985303666c11c481a6e4da5ef6aff4e6e0d7af129c36b71d9ebb8 desktop-pcmanfm.conf 7fdf67c4c34caa0e4608fe919244e32d20dd5ee01e35c6d2930d0d74541852ac desktop-probe.c -a5ca752fae6083c67680f5d194f2972c54971b982f2f04a4abe0c50bff8699c6 desktop-profile.sh +fdec6a4c2523cf7b4c79e9d5156b709fe69675089f7c143877b206a872ce0d9e desktop-profile.sh fdac5e075611812c9cdcd470e05f5e45855906aa2b83b87ef41888e32f77f3ae desktop-qt-panel.conf 9269a09a176f7ff42b2c186f5df9ddfcaeeb64e49953cb9f6eb48171f81611af desktop-report.sh 104ee86773ee0964cdaabfb08bf6a5d98be3492d6dc856c7e0e6ec9b713c4b20 desktop-theme.conf @@ -74,14 +74,21 @@ d6de26ecd9d157ccb3d3ea11d21a574f93b74960f846427a6ccd48c983685b51 linuxrunner-wa 40677bcc2959ac52e0ea328fcf4631607fc2516cdaafe0f0bc90b64e37ae6b3c mesa-demos-notice.txt 1187663ef8b61a243f9e124f36a844133be86438a98021a76e3d3e475a5124cb network-probe.c 513808ba93c9166d5497737516fb0ea342aef1268edb958e3b95455c44ce9a25 package-lock.sh -5c038bf464e587f3e98dc4618acba18ade6832baa76409191e30f92fba0fac1c package-manager.sh +a9787d2baeb7650e30202974abfa00b10c2a3a726f2b9997f052631dd0645cb6 package-manager.sh f19a0c59f860067490f167502d8e33bd0a810f45182d67c52dc3cbac6fcccdfe pacman-recover.sh b18cc4cebbf1e6b772495288b2eb3543c7036281742157ca73bdadaf9d826416 prepare-window-observer.sh 8b3a261e13944164f60f2618dae78373f56eb1338d3f731bd43ed0e9c0b110cb qt-test.cpp 5eb695730a4fc5b9cae5a0eb4efe099ffdd1129fb356629c2c2d2bd1b8e2adb2 regression.sh -7d33bc02a949ae221e24f9bf5861a8fb0238211b3c125d2f1d1a2bb46bd60ba0 session.sh +472bd8e4024788aed58cf7bc1411ffd92494854b4ff0f56130b40678cc4564ab session.sh fb402cc6ffa15e3975c6115e18292a070d66bfbba484fcab02b530da9751c669 shader-compiler-notices.txt ec262147b6c1ece5fd282b574b214764850f8995e7a1531507d0d9a8d5cb93d7 share-check.sh +3ee3207de0ed50a8fd4bc73d80c2212e8404af0ae0a7b8f58c47b8e1f2123395 steamos-desktop.sh +8bef9c042344898913611bc8ed9b6d8e4f8b38aa9dfc450f8173a52bd8e3ca3c steamos-fonts.sh +972ff733f32ecf278044b5b072d84165ee5abae9dfab1abf897f091627f51102 steamos-gpu.sh +ca61604c4811a024ef02659f8656a36326bc8f5502887f02d1d61b6d60a2db00 steamos-gpu.sha256 +735a6caf4059821f61c71b09f7c99c9d9f05ec9bd473311fd299d36c54b6fe94 steamos-gpu.tar.gz +a452f14bf336dfafe5aa92aacfdba7a9a7be02aba0b4691890903c15aa3c2e59 steamos-profile.sh +cc9c4f032d48cb50bcd28ef7aa9fa4e5ffe4c6c9ca085109ffc0400a08e6f9ea steamos-xorg.conf 9d4a856a31777ee7cd7ac6b2b0de9c97b86812616404d6d61e44edcabd167091 support-common.sh 4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 support-version eb8209ed950389276daab13795cfbe3dd4c214865ce2a7478c60136bee838ac3 terminal-notices.txt diff --git a/app/LinuxRunner/GUIViewController.m b/app/LinuxRunner/GUIViewController.m index 20c793b..66f4b65 100644 --- a/app/LinuxRunner/GUIViewController.m +++ b/app/LinuxRunner/GUIViewController.m @@ -1123,7 +1123,7 @@ + (void)buildDesktopReport:(void (^)(NSString *text))completion{ [text appendFormat:@"\n== guest state\n%@\n",guest?:@"(not collected)"]; for(NSString *name in @[@"profile-check.result",@"install-result"]){NSString *t=LRTail([support URLByAppendingPathComponent:name],10);if(t)[text appendFormat:@"\n== %@\n%@\n",name,t];} NSString *profile=LRTail([support URLByAppendingPathComponent:@"desktop-profile.sh.log"],40);if(profile)[text appendFormat:@"\n== desktop-profile.sh.log (last 40 lines)\n%@\n",profile]; - if(session)for(NSString *name in @[@"desktop-state",@"session.log",@"application.log",@"xvfb.log",@"display.log",@"wm.log",@"panel.log",@"desktop.log",@"plasma.log"]){ + if(session)for(NSString *name in @[@"desktop-state",@"session.log",@"application.log",@"xvfb.log",@"xorg.log",@"graphics.log",@"display.log",@"wm.log",@"panel.log",@"desktop.log",@"plasma.log"]){ NSString *t=LRTail([session URLByAppendingPathComponent:name],name.length>12?25:40);if([name isEqual:@"plasma.log"])t=[LRRuntimeBridge redactDiagnosticText:t?:@"" application:YES];if(t.length)[text appendFormat:@"\n== %@ (tail)\n%@\n",name,t];} NSString *saved=LRRuntimeBridge.desktopTraceSavedText;NSArray *savedLines=[saved componentsSeparatedByString:@"\n"]; [text appendFormat:@"\n== earlier attempts (saved timeline, last 150 records)\n%@\n",[[savedLines subarrayWithRange:NSMakeRange(savedLines.count>150?savedLines.count-150:0,MIN(savedLines.count,(NSUInteger)150))] componentsJoinedByString:@"\n"]]; diff --git a/app/LinuxRunner/ManagerView.swift b/app/LinuxRunner/ManagerView.swift index d1392ed..2b24304 100644 --- a/app/LinuxRunner/ManagerView.swift +++ b/app/LinuxRunner/ManagerView.swift @@ -102,7 +102,7 @@ import Darwin environments[index].storageBytes = measured[environments[index].id] if active(environments[index]) { environments[index].lastBootResult = "Runtime running; application acceptance recorded separately" - if ["debian", "arch"].contains(environments[index].distributionID ?? "") { + if ["debian", "arch", "steamos"].contains(environments[index].distributionID ?? "") { environments[index].installationState = FileManager.default.fileExists(atPath: LRRuntimeBridge.rootURL(environments[index].rootfsName).appendingPathComponent("data/var/lib/linuxrunner/initialized").path) ? "Base packages configured" : "First-start configuration pending; setup may require repair" let support = LRRuntimeBridge.exchangeURL(environments[index].rootfsName).appendingPathComponent(".linuxrunner/gui") if let profile = try? String(contentsOf: support.appendingPathComponent("profile-check.result"), encoding: .utf8) { @@ -871,6 +871,7 @@ struct PackageView: View { Text(environment.distribution + " · " + environment.architecture) Text(enabled ? "Operations use this running environment's real package manager." : "Select this environment for the next launch, then reopen LinuxRunner.").font(.caption) Text(status).font(.caption).textSelection(.enabled) + if environment.distributionID == "steamos" { Text("Prepared SteamOS image experiment: installed-package inspection is available. Repository updates and package changes are not enabled.").font(.caption) } } if environment.distributionID == "debian" { Section { @@ -887,11 +888,11 @@ struct PackageView: View { Button("Refresh Repository Indexes") { execute("update") } Button("Available Updates") { execute("updates") } TextField("Package name or search term", text: $query).autocorrectionDisabled().textInputAutocapitalization(.never) - Button("Search") { execute("search") }.disabled(!PackageOperation.validName(query)) + Button("Search") { execute("search") }.disabled(environment.distributionID == "steamos" || !PackageOperation.validName(query)) Button("Package Details") { execute("details") }.disabled(!PackageOperation.validName(query)) - Button("Install Package") { confirm("install") }.disabled(!PackageOperation.validName(query)) - Button("Review Package Upgrade") { confirm("upgrade") }.disabled(!PackageOperation.validName(query)) - Button("Review Removal", role: .destructive) { confirm("remove") }.disabled(!PackageOperation.validName(query)) + Button("Install Package") { confirm("install") }.disabled(environment.distributionID == "steamos" || !PackageOperation.validName(query)) + Button("Review Package Upgrade") { confirm("upgrade") }.disabled(environment.distributionID == "steamos" || !PackageOperation.validName(query)) + Button("Review Removal", role: .destructive) { confirm("remove") }.disabled(environment.distributionID == "steamos" || !PackageOperation.validName(query)) Text("Output, dependency plans and progress open in a Linux terminal. Removal asks for confirmation there. Close the terminal when finished, then refresh the installed list. Cancellation during configuration may require Repair Center.").font(.caption) }.disabled(!enabled) if !packages.isEmpty { @@ -917,7 +918,7 @@ struct PackageView: View { Button("Diagnose Package State") { execute("diagnose") } Button("Check Dependencies") { execute("dependencies") } if environment.distributionID == "debian" { Button("Configure Interrupted Debian Packages") { confirm("configure") } } - Button("Check / Repair Package State") { confirm("repair") } + Button("Check / Repair Package State") { confirm("repair") }.disabled(environment.distributionID == "steamos") Button("Reset GUI Sessions") { LRRuntimeBridge.resetGraphicalSessions(); refreshApplications() } Text("Repair runs native maintainer scripts and keeps a package-database metadata checkpoint inside Linux. It is not a full snapshot or automatic rollback. APT dependency repair asks for confirmation before changing packages. Safe diagnostic mode: use Diagnose and Check Dependencies before repair.").font(.caption) }.disabled(!enabled) @@ -2188,7 +2189,7 @@ private struct GraphicsSettingsView: View { Text("Takes effect after LinuxRunner restarts.").font(.caption).foregroundColor(.orange) } } header: { Text("Linux GPU Device") } footer: { - Text("Metal GPU gives Linux a virtual GPU: Mesa's own virgl driver renders on the iPhone GPU through Metal (ANGLE). Today Hyprland composites on it; other desktops keep software rendering. If the GPU does not start, LinuxRunner falls back to software and shows why. Automatic uses software rendering until Metal is validated on iPhone.") + Text("Metal GPU gives Linux a virtual GPU through Mesa virgl and ANGLE. Hyprland can composite on it. Prepared SteamOS uses it for Xorg and Qt rendering, with KWin window effects disabled. Restart LinuxRunner after changing this setting. Automatic currently uses software rendering.") } } Section("Current Session") { diff --git a/app/LinuxRunner/RuntimeBridge.h b/app/LinuxRunner/RuntimeBridge.h index aa4c87a..49d1491 100644 --- a/app/LinuxRunner/RuntimeBridge.h +++ b/app/LinuxRunner/RuntimeBridge.h @@ -56,6 +56,7 @@ void LRSetDatalessMaterialization(void); + (BOOL)importVerifiedEnvironment:(NSURL *)archive root:(NSString *)root sha256:(NSString *)sha256 metadata:(NSDictionary *)metadata progress:(void (^)(double, NSString *))progress cancel:(BOOL (^)(void))cancel error:(NSError **)error; + (BOOL)activeEnvironmentIsArch; + (BOOL)activeEnvironmentIsDebian; ++ (BOOL)activeEnvironmentIsSteamOS; + (NSDictionary *)probeJITPermissions; /// Execution backend. The JIT exists only in the JIT-capable build variant and /// runs only after iOS grants executable memory (an attached debugger such as diff --git a/app/LinuxRunner/RuntimeBridge.m b/app/LinuxRunner/RuntimeBridge.m index 5ef20a1..c90f198 100644 --- a/app/LinuxRunner/RuntimeBridge.m +++ b/app/LinuxRunner/RuntimeBridge.m @@ -407,7 +407,7 @@ + (NSArray *)diagnosticEntries { return [LRDiagnosticCenter entries]; } NSMutableArray *sessions=[NSMutableArray new]; for(NSURL *child in children){NSNumber *link=nil,*directory=nil;[child getResourceValue:&link forKey:NSURLIsSymbolicLinkKey error:nil];[child getResourceValue:&directory forKey:NSURLIsDirectoryKey error:nil];if([child.lastPathComponent hasPrefix:@"session-"]&&directory.boolValue&&!link.boolValue)[sessions addObject:child];} [sessions sortUsingComparator:^NSComparisonResult(NSURL *a,NSURL *b){NSDate *ad=nil,*bd=nil;[a getResourceValue:&ad forKey:NSURLContentModificationDateKey error:nil];[b getResourceValue:&bd forKey:NSURLContentModificationDateKey error:nil];return [(bd?:NSDate.distantPast) compare:ad?:NSDate.distantPast];}]; - NSDictionary *components=@{@"session.log":@"Session",@"xvfb.log":@"X11",@"display.log":@"Display",@"bus.log":@"D-Bus",@"wm.log":@"Window Manager",@"panel.log":@"Panel",@"desktop.log":@"Desktop",@"plasma.log":@"Plasma",@"application.log":@"Application",@"focus.log":@"Input",@"window.log":@"Windows",@"desktop-state":@"Status"}; + NSDictionary *components=@{@"session.log":@"Session",@"xvfb.log":@"X11",@"xorg.log":@"Xorg",@"graphics.log":@"Graphics",@"display.log":@"Display",@"bus.log":@"D-Bus",@"wm.log":@"Window Manager",@"panel.log":@"Panel",@"desktop.log":@"Desktop",@"plasma.log":@"Plasma",@"application.log":@"Application",@"focus.log":@"Input",@"window.log":@"Windows",@"desktop-state":@"Status"}; for(NSURL *session in [sessions subarrayWithRange:NSMakeRange(0,MIN(sessions.count,3))]) { for(NSString *name in [components.allKeys sortedArrayUsingSelector:@selector(compare:)]) { NSURL *url=[session URLByAppendingPathComponent:name]; @@ -754,6 +754,10 @@ + (BOOL)activeEnvironmentIsArch { NSDictionary *installation=[NSJSONSerialization JSONObjectWithData:[NSData dataWithContentsOfURL:[[self rootURL:self.activeRootName] URLByAppendingPathComponent:@"environment-install.json"]]?:[NSData data] options:0 error:nil]; return [installation[@"distributionID"] isEqual:@"arch"]; } ++ (BOOL)activeEnvironmentIsSteamOS { + NSDictionary *installation=[NSJSONSerialization JSONObjectWithData:[NSData dataWithContentsOfURL:[[self rootURL:self.activeRootName] URLByAppendingPathComponent:@"environment-install.json"]]?:[NSData data] options:0 error:nil]; + return [installation[@"distributionID"] isEqual:@"steamos"]; +} + (BOOL)importVerifiedEnvironment:(NSURL *)archive root:(NSString *)root sha256:(NSString *)sha256 progress:(void (^)(double,NSString *))progress cancel:(BOOL (^)(void))cancel error:(NSError **)error { LRInstallProgress *reporter=[LRInstallProgress new];reporter.progress=progress;reporter.cancel=cancel; return [Roots.instance importVerifiedRootFromArchive:archive name:root sha256:sha256 metadata:@{@"distributionID":@"debian",@"distribution":@"Debian GNU/Linux",@"version":@"13.7",@"packageManager":@"apt",@"defaultShell":@"/bin/bash"} error:error progressReporter:reporter]; @@ -1114,7 +1118,7 @@ + (TerminalViewController *)createLinuxUser:(NSString *)name administrator:(BOOL // locking and duplicate-name checks are authoritative, not this UI list. NSString *passwd=LRAccountTool(@[@"/usr/bin/passwd", @"/bin/passwd"]); // shadow's useradd on glibc distributions (Debian, Arch Linux ARM); BusyBox adduser on Alpine. - NSString *create=LRAccountTool(self.activeEnvironmentIsDebian || self.activeEnvironmentIsArch ? @[@"/usr/sbin/useradd", @"/usr/bin/useradd"] : @[@"/usr/sbin/adduser", @"/sbin/adduser", @"/usr/bin/adduser", @"/bin/adduser"]); + NSString *create=LRAccountTool(self.activeEnvironmentIsDebian || self.activeEnvironmentIsArch || self.activeEnvironmentIsSteamOS ? @[@"/usr/sbin/useradd", @"/usr/bin/useradd"] : @[@"/usr/sbin/adduser", @"/sbin/adduser", @"/usr/bin/adduser", @"/bin/adduser"]); if (!passwd || !create) { if(error)*error=lrError(ENOENT, @"Account tools are not installed yet. Use Create Account in Linux Users; it installs them automatically."); return nil; } // Administrator: the distribution's sudo group (sudo on Debian, wheel on // Alpine); sudo then asks for the user's own password. @@ -1122,12 +1126,12 @@ + (TerminalViewController *)createLinuxUser:(NSString *)name administrator:(BOOL if (administrator) { if (!self.canGrantAdministrator) { if(error)*error=lrError(ENOENT, @"sudo is not installed in this environment; install it first, or create the user without administrator access."); return nil; } grant=self.activeEnvironmentIsDebian ? @" && { getent group sudo >/dev/null || groupadd --system sudo; } && usermod -aG sudo -- \"$1\"" : - self.activeEnvironmentIsArch ? @" && { getent group wheel >/dev/null || groupadd --system wheel; } && usermod -aG wheel -- \"$1\"" + (self.activeEnvironmentIsArch || self.activeEnvironmentIsSteamOS) ? @" && { getent group wheel >/dev/null || groupadd --system wheel; } && usermod -aG wheel -- \"$1\"" " && { [ -e /etc/sudoers.d/linuxrunner-wheel ] || { mkdir -p /etc/sudoers.d && printf '%%wheel ALL=(ALL:ALL) ALL\\n' > /etc/sudoers.d/linuxrunner-wheel && chmod 0440 /etc/sudoers.d/linuxrunner-wheel; }; }" : @" && { getent group wheel >/dev/null || addgroup -S wheel; } && addgroup \"$1\" wheel" " && { [ -e /etc/sudoers.d/linuxrunner-wheel ] || { mkdir -p /etc/sudoers.d && printf '%%wheel ALL=(ALL:ALL) ALL\\n' > /etc/sudoers.d/linuxrunner-wheel && chmod 0440 /etc/sudoers.d/linuxrunner-wheel; }; }"; } - NSString *script=[NSString stringWithFormat:self.activeEnvironmentIsDebian || self.activeEnvironmentIsArch ? + NSString *script=[NSString stringWithFormat:self.activeEnvironmentIsDebian || self.activeEnvironmentIsArch || self.activeEnvironmentIsSteamOS ? @"\"$2\" --create-home --shell /bin/bash -- \"$1\"%@ && exec \"$3\" \"$1\"" : @"\"$2\" -D -h \"/home/$1\" -s /bin/sh \"$1\"%@ && exec \"$3\" \"$1\"", grant]; return [self terminalForCommand:@[@"/bin/sh", @"-c", script, @"linuxrunner-create-user", name, create, passwd] directory:@"/"]; @@ -1169,7 +1173,7 @@ + (void)createLinuxUser:(NSString *)name password:(NSString *)password administr char salt[17], hash[160]; if (lr_random_salt(salt) || lr_sha512_crypt(password.UTF8String, salt, hash, sizeof hash)) { completion(lrError(EIO, @"The password could not be prepared.")); return; } NSString *hashed = @(hash); memset(hash, 0, sizeof hash); - BOOL debian = self.activeEnvironmentIsDebian, arch = self.activeEnvironmentIsArch; + BOOL debian = self.activeEnvironmentIsDebian, arch = self.activeEnvironmentIsArch || self.activeEnvironmentIsSteamOS; void (^create)(void) = ^{ progress(@"Creating your account"); NSString *grant = !administrator ? @"" : debian ? @@ -1421,7 +1425,7 @@ + (TerminalViewController *)removeLinuxUserKeepingHome:(NSString *)name error:(N if(error)*error=lrError(EPERM,@"System accounts and the default login cannot be removed. Select a different default login first."); return nil; } - BOOL shadow = self.activeEnvironmentIsDebian || self.activeEnvironmentIsArch; + BOOL shadow = self.activeEnvironmentIsDebian || self.activeEnvironmentIsArch || self.activeEnvironmentIsSteamOS; NSString *remove = LRAccountTool(shadow ? @[@"/usr/sbin/userdel", @"/usr/bin/userdel"] : @[@"/usr/sbin/deluser", @"/sbin/deluser", @"/usr/bin/deluser", @"/bin/deluser"]); if (!remove) { if(error)*error=lrError(ENOENT,@"The distribution's account-removal tool is missing. Install it using Package Center."); return nil; } @@ -1582,7 +1586,7 @@ + (NSURL *)exportFullDiagnosticBundle:(NSError **)error { NSMutableArray *sessions=[NSMutableArray new]; for(NSURL *u in children)if([u.lastPathComponent hasPrefix:@"session-"]){NSNumber *link=nil;[u getResourceValue:&link forKey:NSURLIsSymbolicLinkKey error:nil];if(!link.boolValue)[sessions addObject:u];} [sessions sortUsingComparator:^NSComparisonResult(NSURL *a,NSURL *b){NSDate *ad=nil,*bd=nil;[a getResourceValue:&ad forKey:NSURLContentModificationDateKey error:nil];[b getResourceValue:&bd forKey:NSURLContentModificationDateKey error:nil];return [(bd?:NSDate.distantPast) compare:(ad?:NSDate.distantPast)];}]; - for(NSURL *session in [sessions subarrayWithRange:NSMakeRange(0,MIN(sessions.count,3))])for(NSString *name in @[@"session.log",@"application.log",@"focus.log",@"window.log",@"display.log",@"xvfb.log",@"bus.log",@"window-state",@"wm.log",@"panel.log",@"desktop.log",@"plasma.log",@"xfconf.log",@"desktop-state"]){ + for(NSURL *session in [sessions subarrayWithRange:NSMakeRange(0,MIN(sessions.count,3))])for(NSString *name in @[@"session.log",@"application.log",@"focus.log",@"window.log",@"display.log",@"xvfb.log",@"xorg.log",@"graphics.log",@"bus.log",@"window-state",@"wm.log",@"panel.log",@"desktop.log",@"plasma.log",@"xfconf.log",@"desktop-state"]){ NSURL *url=[session URLByAppendingPathComponent:name];int fd=open(url.fileSystemRepresentation,O_RDONLY|O_NOFOLLOW);if(fd<0)continue;struct stat st;if(fstat(fd,&st)||!S_ISREG(st.st_mode)){close(fd);continue;} off_t offset=st.st_size>1024*1024?st.st_size-1024*1024:0;NSMutableData *data=[NSMutableData dataWithLength:st.st_size-offset];ssize_t n=pread(fd,data.mutableBytes,data.length,offset);close(fd);if(n<0)continue;data.length=n; NSString *text=[[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding]?:@"non-UTF8 application output omitted"; diff --git a/app/LinuxRunner/SetupWizard.swift b/app/LinuxRunner/SetupWizard.swift index 8708773..f2f3594 100644 --- a/app/LinuxRunner/SetupWizard.swift +++ b/app/LinuxRunner/SetupWizard.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers // Guided setup: Welcome → Distribution → Install Linux → (restart into it) → // Account → Desktop → Finish → Open Desktop. Uses the verified installers, the @@ -17,6 +18,8 @@ struct SetupWizardView: View { @AppStorage("LinuxRunnerSetup.root") private var setupRoot = "" @StateObject private var debian = DebianInstaller() @StateObject private var arch = ArchInstaller() + @StateObject private var steamos = SteamOSInstaller() + @State private var steamOSImporter = false @State private var username = "" @State private var password = "" @State private var confirmation = "" @@ -31,12 +34,18 @@ struct SetupWizardView: View { NavigationView { Form { content } .navigationTitle(title) - .toolbar { ToolbarItem(placement: .cancellationAction) { Button(step == .done ? "Close" : "Later") { dismiss() }.disabled(working != nil || debian.running || arch.running || manager.busy) } } + .toolbar { ToolbarItem(placement: .cancellationAction) { Button(step == .done ? "Close" : "Later") { dismiss() }.disabled(working != nil || debian.running || arch.running || steamos.running || manager.busy) } } } .navigationViewStyle(.stack) .overlay(alignment: .bottom) { InstallProgressCard() } - .interactiveDismissDisabled(working != nil || debian.running || arch.running || manager.busy) + .interactiveDismissDisabled(working != nil || debian.running || arch.running || steamos.running || manager.busy) .onAppear(perform: resume) + .fileImporter(isPresented: $steamOSImporter, allowedContentTypes: [.data], allowsMultipleSelection: false) { result in + switch result { + case .success(let urls): if let source = urls.first { installSteamOS(from: source) } + case .failure(let error): problem = error.localizedDescription + } + } } private var title: String { @@ -55,7 +64,7 @@ struct SetupWizardView: View { Section { Button("Continue") { stepRaw = SetupStep.distribution.rawValue } } case .distribution: Section(footer: Text("Debian is recommended. Arch Linux ARM provides a rolling release with pacman and an experimental X11 desktop. Alpine is small and mainly for the terminal. Wayland is not supported.")) { - Picker("Distribution", selection: $distribution) { Text("Debian GNU/Linux 13 (ARM64) — recommended").tag("debian"); Text("Arch Linux ARM (ARM64) — experimental").tag("arch"); Text("Alpine Linux 3.24 (ARM64)").tag("alpine") }.pickerStyle(.inline).labelsHidden() + Picker("Distribution", selection: $distribution) { Text("Debian GNU/Linux 13 (ARM64) — recommended").tag("debian"); Text("Arch Linux ARM (ARM64) — experimental").tag("arch"); Text("Alpine Linux 3.24 (ARM64)").tag("alpine"); Text("SteamOS ARM64 — prepared image experiment").tag("steamos") }.pickerStyle(.inline).labelsHidden() } let installed = manager.environments.filter { ($0.distributionID ?? "alpine") == distribution } if !installed.isEmpty { @@ -85,12 +94,13 @@ struct SetupWizardView: View { @ViewBuilder private var install: some View { let free = (try? LRRuntimeBridge.managedStorageURL().resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage) ?? 0 let manifest = try? DebianManifest.load() + let steamManifest = try? SteamOSManifest.load() let alpineBytes = Int64((try? Bundle.main.url(forResource: "root", withExtension: "tar.gz")?.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) let download = distribution == "debian" ? (manifest?.downloadBytes ?? 0) : 0 let installed = distribution == "debian" ? (manifest?.installedBytes ?? 0) : alpineBytes * 3 Section("Storage") { - row("Download", distribution == "arch" ? "Calculated from the signed package selection" : distribution == "debian" ? ByteCountFormatter.string(fromByteCount: download, countStyle: .file) : "None (included in the app)") - row(distribution == "debian" ? "Installed size" : "Installed size (estimate)", distribution == "arch" ? "At least 1.5 GB free for base installation" : ByteCountFormatter.string(fromByteCount: installed, countStyle: .file)) + row("Download", distribution == "steamos" ? "Prepared archive from your Mac or Files" : distribution == "arch" ? "Calculated from the signed package selection" : distribution == "debian" ? ByteCountFormatter.string(fromByteCount: download, countStyle: .file) : "None (included in the app)") + row(distribution == "debian" || distribution == "steamos" ? "Installed size" : "Installed size (estimate)", distribution == "steamos" ? ByteCountFormatter.string(fromByteCount: steamManifest?.installedBytes ?? 0, countStyle: .file) : distribution == "arch" ? "At least 1.5 GB free for base installation" : ByteCountFormatter.string(fromByteCount: installed, countStyle: .file)) row("Available", ByteCountFormatter.string(fromByteCount: free, countStyle: .file)) } if distribution == "debian" { @@ -105,6 +115,22 @@ struct SetupWizardView: View { else { Button("Install Arch Linux ARM") { startInstall() }.disabled(free < Int64(1_500_000_000)) } if let failure = arch.failure { Text(failure).foregroundStyle(.red); Button("Try Again") { startInstall() } } } footer: { Text("Official signed Arch Linux ARM packages. Verified downloads are kept for retry. First-start configuration finishes before desktop packages are installed.") } + } else if distribution == "steamos" { + Section { + Text(steamos.message) + if steamos.running { + ProgressView(value: steamos.fraction) + Button("Cancel Installation", role: .destructive) { steamos.cancel() } + } else { + if FileManager.default.fileExists(atPath: SteamOSInstaller.preparedArchiveURL.path) { + Button("Install Prepared SteamOS") { installSteamOS(from: SteamOSInstaller.preparedArchiveURL) } + .disabled(steamManifest == nil || free < (steamManifest?.requiredFreeBytes ?? Int64.max)) + } + Button("Choose Prepared SteamOS Archive…") { steamOSImporter = true }.disabled(steamManifest == nil) + } + if let failure = steamos.failure { Text(failure).foregroundStyle(.red) } + if steamManifest == nil { Text("This build has no supported prepared SteamOS image manifest.").foregroundStyle(.secondary) } + } footer: { Text("Experimental SteamFrame ARM64 userspace with a terminal and Plasma X11 desktop. The archive must match the image prepared for this build. Keep LinuxRunner open during installation.") } } else { Section { if manager.busy { ProgressView("Unpacking Alpine Linux…") } else { Button("Install Alpine") { startInstall() } } if let error = manager.error { Text(error).foregroundStyle(.red) } } @@ -134,6 +160,14 @@ struct SetupWizardView: View { } @ViewBuilder private var desktop: some View { + if LRRuntimeBridge.activeEnvironmentIsSteamOS() { + Section { + Label("SteamOS Plasma X11", systemImage: "desktopcomputer") + Text("The prepared image includes the desktop and display bridge. This experiment uses software rendering.") + if desktopReady { Label("Desktop components checked", systemImage: "checkmark"); Button("Continue") { stepRaw = SetupStep.done.rawValue } } + else { Button("Check SteamOS Desktop") { prepareDesktop() }.disabled(working != nil) } + } + } else { Section(footer: Text("Installs the display, window manager, panel, file manager and sound once (network needed). Existing settings are kept.")) { HStack { Image(systemName: "checkmark.circle.fill").foregroundStyle(Color.accentColor); Text(LRRuntimeBridge.activeEnvironmentIsArch() ? "LinuxRunner Desktop (Openbox, LXQt panel, PCManFM-Qt)" : "LinuxRunner Desktop (Openbox, panel, file manager)") } HStack { Image(systemName: "circle").foregroundStyle(.secondary); VStack(alignment: .leading) { Text("LXQt · Enlightenment · Wayfire · Hyprland"); Text("Optional · install below once the desktop components are ready, or later in Settings › Desktop Environment").font(.caption).foregroundStyle(.secondary) } } @@ -155,6 +189,7 @@ struct SetupWizardView: View { } } } + } } private func row(_ title: String, _ value: String) -> some View { HStack { Text(title); Spacer(); Text(value).foregroundStyle(.secondary) } } @@ -172,7 +207,7 @@ struct SetupWizardView: View { } private func startInstall() { - guard !debian.running, !arch.running, !manager.busy else { return } + guard !debian.running, !arch.running, !steamos.running, !manager.busy else { return } problem = nil let known = Set(manager.environments.map(\.rootfsName)) let finish: (String) -> Void = { root in @@ -192,6 +227,17 @@ struct SetupWizardView: View { } } + private func installSteamOS(from source: URL) { + guard !debian.running, !arch.running, !steamos.running, !manager.busy else { return } + problem = nil + steamos.install(source: source) { environment in + manager.acceptInstalled(environment) + setupRoot = environment.rootfsName + LRRuntimeBridge.setNextRootName(environment.rootfsName) + stepRaw = SetupStep.relaunch.rawValue + } + } + private func createAccount() { problem = nil; working = "Preparing" LRRuntimeBridge.createLinuxUser(username, password: password, administrator: administrator, progress: { working = $0 }) { error in diff --git a/app/LinuxRunner/SteamOSExperiment.entitlements b/app/LinuxRunner/SteamOSExperiment.entitlements new file mode 100644 index 0000000..1be2efd --- /dev/null +++ b/app/LinuxRunner/SteamOSExperiment.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.kernel.extended-virtual-addressing + + + diff --git a/app/LinuxRunner/SteamOSInstaller.swift b/app/LinuxRunner/SteamOSInstaller.swift new file mode 100644 index 0000000..fa47ad4 --- /dev/null +++ b/app/LinuxRunner/SteamOSInstaller.swift @@ -0,0 +1,91 @@ +import Foundation +import SwiftUI + +// Blocking native import runs off the main actor. Cancellation and progress +// throttling are shared with that callback and protected by this lock. +private final class SteamOSInstallControl: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + private var lastReport = 0.0 + func cancel() { lock.lock(); cancelled = true; lock.unlock() } + func isCancelled() -> Bool { lock.lock(); defer { lock.unlock() }; return cancelled } + func reportProgress() -> Bool { + lock.lock(); defer { lock.unlock() } + let now = ProcessInfo.processInfo.systemUptime + guard now - lastReport >= 0.2 else { return false } + lastReport = now + return true + } +} + +@MainActor final class SteamOSInstaller: ObservableObject { + @Published var running = false { + didSet { if running != oldValue { running ? LRRuntimeBridge.beginAwake("steamos-install") : LRRuntimeBridge.endAwake("steamos-install") } } + } + @Published var message = "Install the prepared SteamFrame ARM64 userspace." + @Published var fraction = 0.0 + @Published var failure: String? + private var operation: Task? + private var control = SteamOSInstallControl() + private var operationID: UUID? + + static var preparedArchiveURL: URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("SteamOS/rootfs.tar.gz") + } + + func cancel() { control.cancel(); operation?.cancel() } + + func install(source: URL, completion: @escaping (LinuxEnvironment) -> Void) { + guard !running else { return } + running = true; failure = nil; fraction = 0 + control = SteamOSInstallControl() + let token = control, identifier = UUID() + operationID = identifier + UserDefaults.standard.set(true, forKey: "InstallerActive") + operation = Task { + defer { operationID = nil; running = false; UserDefaults.standard.set(false, forKey: "InstallerActive") } + let scoped = source.startAccessingSecurityScopedResource() + defer { if scoped { source.stopAccessingSecurityScopedResource() } } + do { + let manifest = try SteamOSManifest.load() + let attributes = try source.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey]) + guard attributes.isRegularFile == true, attributes.isSymbolicLink != true, + Int64(attributes.fileSize ?? 0) == manifest.archiveBytes else { + throw SteamOSInstallError.failure("Choose the prepared SteamOS rootfs.tar.gz that matches this app's image manifest.") + } + let available = try LRRuntimeBridge.managedStorageURL().resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage ?? 0 + guard available >= manifest.requiredFreeBytes else { + throw SteamOSInstallError.failure("SteamOS installation needs " + ByteCountFormatter.string(fromByteCount: manifest.requiredFreeBytes, countStyle: .file) + " of available space for verification and installation.") + } + let root = UUID().uuidString.lowercased() + let metadata = ["distributionID": "steamos", "distribution": "SteamOS ARM64", "version": manifest.version, + "packageManager": "pacman", "defaultShell": "/bin/bash", "sourceImageSHA256": manifest.sourceImageSHA256] + message = "Verifying the prepared SteamOS filesystem" + try await Task.detached(priority: .utility) { + try LRRuntimeBridge.importVerifiedEnvironment(source, root: root, sha256: manifest.archiveSHA256, metadata: metadata, + progress: { value, _ in + guard token.reportProgress() else { return } + Task { @MainActor in + guard self.operationID == identifier, self.running else { return } + self.fraction = value + self.message = value < 0.2 ? "Verifying the prepared SteamOS filesystem" : "Installing SteamOS" + } + }, cancel: { token.isCancelled() }) + }.value + var environment = LinuxEnvironment(name: "SteamOS ARM64 · Experimental", rootfsName: root) + environment.distribution = "SteamOS ARM64"; environment.distributionID = "steamos" + environment.packageManager = "pacman"; environment.rootfsVersion = manifest.version + environment.defaultShell = "/bin/bash"; environment.installationState = "Prepared image imported" + environment.desktopAvailability = "Plasma X11 prepared; session checked at startup" + UserDefaults.standard.set("plasma", forKey: "LinuxRunnerDesktop.environment." + root) + completion(environment) + fraction = 1; message = "SteamOS installed. Restart LinuxRunner to configure your account." + LRRuntimeBridge.logCategory("INSTALL", event: "Prepared SteamOS ARM64 image imported; SHA-256 verified") + } catch { + failure = token.isCancelled() ? "Installation cancelled. Your selected archive and existing environments were kept." : error.localizedDescription + LRRuntimeBridge.logCategory("INSTALL", event: failure ?? "SteamOS installation failed") + } + } + } +} diff --git a/app/LinuxRunner/SteamOSManifest.swift b/app/LinuxRunner/SteamOSManifest.swift new file mode 100644 index 0000000..c62a388 --- /dev/null +++ b/app/LinuxRunner/SteamOSManifest.swift @@ -0,0 +1,42 @@ +import Foundation + +enum SteamOSInstallError: LocalizedError { + case failure(String) + var errorDescription: String? { switch self { case .failure(let message): return message } } +} + +struct SteamOSManifest: Decodable { + let schema: Int + let architecture: String + let version: String + let sourceImageSHA256: String + let archiveSHA256: String + let archiveBytes: Int64 + let installedBytes: Int64 + let entries: Int + + static let sourcePin = "3a4a077f1b1f40688ab3279affcb56776bd97c54db1573e7c65fc52a97106676" + var requiredFreeBytes: Int64 { archiveBytes + installedBytes + 512 * 1024 * 1024 } + + static func decode(_ data: Data) throws -> Self { + guard data.count <= 65536 else { throw SteamOSInstallError.failure("SteamOS image manifest is too large.") } + let manifest = try JSONDecoder().decode(Self.self, from: data) + guard manifest.schema == 1, manifest.architecture == "arm64", manifest.version == "0.3.0", + manifest.sourceImageSHA256 == sourcePin, + manifest.archiveSHA256.count == 64, + manifest.archiveSHA256.allSatisfy({ "0123456789abcdef".contains($0) }), + manifest.archiveBytes > 0, manifest.archiveBytes <= 6 * 1024 * 1024 * 1024, + manifest.installedBytes > 0, manifest.installedBytes <= 16 * 1024 * 1024 * 1024, + manifest.entries > 0, manifest.entries <= 500000 else { + throw SteamOSInstallError.failure("This app does not contain a supported prepared SteamOS image manifest.") + } + return manifest + } + + static func load() throws -> Self { + guard let bundle = Bundle.main.url(forResource: "SteamOSSupport", withExtension: "bundle") else { + throw SteamOSInstallError.failure("The prepared SteamOS image manifest is missing from this app.") + } + return try decode(Data(contentsOf: bundle.appendingPathComponent("manifest.json"))) + } +} diff --git a/app/LinuxRunner/SteamOSSupport.bundle/manifest.json b/app/LinuxRunner/SteamOSSupport.bundle/manifest.json new file mode 100644 index 0000000..45d075b --- /dev/null +++ b/app/LinuxRunner/SteamOSSupport.bundle/manifest.json @@ -0,0 +1,10 @@ +{ + "schema": 1, + "architecture": "arm64", + "version": "0.3.0", + "sourceImageSHA256": "3a4a077f1b1f40688ab3279affcb56776bd97c54db1573e7c65fc52a97106676", + "archiveSHA256": "d8683fd3d18f15cdb75fae5dcda8b06f21dd5376abe8eb14565d5b90505dd86a", + "archiveBytes": 3883082382, + "installedBytes": 8870355438, + "entries": 226112 +} diff --git a/app/Roots.m b/app/Roots.m index 7a03268..adbfd17 100644 --- a/app/Roots.m +++ b/app/Roots.m @@ -15,6 +15,9 @@ #import "LinuxRunner/FileTransfer.h" #import "LinuxRunner/BootLog.h" #include +#include +#include +#include static BOOL validRunnerRoot(NSString *name) { return lr_valid_leaf(name.UTF8String) && strlen(name.UTF8String) == [name lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; @@ -103,7 +106,7 @@ - (void)syncFileProviderDomains { #ifdef LINUXRUNNER // No extension is embedded in this target; avoid unrelated provider APIs. return; -#endif +#else if (self.updatingDomains) { self.domainsNeedUpdate = YES; return; @@ -139,6 +142,7 @@ - (void)syncFileProviderDomains { [self syncFileProviderDomains]; self.updatingDomains = NO; }]; +#endif } - (BOOL)accessInstanceVariablesDirectly { @@ -152,6 +156,12 @@ void root_progress_callback(void *cookie, double progress, const char *message, *should_cancel = true; } +#ifdef LINUXRUNNER +static void root_import_progress_callback(void *cookie, double progress, const char *message, bool *should_cancel) { + root_progress_callback(cookie, .2 + .8 * progress, message, should_cancel); +} +#endif + - (BOOL)importRootFromArchive:(NSURL *)archive name:(NSString *)name error:(NSError **)error progressReporter:(id _Nullable)progress { #ifdef LINUXRUNNER return [self importVerifiedRootFromArchive:archive name:name sha256:[NSBundle.mainBundle objectForInfoDictionaryKey:@"LinuxRunnerRootfsSHA256"] metadata:@{@"distributionID":@"alpine",@"distribution":@"Alpine Linux",@"version":@"3.24.2",@"packageManager":@"apk",@"defaultShell":@"/bin/sh"} error:error progressReporter:progress]; @@ -169,32 +179,75 @@ - (BOOL)importVerifiedRootFromArchive:(NSURL *)archive name:(NSString *)name sha // installer supplies its archive hash only after authenticating every package. // Verify a private snapshot, then import those exact bytes. A document // provider may change its original URL while verification is in progress. + unsigned long long maximumBytes = 512ULL * 1024 * 1024; + unsigned long long pinnedBytes = 0; + if ([metadata[@"distributionID"] isEqual:@"steamos"]) { + NSURL *support = [NSBundle.mainBundle URLForResource:@"SteamOSSupport" withExtension:@"bundle"]; + NSData *manifestData = support ? [NSData dataWithContentsOfURL:[support URLByAppendingPathComponent:@"manifest.json"]] : nil; + id decoded = manifestData ? [NSJSONSerialization JSONObjectWithData:manifestData options:0 error:nil] : nil; + NSDictionary *manifest = [decoded isKindOfClass:NSDictionary.class] ? decoded : nil; + pinnedBytes = [manifest[@"archiveBytes"] unsignedLongLongValue]; + if (![manifest isKindOfClass:NSDictionary.class] || [manifest[@"schema"] integerValue] != 1 || + ![manifest[@"architecture"] isEqual:@"arm64"] || ![manifest[@"archiveSHA256"] isEqual:expected] || + ![manifest[@"sourceImageSHA256"] isEqual:metadata[@"sourceImageSHA256"]] || + !pinnedBytes || pinnedBytes > 6ULL * 1024 * 1024 * 1024) { + if (error) *error = [NSError errorWithDomain:@"dev.linuxrunner.rootfs" code:EINVAL + userInfo:@{NSLocalizedDescriptionKey:@"The SteamOS archive must match this app's prepared image manifest."}]; + return NO; + } + maximumBytes = pinnedBytes; + } NSDictionary *attributes = archive ? [archive resourceValuesForKeys: @[NSURLIsRegularFileKey, NSURLIsSymbolicLinkKey, NSURLFileSizeKey] error:error] : nil; if (![attributes[NSURLIsRegularFileKey] boolValue] || [attributes[NSURLIsSymbolicLinkKey] boolValue] || - [attributes[NSURLFileSizeKey] unsignedLongLongValue] > 512ULL * 1024 * 1024) { + [attributes[NSURLFileSizeKey] unsignedLongLongValue] > maximumBytes || + (pinnedBytes && [attributes[NSURLFileSizeKey] unsignedLongLongValue] != pinnedBytes)) { if (error) *error = [NSError errorWithDomain:@"dev.linuxrunner.rootfs" code:EINVAL - userInfo:@{NSLocalizedDescriptionKey:@"Select a regular rootfs archive up to 512 MiB."}]; + userInfo:@{NSLocalizedDescriptionKey:pinnedBytes ? @"Select the prepared SteamOS archive with the expected size." : @"Select a regular rootfs archive up to 512 MiB."}]; return NO; } NSURL *verifiedArchive = [NSFileManager.defaultManager.temporaryDirectory URLByAppendingPathComponent:[NSString stringWithFormat:@"rootfs-%@.tar.gz", NSUUID.UUID.UUIDString]]; - if (!archive || ![NSFileManager.defaultManager copyItemAtURL:archive toURL:verifiedArchive error:error]) - return NO; - - NSInputStream *stream = [NSInputStream inputStreamWithURL:verifiedArchive]; + int input = open(archive.fileSystemRepresentation, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + struct stat sourceStat; + if (input >= 0 && (fstat(input, &sourceStat) < 0 || !S_ISREG(sourceStat.st_mode) || sourceStat.st_size <= 0 || (unsigned long long)sourceStat.st_size > maximumBytes)) { + close(input); input = -1; errno = EINVAL; + } + int output = input < 0 ? -1 : open(verifiedArchive.fileSystemRepresentation, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600); + int ioError = input < 0 || output < 0 ? errno : 0; + BOOL cancelled = NO; CC_SHA256_CTX hash; CC_SHA256_Init(&hash); - [stream open]; uint8_t buffer[65536]; NSInteger count; - while ((count = [stream read:buffer maxLength:sizeof(buffer)]) > 0) + uint8_t buffer[65536]; ssize_t count = 0; unsigned long long copied = 0; + while (!ioError) { + if (progress && [progress shouldCancel]) { cancelled = YES; break; } + count = read(input, buffer, sizeof buffer); + if (count < 0 && errno == EINTR) continue; + if (count < 0) { ioError = errno; break; } + if (!count) break; + copied += count; + if (copied > maximumBytes) { ioError = EFBIG; break; } + ssize_t written = 0; + while (written < count) { + ssize_t n = write(output, buffer + written, count - written); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { ioError = n < 0 ? errno : EIO; break; } + written += n; + } + if (ioError) break; CC_SHA256_Update(&hash, buffer, (CC_LONG)count); - [stream close]; uint8_t digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256_Final(digest, &hash); + [progress updateProgress:maximumBytes ? .2 * (double)copied / maximumBytes : 0 message:@"Copying and verifying the selected filesystem"]; + } + if (input >= 0) close(input); + if (output >= 0 && close(output) < 0 && !ioError) ioError = errno; + uint8_t digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256_Final(digest, &hash); NSMutableString *actual = [NSMutableString new]; for (NSUInteger i = 0; i < sizeof(digest); i++) [actual appendFormat:@"%02x", digest[i]]; - if (!stream || count < 0 || expected.length != 64 || ![actual isEqual:expected]) { + if (ioError || cancelled || (pinnedBytes && copied != pinnedBytes) || expected.length != 64 || ![actual isEqual:expected]) { [NSFileManager.defaultManager removeItemAtURL:verifiedArchive error:nil]; - if (error) *error = [NSError errorWithDomain:@"dev.linuxrunner.rootfs" code:EINVAL - userInfo:@{NSLocalizedDescriptionKey:@"Rootfs integrity verification failed. The archive differs from its authenticated installation manifest."}]; + if (error) *error = cancelled ? [NSError errorWithDomain:NSCocoaErrorDomain code:NSUserCancelledError userInfo:nil] : + [NSError errorWithDomain:@"dev.linuxrunner.rootfs" code:ioError ?: EINVAL + userInfo:@{NSLocalizedDescriptionKey:ioError ? [NSString stringWithFormat:@"The filesystem could not be copied: %s", strerror(ioError)] : @"Rootfs integrity verification failed. The archive differs from its installation manifest."}]; return NO; } #endif @@ -216,7 +269,13 @@ - (BOOL)importVerifiedRootFromArchive:(NSURL *)archive name:(NSString *)name sha #endif BOOL imported = fakefs_import(importArchive.fileSystemRepresentation, tempDestination.fileSystemRepresentation, - &fs_err, (struct progress) {(__bridge void *) progress, root_progress_callback}); + &fs_err, (struct progress) {(__bridge void *) progress, +#ifdef LINUXRUNNER + root_import_progress_callback +#else + root_progress_callback +#endif + }); #ifdef LINUXRUNNER [NSFileManager.defaultManager removeItemAtURL:verifiedArchive error:nil]; #endif @@ -224,12 +283,12 @@ - (BOOL)importVerifiedRootFromArchive:(NSURL *)archive name:(NSString *)name sha NSString *domain = NSPOSIXErrorDomain; if (fs_err.type == ERR_SQLITE) domain = @"SQLite"; - *error = [NSError errorWithDomain:domain + if (error) *error = [NSError errorWithDomain:domain code:fs_err.code userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"%s, line %d", fs_err.message, fs_err.line]}]; - if (fs_err.type == ERR_CANCELLED) - *error = nil; + if (error && fs_err.type == ERR_CANCELLED) + *error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSUserCancelledError userInfo:nil]; free(fs_err.message); [NSFileManager.defaultManager removeItemAtURL:tempDestination error:nil]; return NO; @@ -238,8 +297,10 @@ - (BOOL)importVerifiedRootFromArchive:(NSURL *)archive name:(NSString *)name sha NSData *installation=[NSJSONSerialization dataWithJSONObject:metadata options:NSJSONWritingPrettyPrinted error:error]; if(!installation||![installation writeToURL:[tempDestination URLByAppendingPathComponent:@"environment-install.json"] options:NSDataWritingAtomic error:error]){[NSFileManager.defaultManager removeItemAtURL:tempDestination error:nil];return NO;} #endif - if (![NSFileManager.defaultManager moveItemAtURL:tempDestination toURL:destination error:error]) + if (![NSFileManager.defaultManager moveItemAtURL:tempDestination toURL:destination error:error]) { + [NSFileManager.defaultManager removeItemAtURL:tempDestination error:nil]; return NO; + } void (^addRoot)(void) = ^{ [[self mutableOrderedSetValueForKey:@"roots"] addObject:name]; diff --git a/docs/BUILDING.md b/docs/BUILDING.md index ea80d3a..b6e8a50 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -89,3 +89,7 @@ python3 scripts/generate-gui-support-manifest.py --check ``` Use disposable fixtures for mutating tests. [Architecture](ARCHITECTURE.md) · [Third-party notices](../THIRD_PARTY_NOTICES.md) + +For the optional local SteamFrame image exporter and prepared ARM64 installer, +see [SteamOS userspace experiment](STEAMOS_EXPERIMENT.md). The archive is supplied +separately from the IPA and must match the bundled manifest. diff --git a/docs/ROOT_IMPORT.md b/docs/ROOT_IMPORT.md new file mode 100644 index 0000000..7cae4c2 --- /dev/null +++ b/docs/ROOT_IMPORT.md @@ -0,0 +1,26 @@ +# Verified archive import + +LinuxRunner snapshots an authenticated archive into a private, exclusive file, +checks its SHA-256 while copying, and extracts those exact bytes. Copying is +bounded, does not follow a final symlink, and supports cancellation. The generic +512 MiB limit remains; a prepared SteamOS archive additionally requires the +bundled manifest's architecture, source-image hash, archive hash and exact size +(up to 6 GiB). No SteamOS image or installer is introduced by this change. + +PAX UTF-8 names may be unavailable in the host's C locale. The importer accepts +only libarchive's four exact PAX locale-conversion warnings, after validating the +original pathname, link and account-name bytes as UTF-8. Other warnings and codec +failures remain errors. The app does not change global or per-thread locale. +Every import exit closes descriptors, SQLite and archive state; failed staging +and private copies are removed. Cancellation returns NSUserCancelledError. + +Run `tests/linuxrunner/steamos/run-root-import.sh` on macOS after initializing +the pinned libarchive submodule. It compiles the current importer and a static +libarchive with gzip support, using disposable directories and a test-only app +bundle. The fixture verifies Unicode file/symlink bytes in the C locale, locale +preservation, invalid UTF-8 and truncated gzip rejection, wrong SHA/source pin, +symlink rejection, cancellation during copy/extraction, successful publication, +and preservation of the source archive. It does not access the iOS app container. + +The extracted PR passed this host fixture on Apple Silicon macOS with Xcode +clang. It has not been independently installed or tested on a physical iPad. diff --git a/docs/STEAMOS_EXPERIMENT.md b/docs/STEAMOS_EXPERIMENT.md new file mode 100644 index 0000000..063a6de --- /dev/null +++ b/docs/STEAMOS_EXPERIMENT.md @@ -0,0 +1,546 @@ +# SteamOS ARM64 userspace experiment + +> Scope: the consolidated upstream SteamOS PR combines reviewed fork PRs #5 +> (verified importing), #8 (installer) and #10 (guest graphics). It is based on +> upstream main. Full accelerated desktop use additionally needs the separate +> grouped desktop and GPU compatibility PRs; storage remains upstream PR #18. +> All final component heads passed external Codex review before merging into +> the fork. Prior physical observations below describe the integration, not an +> independent installation of this grouped branch. Historical capture opt-in +> and leak discussions predate the separately reviewed guarded default and +> cleanup implementation. Their original evidence and limits remain retained. + +The supplied SteamFrame repair image runs a terminal and an X11 KDE Plasma +desktop in LinuxRunner on a physical iPad running iPadOS 27.2, after initial +validation in the macOS ARM64 host runtime. The app offers an experimental +installer for the specifically prepared image, with a bundled archive checksum, +storage checks, cancellation and regular Linux account setup. The user confirmed +KDE's wallpaper and panel, screen taps, Magic Keyboard trackpad clicks and +hardware typing into Konsole, where `uname -m` printed `aarch64`. + +## Observed results + +Tested on 2026-11-02 against LinuxRunner source `d141978`, using the interpreter +with JIT disabled and a real ARM64 guest vDSO: + +- Bash 5.3.3, `uname -m` (`aarch64`), user identity, file write/read and + fork/wait passed. +- Pacman 6.1.0 ran and queried the image's existing package database: + `bash 5.3.3-3`. Package downloads, installation and upgrades were not tested. +- Xvfb at 960 × 540, KWin X11, Plasma and Konsole rendered under a dedicated + regular guest account. The desktop probe checked managed window classes for + both `plasmashell` and `konsole` and captured the framebuffer. +- Graphics used Qt software rendering with KWin compositing disabled. This + does not establish Metal, Vulkan, gamescope, Steam client or game support. +- The managed app desktop profile passed its offline preparation check, started + Plasma as a regular user through the normal session launcher, published + `ready=1` and answered the RFB 3.8 handshake through x11vnc on the host. +- Native importer tests passed checksum rejection, source-pin rejection, + symlink rejection, cancellation during copying and extraction, and source + preservation. Swift manifest tests exercise size bounds and overflow. +- The first iPad import exposed PAX UTF-8 filename conversion under iOS's C + locale. The importer now preserves the original name bytes supplied by + libarchive's PAX fallback for that specific conversion warning, after checking + that the fields contain valid UTF-8. It requires no installed UTF-8 locale and + changes no thread/process locale. Other parse/codec warnings remain fatal. + A regression linked against the bundled libarchive 3.4.3 reproduced the old + failure and tests non-ASCII filenames, symlink targets, malformed UTF-8 and + truncated gzip rejection under the C locale. Archive integrity is checked + separately against the pinned SHA-256 before extraction. Failed/cancelled + imports also release their archive, database and file handles. +- The first installed iPad session reached X11 and KWin but its Plasma panel + and desktop surface did not become ready within four minutes. Discover's + Flatpak notifier crashed, and iPadOS rejected several host memory mappings. + The SteamOS session now defaults Discover, Baloo indexing, the Python firewall + applet and IBus out of autostart, preserving explicit per-account overrides. + Baloo attempted a 256 GiB shared mapping; these services are outside the + desktop/terminal milestone. The updated test app was signed with expanded + virtual addressing through `SteamOSExperiment.entitlements`, then installed + and launched on the iPad. This does not enable JIT or change the anonymous + memory budget. The updated host session passed panel/surface readiness and + ran an ARM64 shell through Konsole with those applets absent. The iPad then + reported `ready=1` with panel and surface present after 15 seconds, and a user + screenshot confirmed KDE's wallpaper and panel. A `tags:/` error prompted + explicitly disabling indexing in KDE's configuration as well as autostart. +- Physical mouse testing exposed a generic input-source transition bug: the + first GCMouse press could be accepted before UIKit identified an indirect + pointer, after which its release was ignored and the button remained held. + GCMouse now consistently owns physical buttons while UIKit supplies absolute + pointer positions. Duplicate UIKit button events are suppressed; direct + finger/Pencil clicks and UIKit-only pointers remain supported. Policy tests + cover the transition, and input diagnostics record forwarded button edges. + The updated app was installed and launched on the iPad. Direct screen taps + worked before this fix, and the user confirmed Magic Keyboard trackpad clicks + afterward. The native RFB transport probe preserved all button, + key and wheel transitions during 9,051 queued input events, while coalescing + redundant pointer motion. +- Subsequent iPad interaction exposed a Plasma crash in Qt's raster painter + called by `breeze6.so`. The SteamOS session selects Qt's Fusion widget style, + including applications activated through its session bus. Hardware typing + also requires the native framebuffer view to become first responder: it now + requests that focus on a desktop click when a hardware keyboard is present. + Focus and key-event counters omit typed content. A host probe sent a command + through the native RFB client into Konsole and verified the file written by + that shell; this establishes transport/terminal input separately from UIKit + hardware-keyboard focus. +- The final updated app was built, installed and launched on the same iPad. + Its trace recorded panel/surface readiness after 18 seconds, successful native + keyboard focus and hardware key-down/key-up delivery. The user confirmed that + typing into Konsole worked and `uname -m` printed `aarch64`. This completes the + initial physical desktop/terminal milestone. Longer interactive sessions, + restarts across OS updates and Steam/game compatibility are not qualified. + +The image's default Wayland session invokes +`start-gamescope-session plasma-session.target`. The experiment instead uses +`dbus-run-session -- startplasma-x11` and sets `systemdBoot=false` in the test +user's `startkderc`. Steam autostart is disabled for that account. Auxiliary +services still report missing system bus, FUSE, PipeWire and polkit integration; +the desktop result does not qualify those services or long-running interactive +use. + +## Software-renderer notification + +The verified V7 iPad build uses software rendering, so Plasma's **Software +Renderer In Use** notification is expected there. The new GPU candidate adds +an opt-in path under **Settings → Graphics → Linux GPU Device → GPU → Metal +GPU · Experimental**. Force-close and reopen LinuxRunner after changing it, +then open the existing desktop. No rootfs reinstall is needed. + +The candidate uses Xorg modesetting/glamor and Qt Quick OpenGL on virgl/Metal. +KWin window effects remain disabled: its compositor does not yet finish +initializing reliably in this environment. This does not qualify games, +Vulkan, arbitrary OpenGL applications or every border-color mode. The Linux +application renderer picker and Metal framebuffer presentation are separate +controls; neither replaces the Linux GPU Device setting. + +### Reusing the Arch GPU stack + +SteamOS reuses the repository's virtual DRM/KMS device, Mesa virgl driver, +host virglrenderer and ANGLE Metal backend. The current Arch Linux ARM Mesa +package (`1:26.2.6-1`) requires `GLIBC_2.43`; this image supplies glibc 2.39. +Installing that package or replacing glibc is not a compatible driver update. + +Instead, Mesa 26.3 development revision +`590bf21d918c86908d96d1f4590ecd25b9657171` was cross-compiled against the +image's headers and libraries, with prefix `/opt/linuxrunner/steamos-gpu`. +System Mesa remains unchanged. The Meson options are: + +```text +--prefix=/opt/linuxrunner/steamos-gpu --libdir=lib --buildtype=release +-Dgallium-drivers=virgl -Dvulkan-drivers= -Dplatforms=x11,wayland +-Dglx=dri -Dglvnd=enabled -Degl=enabled -Dgbm=enabled -Dllvm=disabled +-Dgallium-va=disabled -Dgallium-rusticl=false -Dgles1=disabled -Dgles2=enabled +-Dbuild-tests=false -Dtools= -Dvideo-codecs= -Dvalgrind=disabled -Dlibunwind=disabled +``` + +Use an AArch64 Linux cross toolchain, matching Wayland 1.26 scanner, LLVM +archiver and the image's glibc 2.39/GCC 15.1.1 sysroot. Apple `ar` does not +produce suitable ELF archive indexes. The local experiment's generated +cross file and compiler wrappers are under `build/steamos-gpu-deps`; its +configure arguments are retained in `build/steamos-device/mesa-configure-input.json`. +Never build against the host macOS SDK or newer Arch libraries. + +The host bridge implements transparent-zero sampler borders using Metal's +clamp-to-zero. ANGLE keeps the full GLES border-color extension unadvertised; +virglrenderer explicitly rejects unsupported nonzero borders. The host also +resets its GL context before embedder operations to protect guest framebuffer +state. Virtual CRTC time advances at 60 Hz independently of page flips, and +modern/legacy vblank events wake Xorg/Present asynchronously. Event queues are +bounded, and closing a file cancels and joins its event worker. + +### Package and validate an update + +Build the pinned host/iOS dependencies as described in `BUILDING.md`. Build +`tests/linuxrunner/gpu/virgl_border_contract.c` against the image's sysroot +with `-lEGL -lGLESv2 -lgbm`, then package Mesa's staged prefix: + +```sh +python3 tools/steamos/package-gpu-overlay.py \ + --stage build/steamos-device/gpu-stage/opt/linuxrunner/steamos-gpu \ + --mesa-source build/steamos-gpu-deps/mesa \ + --probe build/steamos-device/gpu-probe/virgl-border-contract \ + --output build/steamos-device/gpu-package --bundle +``` + +The packager uses the standalone probe source when the host GPU prerequisite +is present. In this scoped checkout it can recover the same preferred source +from the checksum-verified bundled overlay, checking its BUILD.json identity. +A refusal to modify custom tags-worker paths is nonfatal to desktop startup. + +The output directory must be new. The packager checks architecture and +symlinks, retains licenses/source identity, normalizes archive metadata, and +regenerates the GUI resource manifest. `steamos-gpu.sh` verifies and stages +this overlay before publishing the isolated prefix. Existing installations +receive it on the next GPU desktop start. Missing GPU support or a failed +startup pixel check selects the established software desktop. + +Each GPU desktop start records the renderer and seven RGBA border/filtering +comparisons in `graphics.log` before starting Xorg. `xorg.log` records the +actual glamor driver. GUI diagnostic exports include both files. Select +Software Rendering and restart LinuxRunner to return to the software path. + +The macOS ARM64 host checks pass for: + +- All four existing `virgl_render_contract.c` pixel comparisons. +- Seven transparent-border pixel comparisons, including filtered edges and + mixed wrap modes, plus rejection of unsupported nonzero sampler borders. +- CRTC time without flips, asynchronous modern/legacy events, queue bounds, + cancellation on close and the existing KMS/PRIME contract. +- The packaged driver installation, Xorg/Qt renderer identity, visible KDE + panel and desktop, Konsole launch and fresh RFB keyboard output. +- Shift punctuation/case (`1!Aa?A`) through Konsole, including releasing Shift + before the letter key. `keyboard_policy_test.m` also checks layout-aware + text, Caps Lock, Control/Shift and Option shortcut mapping. + +The GPU-capable V8 app was built, signed, installed and launched on the +connected M4 iPad using MobileBuildMCP 2.7.1, with JIT disabled. The physical +startup log identifies virgl/ANGLE on the Apple M4 GPU, all seven startup pixel +comparisons pass, and Xorg reports the accelerated backend. The operator +confirmed the desktop looks correct and Magic Keyboard Shift produces `!` +and `A`. KWin effects remain disabled. The previously verified V7 software +app is retained locally for rollback. + +### Resolution and performance follow-up + +The same iPad reported a 1566 × 975 point viewer with a fixed 960 × 540 Linux +desktop. SteamOS on iPad now defaults to Fit Screen, measured from the actual +viewer after immersive layout. The old Wide default is migrated once for this +environment; subsequent explicit choices and other distributions are retained. +Fit uses logical points with the existing 1600 × 1200 cap, not Retina pixels. +Changing guest resolution still requires stopping and starting the desktop. + +For SteamOS with the Linux GPU active, the viewer defaults to its existing +Metal presenter unless the user has explicitly chosen Core Graphics. It +uploads accumulated damage to reusable textures rather than taking a complete +Core Graphics framebuffer snapshot for each update. The existing single-thread +adaptive x11vnc policy reduces capture delays during recent input while retaining +25 ms idle polling. The threaded input mode remains opt-in because it previously +failed browser text-integrity checks. + +A physical Time Profiler recording also identified `StorageUsage.bytes` under +`EnvironmentManager.refresh` as 57.5% of running CPU samples in its captured +interval. The retained dSYM UUID is `7AAD93F0-A52E-3F15-A9B4-94D01271185C`; +the profile's main return address establishes image base `0x104888000`. +`atos` resolves the hot native frames to the storage walk and manager refresh. +The manager now cancels a running storage walk when a graphical viewer opens, +defers automatic storage refresh until it closes. Complete cached storage totals remain visible; cancelled +walks never publish partial or zero totals. Cancellation is checked between +files, and generation checks prevent an old task overwriting a newer scan. +The full Storage settings measurement remains available on request. + +The prepared SteamOS profile also defaults Linux PowerDevil and the polkit +agent out of autostart, preserving explicit account overrides. iPadOS provides +the host's power/brightness/authentication; this environment lacks the Linux +system bus those agents repeatedly query. These changes are candidates for +physical performance measurement, not a claim of a particular FPS improvement. + +KIO 6.14 unconditionally enumerates `tags:/` when its worker is installed, even +with Baloo indexing disabled. Disabling the daemon/config alone therefore did +not prevent the recurring error. For accounts using the managed Baloo-off +default, `steamos-desktop.sh` retains `tags.so` under +`/opt/linuxrunner/steamos-disabled-plugins`, outside Qt's plugin search path. +It does not delete the plugin or an index. An explicit account autostart +override restores the saved worker at next desktop start. To restore it manually, +temporarily run `sh steamos-desktop.sh restore-tags` as root from the managed support directory. +Unrecognized replacement copies and symlinks are left untouched. + +The opt-in `LinuxRunnerDesktop.performanceDiagnostics` launch preference writes +bounded session-local `performance.jsonl`: monotonic time, process CPU time, +physical memory footprint, thermal category, received updates, presenter counters +and aggregate input latency. It records no typed content. The focused XCTest +`testSteamOSDeviceDesktopPerformance` requires explicit bundle/root environment +variables and exercises the existing desktop through real device UI automation. +It does not import or reinstall Linux. + +The V9 and storage-only V11 physical UI runs both passed desktop readiness, real touch/text +input into Konsole, and visible `LR_PERF_aarch64` output. The final iPad session +used a 1560 × 1112 guest framebuffer for a 1566 × 1119 point viewer, selected +Metal presentation and adaptive capture, passed all seven GPU pixel checks, +and had no `tags:/` errors in its Plasma log. + +In the paired native metrics snapshots 30–60 seconds after session metrics +began, before scripted interaction, process CPU use was 122.3% of one core +with the background storage scan and 37.2% after cancellation. The paired 30-second windows +had nominal thermal state and at most one display update. This is one paired +quiet-window measurement of process CPU, not a desktop FPS or sustained +performance claim. The earlier CPU trace independently identifies the storage +walk. The final app's framebuffer upload CPU time remains a component counter, +not an end-to-end display measurement. + +The controlled macOS Xlib fixture also completed 30 click/repaint samples per +policy: legacy median 91.9 ms, adaptive 26.7 ms (p95 94.8 and 79.7 ms). This +measures Linux input through native RFB decode at 640 × 480; it excludes physical +UIKit and GPU completion and does not establish SteamOS desktop FPS. + +### Verified GPU, capture and text follow-up + +A fresh physical iPad audit identifies `ANGLE Metal Renderer: Apple M4 GPU` in +both Xorg's enabled glamor backend and Plasma's QRhi/OpenGL context. All seven +startup GPU pixel checks pass. This establishes accelerated rendering for those +components; KWin effects remain off and guest CPU instructions are interpreted. + +The panel is **2732 × 2048**, while this session renders a **1560 × 1112** Linux +framebuffer in a **1566 × 1119 point** viewer. iPadOS reports backing scale 2 and +native scale 1.7181208053691275. The app's screenshot/backing resolution therefore +differs from the physical panel. Fit Screen's logical-point size/cap is a +LinuxRunner policy, not a headset display setting. Full native-resolution/DPI +rendering is not implemented by this follow-up; it requires a larger guest +framebuffer and more capture/rendering work. + +The same-binary socket/shared-image comparison passed physical desktop readiness, +Konsole, `aarch64`, synthesized input and a bounded terminal repaint workload. +x11vnc's startup framebuffer-read calibration increased from **31 to 1226 MB/s** +with shared images. That is a component check, not a desktop FPS or sustained +bandwidth result. Shared capture alone used **40.8% versus 38.1% of one CPU core** +in the retained paired quiet window. Only two touch events were measured per run, +so the result does not establish an interaction-latency improvement. + +SysV shared-image capture remains explicitly opt-in through the existing X11 +shared-images switch. The independent review found that x11vnc marks successful +segments for deletion during cleanup; a forced `SIGKILL` skips that cleanup and +can retain their backing resources until the LinuxRunner app process exits. +Session-owned crash cleanup has not been qualified, so no distribution receives +an automatic shared-capture default. Existing explicit choices are preserved. +After a forcibly terminated shared-capture session, fully restart LinuxRunner +before retrying; restarting the desktop alone does not reclaim orphan segments. + +The established adaptive timing is retained; the slower idle policy did not +establish better interaction. A short device profile still found substantial +Xorg/x11vnc interpreter activity; native Metal presentation upload CPU time was +small in these runs. The evidence does not isolate X11 control-message costs or +justify replacing their transport with a custom shared-memory command queue. +Such a queue would still need ordering, wakeups, ownership and crash recovery. + +The font audit found Noto Sans/Noto Sans Mono and standard RGB subpixel +antialiasing at 96 DPI. The managed SteamOS default now chooses grayscale +antialiasing (`Xft.rgba: none`) for this scaled/rotatable virtual display. +`steamos-fonts.sh` uses KConfig, preserves explicit account subpixel preferences +and custom configuration paths, and cannot prevent desktop startup on failure. +This avoids assuming a fixed RGB stripe layout through scaling; subjective text +clarity and a full native-resolution desktop remain separate questions. + +[Allowlisted measurements and limits](../tests/linuxrunner/steamos/graphics-followup-2026-11-02.json) +retain the paired counters and binary UUID without raw device/account logs. +`test_steamos_font_defaults.py` verifies defaults and preservation behavior; +`test_desktop_configuration.py` checks existing configurations and shell syntax. +The focused physical UI test has explicit capture overrides for comparisons and +an optional short smoke mode, which omits the quiet-window measurement. +The pre-review build passed that physical smoke test through completed 30-line +terminal output, with its then-default shared capture, adaptive timing, grayscale +fonts, and Xorg/Plasma M4 renderer identity confirmed again. Those receipts are +retained as historical validation of that candidate, not of a default promotion. +The review correction restores opt-in capture; a subsequent physical SteamOS +startup reached desktop readiness with `shared-images=off`, adaptive timing and +grayscale fonts. The final post-Arch short test also passed Konsole input, +`aarch64` and the completed 30-line repaint workload with capture off and no +launch override. Xorg/Plasma again identified the M4, and all seven GPU pixel +checks passed. The font audit found `Xft.rgba: none`; this existing account's +explicit key was preserved. Six font-default checks and thirteen existing +desktop-configuration/shell-syntax checks passed on the host. The installed +root filesystem was reused throughout. + +The [supported Arch reference](ARCH_REFERENCE.md) is now installed and passes +basic desktop/xterm input qualification too. Its D-Bus startup exposed a native +Unix-socket backing-path limit, corrected independently of the graphics policy. +Arch uses its existing lightweight software desktop at 960 × 540, so this is a +regression reference rather than a matched Plasma graphics benchmark. + +A subsequent [controlled active capture comparison](CAPTURE_COMPARISON.md) +measured 30 click and 30 keyboard responses per mode, plus numbered full-screen +repaints, on both Arch and SteamOS. Shared capture improved those native +submission timings and delivered more distinct repaints. These are synthetic +capture-path measurements, with a recorded thermal-state difference in the +SteamOS shared run; GPU completion and physical panel latency remain unmeasured. +The known forced-exit resource lifetime still keeps shared capture opt-in. + +Further performance work should measure sustained real-application workloads +and native/panel presentation separately. The host GPU bridge currently waits +for completion after each command submission; changing that requires real +asynchronous fence and DRM wait semantics, rather than removing correctness +barriers. KWin effects and game compatibility remain outside this result. + +## Networking + +LinuxRunner translates guest Linux sockets to iPadOS sockets. Wi-Fi and +Ethernet hardware remain managed by iPadOS; Linux Wi-Fi kernel modules and +firmware from Arch or SteamOS are not used in this userspace environment. +The app's existing DNS setup supplies the iPadOS resolver to every distro, +including SteamOS. + +The maintained read-only check is `tests/linuxrunner/steamos/network.sh`. It +checks IPv4 name resolution and an HTTPS request with certificate verification +and HTTPS-only redirects. Both checks passed in the disposable host root. +On 2026-11-02 the operator also ran the equivalent `getent` and `curl` commands +in Konsole on the connected iPad and reported `DNS_OK` and `HTTPS_OK`. This +confirms DNS and outbound HTTPS on that iPad's current connection; Steam login, +downloads and other network services have not been qualified. + +## Source image + +Local filename: `SteamFrame OOBE Repair 2026-09-22.img.bz2`. + +Local compressed-image SHA-256: + +```text +3a4a077f1b1f40688ab3279affcb56776bd97c54db1573e7c65fc52a97106676 +``` + +This records the supplied bytes; it is not an independently authenticated Valve +checksum. The initial image probes were offline. Device-image preparation adds +two pinned Arch Linux ARM display packages; it does not use the SteamOS image's +package repositories. + +The image is a 7 GiB GPT disk containing a 5 GiB Btrfs `rootfs-A` partition. +Its `/etc/os-release` identifies SteamOS `0.3.0`, variant `vr`, build +`20260922.5152327`. Bash, Pacman, Xvfb, KWin and Plasma are ARM64 ELF binaries. +The root partition's type GUID advertises x86-64 despite those ARM64 contents; +the exporter checks the binaries themselves. + +## Inspect and export locally + +Use Python 3.10–3.14; the pinned Btrfs parser is incompatible with Python 3.15. +The tool reads a local raw `.img` or `.img.bz2`, validates GPT checksums and +SteamOS/ARM64 identity, and optionally writes an uncompressed userspace tar. +It never runs image scripts or extracts guest paths onto the host filesystem. +Existing output files are refused. + +```sh +python3.14 -m venv build/steamos-tools +build/steamos-tools/bin/pip install -r tools/steamos/requirements.txt +mkdir -p build/steamos-probe + +# Inspection only. Replace the image path with your local copy. +build/steamos-tools/bin/python tools/steamos/export-steamframe.py \ + '/path/to/SteamFrame OOBE Repair 2026-09-22.img.bz2' \ + --report build/steamos-probe/image-report.json + +# Export separately, or combine --report and --output in one invocation. +build/steamos-tools/bin/python tools/steamos/export-steamframe.py \ + '/path/to/SteamFrame OOBE Repair 2026-09-22.img.bz2' \ + --output build/steamos-probe/steamos-rootfs.tar +``` + +Btrfs requires random access. A compressed input is decompressed into a +temporary file, with a default 16 GiB limit. Passing an already decompressed +image avoids repeating that work. The tested export contains 226,048 entries +and is approximately 8.4 GiB; allow space for both the archive and an imported +filesystem, plus 7 GiB of temporary space when using compressed input. + +The export preserves Linux ownership, modes, symlinks and hardlinks. It omits +boot/EFI/persistent partitions, kernel modules, firmware and debug files; +`dev`, `proc`, `sys`, `run` and `tmp` are exported as empty directories. Separate +`var-A` and `home` partitions are not merged into the export. This is a +development userspace export, not a recovery or bootable image. + +## Reproduce the host checks + +Follow [Building LinuxRunner](BUILDING.md) for dependencies, including Meson, +Ninja, libarchive development files and `clang` with `ld.lld` on PATH. Require +the real vDSO: a stub-only host build failed Bash signal return during the +initial experiment. + +```sh +meson setup build/steamos-host -Dengine=asbestos -Dguest_arch=arm64 \ + -Djit=false -Djit_emit=false -Dcli_aot= -Dvdso_required=true +ninja -C build/steamos-host ish tools/fakefsify +``` + +Import onto a **case-sensitive** volume. The complete image contains both +`usr/lib/Xorg` (a file) and `usr/lib/xorg` (a directory); the import fails on a +case-insensitive macOS volume. For example, create an empty test disk image: + +```sh +hdiutil create -size 16g -type SPARSE -fs 'Case-sensitive APFS' \ + -volname LinuxRunnerSteamOSProbe build/steamos-probe/steamos-test.sparseimage +mkdir -p build/steamos-probe/case-sensitive +hdiutil attach -nobrowse -mountpoint "$PWD/build/steamos-probe/case-sensitive" \ + build/steamos-probe/steamos-test.sparseimage +build/steamos-host/tools/fakefsify build/steamos-probe/steamos-rootfs.tar \ + build/steamos-probe/case-sensitive/rootfs +export CPU_ROOTFS="$PWD/build/steamos-probe/case-sensitive/rootfs" +``` + +Run these checks only in that disposable filesystem. The desktop check writes +configuration for a dedicated test account, disables its Steam autostart, runs +for approximately 50 seconds, captures window identities and a framebuffer, +then stops the display. + +```sh +build/steamos-host/ish -f "$CPU_ROOTFS" /bin/bash --noprofile --norc \ + -ec "$(cat tests/linuxrunner/steamos/terminal.sh)" + +build/steamos-host/ish -f "$CPU_ROOTFS" /bin/bash --noprofile --norc -ec \ + 'id -u lrprobe >/dev/null 2>&1 || useradd -m -U -s /bin/bash lrprobe; exec runuser -u lrprobe -- /bin/bash --noprofile --norc -ec "$1"' \ + bash "$(cat tests/linuxrunner/steamos/desktop.sh)" +``` + +Expected markers are `STEAMOS_TERMINAL=PASS` and `STEAMOS_DESKTOP=PASS`. +Desktop logs and the XWD framebuffer are under the guest path +`/home/lrprobe/steamframe-probe/`. Save desired evidence before detaching the +test volume: + +```sh +hdiutil detach "$PWD/build/steamos-probe/case-sensitive" +``` + +The exporter contract tests run without third-party dependencies: + +```sh +python3.14 -m unittest discover -s tests/linuxrunner/steamos -v +``` + +## Prepared device image + +The app installs a prepared `rootfs.tar.gz`, rather than the raw disk image. +`SteamOSSupport.bundle/manifest.json` pins its exact size, SHA-256, original +image identity and expected installed size. Other distributions retain their +512 MiB import limit; only a matching SteamOS pin admits the larger archive. +Copying, verification and extraction run off the UI thread with cooperative +cancellation. A completed filesystem is published under a new environment ID; +the original archive and existing environments are kept. + +The device preparation tool requires the disposable case-sensitive fakefs root +from the previous steps. It adds checksum-pinned x11vnc 0.9.17 and +libvncserver 0.9.15 packages, records them in the SteamOS package database, and +builds the four LinuxRunner desktop helpers using that image's GCC. Their +checksums and source identities are checked by the app's offline desktop +profile. The root must be imported from the supplied image; use it only for +preparation, before creating test users or running desktop probes. + +```sh +ninja -C build/steamos-host tools/unfakefsify +python3.14 tools/steamos/prepare-device-image.py \ + --source-image '/path/to/SteamFrame OOBE Repair 2026-09-22.img.bz2' \ + --root "$CPU_ROOTFS" --cache build/steamos-arch-cache \ + --output build/steamos-package \ + --app-manifest app/LinuxRunner/SteamOSSupport.bundle/manifest.json +``` + +The prepared archive for this candidate is 3,883,082,382 bytes. Installation +needs approximately 13.3 GB of additional free space for its verified snapshot, +expanded filesystem and working reserve. Rebuilding the prepared image may +change its checksum: rebuild the app with the corresponding generated manifest. +This is a local-image workflow; the app provides no SteamOS download endpoint. + +Build and install the normal LinuxRunner scheme using your Apple development +team. For a separate test installation, override `ROOT_BUNDLE_IDENTIFIER` and +`LINUXRUNNER_DISPLAY_NAME` and use `app/LinuxRunner/SteamOSExperiment.entitlements` +for private-container storage with expanded virtual addressing on supported +devices. The signing profile must allow that entitlement. +Do not override `PRODUCT_NAME` globally: that also renames dependent libraries. + +Copy the prepared archive to the test app's `Documents/SteamOS/rootfs.tar.gz`, +or select it through **Choose Prepared SteamOS Archive…**. Open +**Distributions → Set Up Linux → Continue → SteamOS ARM64 → Continue → +Install Prepared SteamOS**. Keep the app in the foreground until completion. +When requested, force-close and reopen the app, create or select a regular +Linux account, then choose **Check SteamOS Desktop → Continue → Open Desktop**. + +The initial desktop uses Plasma X11 with software rendering and Steam autostart +disabled for newly configured accounts. Installed-package inspection is +available; managed repository updates and package changes are disabled for +this hardware-specific image experiment. Audio and system services remain +outside this milestone. Gamescope, Vulkan and local games are separate work. + +The isolated storage-lifecycle change and allowlisted before/after evidence were +submitted upstream as [PR #18](https://github.com/mxhm11cr/LinuxRunner/pull/18). diff --git a/iSH.xcodeproj/project.pbxproj b/iSH.xcodeproj/project.pbxproj index a75b41b..17c778b 100644 --- a/iSH.xcodeproj/project.pbxproj +++ b/iSH.xcodeproj/project.pbxproj @@ -25,6 +25,9 @@ 1C1100000000000000000004 /* DebianSupport.bundle */ = {isa = PBXBuildFile; fileRef = 1C1200000000000000000004; }; 1C1100000000000000000003 /* DebianInstaller.swift */ = {isa = PBXBuildFile; fileRef = 1C1200000000000000000003; }; 1C1100000000000000000A01 /* ArchInstaller.swift */ = {isa = PBXBuildFile; fileRef = 1C1200000000000000000A01; }; + 1C1100000000000000000B03 /* SteamOSSupport.bundle */ = {isa = PBXBuildFile; fileRef = 1C1200000000000000000B03; }; + 1C1100000000000000000B02 /* SteamOSInstaller.swift */ = {isa = PBXBuildFile; fileRef = 1C1200000000000000000B02; }; + 1C1100000000000000000B01 /* SteamOSManifest.swift */ = {isa = PBXBuildFile; fileRef = 1C1200000000000000000B01; }; 1C1100000000000000000002 /* DebianBootstrap.c */ = {isa = PBXBuildFile; fileRef = 1C1200000000000000000002; }; AABBCCDD1122334455667701 /* netlink.c in Sources */ = {isa = PBXBuildFile; fileRef = AABBCCDD1122334455667702 /* netlink.c */; }; 0FC2383C2991FBA6004C09EC /* mmx.c in Sources */ = {isa = PBXBuildFile; fileRef = 0FC2383B2991FB82004C09EC /* mmx.c */; }; @@ -556,6 +559,9 @@ 1C1200000000000000000004 /* DebianSupport.bundle */ = {isa = PBXFileReference; lastKnownFileType = wrapper.cfbundle; path = app/LinuxRunner/DebianSupport.bundle; sourceTree = SOURCE_ROOT; }; 1C1200000000000000000003 /* DebianInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = app/LinuxRunner/DebianInstaller.swift; sourceTree = SOURCE_ROOT; }; 1C1200000000000000000A01 /* ArchInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = app/LinuxRunner/ArchInstaller.swift; sourceTree = SOURCE_ROOT; }; + 1C1200000000000000000B03 /* SteamOSSupport.bundle */ = {isa = PBXFileReference; lastKnownFileType = wrapper.cfbundle; path = app/LinuxRunner/SteamOSSupport.bundle; sourceTree = SOURCE_ROOT; }; + 1C1200000000000000000B02 /* SteamOSInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = app/LinuxRunner/SteamOSInstaller.swift; sourceTree = SOURCE_ROOT; }; + 1C1200000000000000000B01 /* SteamOSManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = app/LinuxRunner/SteamOSManifest.swift; sourceTree = SOURCE_ROOT; }; 1C1200000000000000000002 /* DebianBootstrap.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = app/LinuxRunner/DebianBootstrap.c; sourceTree = SOURCE_ROOT; }; AABBCCDD1122334455667702 /* netlink.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = netlink.c; sourceTree = ""; }; 0FC2383B2991FB82004C09EC /* mmx.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = mmx.c; sourceTree = ""; }; @@ -1214,6 +1220,9 @@ 1C1200000000000000000004 /* DebianSupport.bundle */, 1C1200000000000000000003 /* DebianInstaller.swift */, 1C1200000000000000000A01 /* ArchInstaller.swift */, + 1C1200000000000000000B03 /* SteamOSSupport.bundle */, + 1C1200000000000000000B02 /* SteamOSInstaller.swift */, + 1C1200000000000000000B01 /* SteamOSManifest.swift */, 1C1200000000000000000002 /* DebianBootstrap.c */, BB792B521F96D90D00FFB7A4 /* app */, BB9C7B88240A343400F5D4F0 /* iSH.xcconfig */, @@ -2044,6 +2053,7 @@ 1F5300000000000000000048 /* LinuxRunnerLaunch.storyboard in Resources */, 1B1100000000000000000004 /* GUISupport.bundle in Resources */, 1C1100000000000000000004 /* DebianSupport.bundle */, + 1C1100000000000000000B03 /* SteamOSSupport.bundle */, CC000002AF96D90D00FFB7A4 /* RootfsPatch.bundle in Resources */, JB000001AF96D90D00FFB7A4 /* JetBrainsMonoNerdFontMono-Regular.ttf in Resources */, JB000002AF96D90D00FFB7A4 /* JetBrainsMonoNerdFontMono-Bold.ttf in Resources */, @@ -2596,6 +2606,8 @@ 1C1100000000000000000001 /* Diagnostics.swift in Sources */, 1C1100000000000000000003 /* DebianInstaller.swift */, 1C1100000000000000000A01 /* ArchInstaller.swift */, + 1C1100000000000000000B02 /* SteamOSInstaller.swift */, + 1C1100000000000000000B01 /* SteamOSManifest.swift */, 1C1100000000000000000002 /* DebianBootstrap.c */, AA000020AF96D90D00FFB7A4 /* main.m in Sources */, AA000021AF96D90D00FFB7A4 /* LocationDevice.m in Sources */, diff --git a/tests/linuxrunner/gui/test_steamos_font_defaults.py b/tests/linuxrunner/gui/test_steamos_font_defaults.py new file mode 100644 index 0000000..4ed6aba --- /dev/null +++ b/tests/linuxrunner/gui/test_steamos_font_defaults.py @@ -0,0 +1,73 @@ +"""Preserve explicit account settings when choosing virtual-display font defaults.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[3] / "app/LinuxRunner/GUISupport.bundle/steamos-fonts.sh" + + +class SteamOSFontDefaults(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.config = self.base / "config" + self.config.mkdir() + self.file = self.config / "kdeglobals" + self.calls = self.base / "calls.json" + self.bin = self.base / "bin" + self.bin.mkdir() + tool = self.bin / "kwriteconfig6" + tool.write_text("#!/usr/bin/env python3\nimport json,os,sys\nfrom pathlib import Path\nPath(os.environ['FONT_TEST_CALLS']).write_text(json.dumps(sys.argv[1:]))\n") + tool.chmod(0o755) + + def run_defaults(self): + return subprocess.run(["/bin/sh", str(SCRIPT)], check=True, capture_output=True, text=True, + env=dict(os.environ, PATH=f"{self.bin}:{os.environ['PATH']}", + XDG_CONFIG_HOME=str(self.config), FONT_TEST_CALLS=str(self.calls))) + + def test_default_requests_only_grayscale_key(self): + self.file.write_text("[General]\nColorScheme=Custom\n") + self.run_defaults() + self.assertEqual(json.loads(self.calls.read_text()), + ["--file", str(self.file), "--group", "General", "--key", "XftSubPixel", "none"]) + + def test_missing_file_can_receive_default(self): + self.run_defaults() + self.assertTrue(self.calls.exists()) + + def test_explicit_preferences_are_preserved(self): + for key in ["XftSubPixel", "XftSubPixel[$i]", "XftSubPixel[$e]", "XftSubPixel[$i][$e]"]: + for value in ["rgb", "none", "bgr"]: + original = f"[General]\n{key}={value}\n" + self.file.write_text(original) + self.run_defaults() + self.assertFalse(self.calls.exists()) + self.assertEqual(self.file.read_text(), original) + + def test_symlink_is_preserved(self): + target = self.base / "custom" + target.write_text("custom file") + self.file.symlink_to(target) + self.run_defaults() + self.assertFalse(self.calls.exists()) + self.assertEqual(target.read_text(), "custom file") + + def test_existing_directory_is_preserved(self): + self.file.mkdir() + self.run_defaults() + self.assertFalse(self.calls.exists()) + + def test_unavailable_configuration_directory_is_nonfatal(self): + self.config.rmdir() + self.config.write_text("user file") + self.run_defaults() + self.assertFalse(self.calls.exists()) + self.assertEqual(self.config.read_text(), "user file") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/linuxrunner/steamos/RootImportTests.m b/tests/linuxrunner/steamos/RootImportTests.m new file mode 100644 index 0000000..61bd918 --- /dev/null +++ b/tests/linuxrunner/steamos/RootImportTests.m @@ -0,0 +1,119 @@ +#import +#import +#import "app/Roots.h" +#include +#include +#include +#ifdef __APPLE__ +#include +#endif + +// Isolated native importer test. No app container or user preferences are used. +static NSURL *fixture; +NSURL *ContainerURL(void) { return fixture; } +void LRBootEvent(const char *message) { (void)message; } +static NSString *digestOf(NSData *data) { + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256(data.bytes, (CC_LONG)data.length, digest); + NSMutableString *text = [NSMutableString new]; + for (NSUInteger i = 0; i < sizeof digest; i++) [text appendFormat:@"%02x", digest[i]]; + return text; +} + +@interface TestProgress : NSObject +@property BOOL cancelled; +@property BOOL cancelDuringImport; +@end +@implementation TestProgress +- (BOOL)shouldCancel { return self.cancelled; } +- (void)updateProgress:(double)value message:(NSString *)message { + (void)message; + if (self.cancelDuringImport && value >= .2) self.cancelled = YES; +} +@end + +int main(void) { @autoreleasepool { + assert(setlocale(LC_ALL, "C")); + locale_t initialLocale = uselocale((locale_t)0); + NSFileManager *files = NSFileManager.defaultManager; + fixture = [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:NSUUID.UUID.UUIDString]]; + NSURL *content = [fixture URLByAppendingPathComponent:@"content/etc"]; + assert([files createDirectoryAtURL:content withIntermediateDirectories:YES attributes:nil error:nil]); + NSData *original = [@"ID=steamos\n" dataUsingEncoding:NSUTF8StringEncoding]; + assert([original writeToURL:[content URLByAppendingPathComponent:@"os-release"] atomically:YES]); + NSString *unicodeName = @"Főtanúsítvány-日本語.crt"; + assert([original writeToURL:[content URLByAppendingPathComponent:unicodeName] atomically:YES]); + assert([files createSymbolicLinkAtPath:[content URLByAppendingPathComponent:@"unicode-link"].path withDestinationPath:unicodeName error:nil]); + // Foundation supplies the filesystem spelling (NFD on macOS) to symlink(). + // Compare the imported link with those actual source bytes, not an NFC + // spelling that was never present in the archive fixture. + char sourceLink[4096]; + ssize_t sourceLinkLength = readlink([content URLByAppendingPathComponent:@"unicode-link"].fileSystemRepresentation, sourceLink, sizeof sourceLink); + assert(sourceLinkLength > 0); + NSData *expectedLink = [NSData dataWithBytes:sourceLink length:(NSUInteger)sourceLinkLength]; + NSURL *archive = [fixture URLByAppendingPathComponent:@"rootfs.tar.gz"]; + NSTask *tar = [NSTask new]; tar.executableURL = [NSURL fileURLWithPath:@"/usr/bin/tar"]; + tar.arguments = @[@"--format=pax", @"-czf", archive.path, @"-C", content.URLByDeletingLastPathComponent.path, @"etc"]; + tar.environment = @{@"LC_ALL":@"en_US.UTF-8", @"LANG":@"en_US.UTF-8"}; + assert([tar launchAndReturnError:nil]); [tar waitUntilExit]; assert(tar.terminationStatus == 0); + NSData *bytes = [NSData dataWithContentsOfURL:archive]; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256(bytes.bytes, (CC_LONG)bytes.length, digest); + NSMutableString *sha = [NSMutableString new]; + for (NSUInteger i = 0; i < sizeof digest; i++) [sha appendFormat:@"%02x", digest[i]]; + NSString *source = @"3a4a077f1b1f40688ab3279affcb56776bd97c54db1573e7c65fc52a97106676"; + NSURL *support = [NSBundle.mainBundle.resourceURL URLByAppendingPathComponent:@"SteamOSSupport.bundle"]; + assert([files createDirectoryAtURL:support withIntermediateDirectories:YES attributes:nil error:nil]); + NSDictionary *manifest = @{@"schema":@1, @"architecture":@"arm64", @"archiveSHA256":sha, + @"sourceImageSHA256":source, @"archiveBytes":@(bytes.length)}; + assert([[NSJSONSerialization dataWithJSONObject:manifest options:0 error:nil] + writeToURL:[support URLByAppendingPathComponent:@"manifest.json"] atomically:YES]); + assert([NSBundle.mainBundle URLForResource:@"SteamOSSupport" withExtension:@"bundle"]); + // Avoid the app's boot-time default-root creation in this fixture. + Roots *roots = [Roots alloc]; + [roots setValue:[NSMutableOrderedSet new] forKey:@"roots"]; + NSDictionary *alpine = @{@"distributionID":@"alpine"}; + NSDictionary *steam = @{@"distributionID":@"steamos", @"sourceImageSHA256":source}; + NSError *error = nil; + NSTask *invalid = [NSTask new]; invalid.executableURL = [NSURL fileURLWithPath:@"/usr/bin/python3"]; + invalid.arguments = @[@"-c", @"import gzip,pathlib,sys; p=pathlib.Path(sys.argv[1]); raw=gzip.decompress(p.read_bytes()); assert b'\\xcc\\x8b' in raw; p.with_name('bad-utf8.tar.gz').write_bytes(gzip.compress(raw.replace(b'\\xcc\\x8b',b'\\xff\\x8b',1)))", archive.path]; + assert([invalid launchAndReturnError:nil]); [invalid waitUntilExit]; assert(!invalid.terminationStatus); + NSURL *badUTF8 = [fixture URLByAppendingPathComponent:@"bad-utf8.tar.gz"]; + assert(![roots importVerifiedRootFromArchive:badUTF8 name:@"bad-utf8" sha256:digestOf([NSData dataWithContentsOfURL:badUTF8]) metadata:alpine error:&error progressReporter:nil]); + assert(error && ![files fileExistsAtPath:[roots rootUrl:@"bad-utf8"].path]); + // Match the fixture's hash so this exercises codec failure during import, + // independently of the source-integrity check that precedes extraction. + NSData *truncatedGzip = [bytes subdataWithRange:NSMakeRange(0, 16)]; + NSURL *badGzip = [fixture URLByAppendingPathComponent:@"bad-gzip.tar.gz"]; + assert([truncatedGzip writeToURL:badGzip atomically:YES]); + error = nil; + assert(![roots importVerifiedRootFromArchive:badGzip name:@"bad-gzip" sha256:digestOf(truncatedGzip) metadata:alpine error:&error progressReporter:nil]); + assert(error && ![files fileExistsAtPath:[roots rootUrl:@"bad-gzip"].path]); + error = nil; + assert(![roots importVerifiedRootFromArchive:archive name:@"wrong-hash" sha256:[@"b" stringByPaddingToLength:64 withString:@"b" startingAtIndex:0] metadata:alpine error:&error progressReporter:nil]); + assert(error && ![files fileExistsAtPath:[roots rootUrl:@"wrong-hash"].path]); + TestProgress *progress = [TestProgress new]; progress.cancelled = YES; error = nil; + assert(![roots importVerifiedRootFromArchive:archive name:@"cancel-copy" sha256:sha metadata:alpine error:&error progressReporter:progress]); + assert(error.code == NSUserCancelledError && ![files fileExistsAtPath:[roots rootUrl:@"cancel-copy"].path]); + progress = [TestProgress new]; progress.cancelDuringImport = YES; error = nil; + assert(![roots importVerifiedRootFromArchive:archive name:@"cancel-import" sha256:sha metadata:alpine error:&error progressReporter:progress]); + assert(error.code == NSUserCancelledError && ![files fileExistsAtPath:[roots rootUrl:@"cancel-import"].path]); + NSURL *link = [fixture URLByAppendingPathComponent:@"symlink.tar.gz"]; + assert([files createSymbolicLinkAtURL:link withDestinationURL:archive error:nil]); + assert(![roots importVerifiedRootFromArchive:link name:@"link" sha256:sha metadata:alpine error:&error progressReporter:nil]); + assert((![roots importVerifiedRootFromArchive:archive name:@"wrong-source" sha256:sha metadata:@{@"distributionID":@"steamos", @"sourceImageSHA256":@"wrong"} error:&error progressReporter:nil])); + error = nil; + if (![roots importVerifiedRootFromArchive:archive name:@"steam" sha256:sha metadata:steam error:&error progressReporter:nil]) { + fprintf(stderr, "STEAMOS_ROOT_IMPORT: FAIL %s\n", error.localizedDescription.UTF8String); + return 1; + } + assert(!error); + NSData *imported = [NSData dataWithContentsOfURL:[[roots rootUrl:@"steam"] URLByAppendingPathComponent:@"data/etc/os-release"]]; + assert([imported isEqual:original] && [roots.roots containsObject:@"steam"]); + NSData *unicode = [NSData dataWithContentsOfURL:[[roots rootUrl:@"steam"] URLByAppendingPathComponent:[@"data/etc/" stringByAppendingString:unicodeName]]]; + assert([unicode isEqual:original]); + NSData *linkBytes = [NSData dataWithContentsOfURL:[[roots rootUrl:@"steam"] URLByAppendingPathComponent:@"data/etc/unicode-link"]]; + assert([linkBytes isEqual:expectedLink]); + assert(uselocale((locale_t)0) == initialLocale && !strcmp(setlocale(LC_CTYPE, NULL), "C")); + assert([[NSData dataWithContentsOfURL:archive] isEqual:bytes]); + assert([files removeItemAtURL:fixture error:nil]); + puts("STEAMOS_ROOT_IMPORT: PASS (pins, cancellation, UTF-8 names in C locale, malformed UTF-8 and truncated gzip rejection)"); +} return 0; } diff --git a/tests/linuxrunner/steamos/SteamOSManifestTests.swift b/tests/linuxrunner/steamos/SteamOSManifestTests.swift new file mode 100644 index 0000000..3bdf3b4 --- /dev/null +++ b/tests/linuxrunner/steamos/SteamOSManifestTests.swift @@ -0,0 +1,24 @@ +import Foundation + +@main struct SteamOSManifestTests { + static func main() throws { + let valid: [String: Any] = ["schema": 1, "architecture": "arm64", "version": "0.3.0", + "sourceImageSHA256": SteamOSManifest.sourcePin, + "archiveSHA256": String(repeating: "a", count: 64), + "archiveBytes": 2_000_000_000, "installedBytes": 9_000_000_000, "entries": 226000] + let manifest = try SteamOSManifest.decode(JSONSerialization.data(withJSONObject: valid)) + precondition(manifest.requiredFreeBytes == 11_536_870_912) + let invalid: [(String, Any)] = [("schema", 2), ("architecture", "x86_64"), ("version", "3.7"), + ("sourceImageSHA256", String(repeating: "0", count: 64)), ("archiveSHA256", "../../archive"), + ("archiveBytes", -1), ("archiveBytes", Int64.max), ("installedBytes", Int64.max), + ("entries", 0), ("entries", 500001)] + for (key, value) in invalid { + var input = valid; input[key] = value + do { _ = try SteamOSManifest.decode(JSONSerialization.data(withJSONObject: input)); fatalError("Accepted invalid \(key)") } + catch { } + } + do { _ = try SteamOSManifest.decode(Data(repeating: 0, count: 65537)); fatalError("Accepted oversized manifest") } + catch { } + print("STEAMOS_MANIFEST: PASS (identity, digest syntax, size bounds, overflow and schema)") + } +} diff --git a/tests/linuxrunner/steamos/desktop.sh b/tests/linuxrunner/steamos/desktop.sh new file mode 100644 index 0000000..63f74a4 --- /dev/null +++ b/tests/linuxrunner/steamos/desktop.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Manual experiment in a disposable SteamFrame root, as a dedicated regular user. +# Writes test-user configuration and reports; never run in an existing user environment. +set -eu +. /etc/os-release +[ "$ID" = steamos ] +[ "$(id -u)" -ne 0 ] || { echo 'Use a dedicated regular guest user'; exit 2; } +export DISPLAY=:17 XDG_SESSION_TYPE=x11 +export QT_QPA_PLATFORM=xcb QT_QUICK_BACKEND=software QT_OPENGL=software +export QT_XCB_GL_INTEGRATION=none QT_X11_NO_MITSHM=1 KWIN_COMPOSE=N LIBGL_ALWAYS_SOFTWARE=1 +export XDG_RUNTIME_DIR +XDG_RUNTIME_DIR=$(mktemp -d /tmp/lr-steamos-runtime.XXXXXXXX) +report="$HOME/steamframe-probe" +mkdir -p "$report/framebuffer" "$HOME/.config/autostart" +: > "$report/clients.log" +printf '[General]\nsystemdBoot=false\n' > "$HOME/.config/startkderc" +# This milestone exercises the desktop and terminal; keep the Steam client out of autostart. +printf '[Desktop Entry]\nType=Application\nHidden=true\n' > "$HOME/.config/autostart/steam.desktop" +Xvfb "$DISPLAY" -screen 0 960x540x24 -nolisten tcp -ac -noreset -fbdir "$report/framebuffer" -extension MIT-SHM > "$report/xvfb.log" 2>&1 & xpid=$! +pid= terminal= +cleanup() { + [ -z "$terminal" ] || kill "$terminal" 2>/dev/null || true + [ -z "$pid" ] || kill "$pid" 2>/dev/null || true + kill "$xpid" 2>/dev/null || true + wait "$xpid" 2>/dev/null || true +} +trap cleanup EXIT +for ((i=0;i<100;i++)); do + [ ! -S /tmp/.X11-unix/X17 ] || break + kill -0 "$xpid" || { cat "$report/xvfb.log"; exit 3; } + sleep .1 +done +[ -S /tmp/.X11-unix/X17 ] +dbus-run-session -- startplasma-x11 > "$report/plasma.log" 2>&1 & pid=$! +sleep 30 +kill -0 "$pid" +konsole --nofork --separate --hide-menubar --hide-tabbar -e /bin/bash --noprofile --norc -ic 'printf "SteamOS ARM64 in LinuxRunner\n"; cat /etc/os-release; uname -m; exec /bin/bash --noprofile --norc' > "$report/konsole.log" 2>&1 & terminal=$! +sleep 20 +xprop -root _NET_CLIENT_LIST _NET_SUPPORTING_WM_CHECK > "$report/windows.log" +for window in $(sed -n 's/.*window id # //p' "$report/windows.log" | tr -d ','); do + xprop -id "$window" WM_CLASS _NET_WM_NAME >> "$report/clients.log" 2>&1 || true +done +cp "$report/framebuffer/Xvfb_screen0" "$report/desktop.xwd" +grep -qi plasmashell "$report/clients.log" +grep -qi konsole "$report/clients.log" +printf 'STEAMOS_DESKTOP=PASS (X11 software rendering; host runtime experiment)\n' diff --git a/tests/linuxrunner/steamos/network.sh b/tests/linuxrunner/steamos/network.sh new file mode 100755 index 0000000..3a08127 --- /dev/null +++ b/tests/linuxrunner/steamos/network.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Uses LinuxRunner's socket layer and the resolver supplied by iPadOS. +# This check reads network state; it does not install or configure drivers. +set -eu +. /etc/os-release +[ "$ID" = steamos ] +getent ahostsv4 archlinuxarm.org >/dev/null +printf 'STEAMOS_NETWORK_DNS=PASS\n' +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ + --connect-timeout 15 --max-time 40 --output /dev/null https://archlinuxarm.org/ +printf 'STEAMOS_NETWORK_HTTPS=PASS\n' diff --git a/tests/linuxrunner/steamos/run-root-import.sh b/tests/linuxrunner/steamos/run-root-import.sh new file mode 100755 index 0000000..4f39ec7 --- /dev/null +++ b/tests/linuxrunner/steamos/run-root-import.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Host-only disposable importer fixture. Requires CMake and pinned libarchive. +set -euo pipefail +cd "$(dirname "$0")/../../.." +out=build/root-import-test +mkdir -p "$out/RootImportTests.app/Contents/MacOS" +cmake -S deps/libarchive -B "$out/archive" -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -DBUILD_SHARED_LIBS=OFF \ + -DENABLE_TEST=OFF -DENABLE_TAR=OFF -DENABLE_CPIO=OFF -DENABLE_CAT=OFF \ + -DENABLE_LZ4=OFF -DENABLE_ZSTD=OFF -DENABLE_LZMA=OFF -DENABLE_BZip2=OFF \ + -DENABLE_LIBXML2=OFF -DENABLE_EXPAT=OFF -DENABLE_OPENSSL=OFF \ + -DENABLE_MBEDTLS=OFF -DENABLE_NETTLE=OFF +cmake --build "$out/archive" --target archive_static -j 4 +for source in tools/fakefs.c fs/fake-db.c fs/fake-migrate.c fs/fake-rebuild.c \ + util/fchdir.c app/LinuxRunner/FileTransfer.c; do + xcrun clang -O1 -g -DGUEST_ARM64=1 -I. -Ideps/libarchive/libarchive \ + -c "$source" -o "$out/$(basename "$source" .c).o" +done +cat > "$out/host-log.c" <<'C' +#include +#include +#include +void ish_printk(const char *f, ...) { va_list a; va_start(a,f); vfprintf(stderr,f,a); va_end(a); } +_Noreturn void die(const char *f, ...) { va_list a; va_start(a,f); vfprintf(stderr,f,a); va_end(a); abort(); } +C +xcrun clang -c "$out/host-log.c" -o "$out/host-log.o" +xcrun clang -O1 -g -fobjc-arc -DLINUXRUNNER=1 -DGUEST_ARM64=1 -I. \ + tests/linuxrunner/steamos/RootImportTests.m app/Roots.m app/NSObject+SaneKVO.m \ + "$out"/*.o "$out/archive/libarchive/libarchive.a" \ + -framework Foundation -lsqlite3 -lz -liconv \ + -o "$out/RootImportTests.app/Contents/MacOS/RootImportTests" +exec "$out/RootImportTests.app/Contents/MacOS/RootImportTests" diff --git a/tests/linuxrunner/steamos/terminal.sh b/tests/linuxrunner/steamos/terminal.sh new file mode 100644 index 0000000..32068dc --- /dev/null +++ b/tests/linuxrunner/steamos/terminal.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Run inside a disposable SteamFrame userspace, never against an installed user environment. +set -eu +. /etc/os-release +[ "$ID" = steamos ] +[ "$(uname -m)" = aarch64 ] +[ -n "$BASH_VERSION" ] +printf 'STEAMOS_BASH=%s\n' "$BASH_VERSION" +id +pacman --version +pacman -Q bash +work=$(mktemp -d /tmp/linuxrunner-steamos.XXXXXXXX) +trap 'rm -rf "$work"' EXIT +printf 'SteamOS userspace\n' > "$work/value" +[ "$(cat "$work/value")" = 'SteamOS userspace' ] +# Exercise fork/wait and signal return, including the guest vDSO path. +(/usr/bin/true) & +child=$! +wait "$child" +printf 'STEAMOS_TERMINAL=PASS\n' diff --git a/tests/linuxrunner/steamos/test_export.py b/tests/linuxrunner/steamos/test_export.py new file mode 100644 index 0000000..a734373 --- /dev/null +++ b/tests/linuxrunner/steamos/test_export.py @@ -0,0 +1,134 @@ +"""Offline contracts for the local SteamFrame image exporter.""" + +import bz2 +from datetime import datetime, timezone +import importlib.util +import io +from pathlib import Path +import struct +import tarfile +import tempfile +import types +import unittest +import zlib + + +SCRIPT = Path(__file__).resolve().parents[3] / "tools/steamos/export-steamframe.py" +SPEC = importlib.util.spec_from_file_location("steamframe_export", SCRIPT) +exporter = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(exporter) + + +def disk_image(): + image = bytearray(64 * 512) + table = bytearray(128) + table[:16] = b"\x01" * 16 + struct.pack_into("= 0xc2 && lead <= 0xdf) { count = 1; code = lead & 0x1f; minimum = 0x80; } + else if (lead >= 0xe0 && lead <= 0xef) { count = 2; code = lead & 0x0f; minimum = 0x800; } + else if (lead >= 0xf0 && lead <= 0xf4) { count = 3; code = lead & 7; minimum = 0x10000; } + else return false; + while (count--) { + unsigned char next = *p++; + if ((next & 0xc0) != 0x80) return false; + code = (code << 6) | (next & 0x3f); + } + if (code < minimum || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return false; + } + return true; +} + +static bool pax_original_names(struct archive *archive, struct archive_entry *entry) { + int format = archive_format(archive); + if (format != ARCHIVE_FORMAT_TAR_PAX_INTERCHANGE && format != ARCHIVE_FORMAT_TAR_PAX_RESTRICTED) return false; + const char *warning = archive_error_string(archive); + if (!warning) return false; + // libarchive's PAX reader explicitly copies the original UTF-8 bytes back + // into the entry when native-locale conversion fails (pax_header()). Those + // bytes are the desired Linux names. Accept only this specific warning; + // malformed UTF-8, checksum/codec errors and every other warning still fail. + static const char *const messages[] = { + "Pathname can't be converted from UTF-8 to current locale.", + "Linkname can't be converted from UTF-8 to current locale.", + "Uname can't be converted from UTF-8 to current locale.", + "Gname can't be converted from UTF-8 to current locale.", + }; + bool conversion = false; + for (size_t i = 0; i < sizeof messages / sizeof *messages; i++) + if (!strcmp(warning, messages[i])) conversion = true; + if (!conversion || !archive_entry_pathname(entry)) return false; + if (!valid_utf8(archive_entry_pathname(entry)) || !valid_utf8(archive_entry_symlink(entry)) || + !valid_utf8(archive_entry_hardlink(entry)) || !valid_utf8(archive_entry_uname(entry)) || !valid_utf8(archive_entry_gname(entry))) return false; + if (!strncmp(warning, "Linkname", 8) && !archive_entry_symlink(entry) && !archive_entry_hardlink(entry)) return false; + archive_clear_error(archive); + return true; +} + +static bool fakefs_import_contents(const char *archive_path, const char *fs, struct fakefsify_error *err_out, struct progress p, struct import_resources *resources) { int err = mkdir(fs, 0777); if (err < 0) POSIX_ERR(); @@ -87,13 +140,15 @@ bool fakefs_import(const char *archive_path, const char *fs, struct fakefsify_er snprintf(path_tmp, sizeof(path_tmp), "%s/data", fs); err = mkdir(path_tmp, 0777); int root_fd = open(path_tmp, O_RDONLY); + resources->root_fd = root_fd; if (root_fd < 0) POSIX_ERR(); // open the database snprintf(path_tmp, sizeof(path_tmp), "%s/meta.db", fs); - sqlite3 *db; + sqlite3 *db = NULL; err = sqlite3_open_v2(path_tmp, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL); + resources->db = db; CHECK_ERR(); EXEC("pragma journal_mode=wal") EXEC("begin"); @@ -101,6 +156,7 @@ bool fakefs_import(const char *archive_path, const char *fs, struct fakefsify_er // open the archive struct archive *archive = archive_read_new(); + resources->archive = archive; if (archive == NULL) ARCHIVE_ERR(archive); archive_read_support_filter_gzip(archive); @@ -121,7 +177,8 @@ bool fakefs_import(const char *archive_path, const char *fs, struct fakefsify_er // do actual shit struct archive_entry *entry; - while ((err = archive_read_next_header(archive, &entry)) == ARCHIVE_OK) { + while ((err = archive_read_next_header(archive, &entry)) == ARCHIVE_OK || + (err == ARCHIVE_WARN && pax_original_names(archive, entry))) { char entry_path[MAX_PATH]; if (!path_normalize(archive_entry_pathname(entry), entry_path)) { // Avoid pwnage @@ -171,6 +228,7 @@ bool fakefs_import(const char *archive_path, const char *fs, struct fakefsify_er case AE_IFCHR: case AE_IFSOCK: fd = openat(root_fd, fix_path(entry_path), O_WRONLY | O_CREAT | O_TRUNC, 0666); + resources->entry_fd = fd; if (fd < 0) { if (errno == EISDIR) continue; // assuming it's case insensitivity POSIX_ERR(); @@ -211,6 +269,7 @@ bool fakefs_import(const char *archive_path, const char *fs, struct fakefsify_er } if (fd != -1) close(fd); + resources->entry_fd = -1; struct timespec times[2] = { // for utimes, atime is first, mtime is second @@ -255,14 +314,25 @@ bool fakefs_import(const char *archive_path, const char *fs, struct fakefsify_er FINALIZE(insert_path); FINALIZE(insert_hardlink); EXEC("commit"); - sqlite3_close(db); - close(root_fd); - - if (archive_read_free(archive) != ARCHIVE_OK) + if (archive_read_close(archive) != ARCHIVE_OK) ARCHIVE_ERR(archive); return true; } +bool fakefs_import(const char *archive_path, const char *fs, struct fakefsify_error *err_out, struct progress p) { + struct import_resources resources = {.root_fd = -1, .entry_fd = -1}; + bool result = fakefs_import_contents(archive_path, fs, err_out, p, &resources); + if (resources.entry_fd >= 0) close(resources.entry_fd); + if (resources.archive) archive_read_free(resources.archive); + if (resources.db) { + sqlite3_stmt *statement; + while ((statement = sqlite3_next_stmt(resources.db, NULL))) sqlite3_finalize(statement); + sqlite3_close(resources.db); + } + if (resources.root_fd >= 0) close(resources.root_fd); + return result; +} + bool fakefs_export(const char *fs, const char *archive_path, struct fakefsify_error *err_out, struct progress p) { // open the archive struct archive *archive = archive_write_new(); diff --git a/tools/steamos/export-steamframe.py b/tools/steamos/export-steamframe.py new file mode 100644 index 0000000..d42fa50 --- /dev/null +++ b/tools/steamos/export-steamframe.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Inspect a local SteamFrame image and export experimental Linux userspace. + +Does not authenticate Valve images, boot a kernel, or install an app environment. +Requires Python 3.10–3.14 and requirements.txt from this directory. +""" + +import argparse +import bz2 +import contextlib +import json +import os +from pathlib import Path +import struct +import sys +import tarfile +import tempfile +import uuid +import zlib + + +GIB = 1024**3 +EXCLUDED = { + "boot", "efi", "esp", "persist", "usr/lib/modules", "usr/lib/firmware", "usr/lib/debug", +} +EMPTY_DIRECTORIES = {"dev", "proc", "sys", "run", "tmp"} +PROBES = ("bash", "pacman", "Xvfb", "kwin_x11", "plasmashell", "startplasma-x11") + + +@contextlib.contextmanager +def open_image(path, limit): + with path.open("rb") as source: + compressed = source.read(3) == b"BZh" + source.seek(0) + if not compressed: + if os.fstat(source.fileno()).st_size > limit: + raise ValueError("Image exceeds --max-image-gib") + yield source + return + # Random access is needed for Btrfs. The source is never modified. + with tempfile.TemporaryFile() as unpacked, bz2.BZ2File(source) as reader: + size = 0 + while block := reader.read(8 * 1024**2): + size += len(block) + if size > limit: + raise ValueError("Decompressed image exceeds --max-image-gib") + unpacked.write(block) + unpacked.seek(0) + yield unpacked + + +def partitions(image): + image.seek(0, os.SEEK_END) + image_size = image.tell() + for sector in (512, 4096): + image.seek(sector) + header = image.read(sector) + if header[:8] == b"EFI PART": + break + else: + raise ValueError("No GPT partition table found") + header_size, checksum = struct.unpack_from(" 1024**2: + raise ValueError("Invalid GPT entry table size") + if table_lba * sector + table_size > image_size: + raise ValueError("GPT entry table exceeds image") + image.seek(table_lba * sector) + table = image.read(table_size) + if zlib.crc32(table) != table_checksum: + raise ValueError("GPT entry table checksum mismatch") + result = [] + for index in range(count): + entry = table[index * entry_size:(index + 1) * entry_size] + if entry[:16] == b"\0" * 16: + continue + first, last = struct.unpack_from(" last or (last + 1) * sector > image_size: + raise ValueError("Partition exceeds image") + result.append({ + "number": index + 1, + "name": entry[56:128].decode("utf-16-le").rstrip("\0"), + "type_guid": str(uuid.UUID(bytes_le=entry[:16])), + "offset": first * sector, + "size": (last - first + 1) * sector, + }) + return result + + +def resolve(fs, path): + """Resolve guest symlinks without following any host filesystem path.""" + import posixpath + + for _ in range(40): + parts = posixpath.normpath(path).strip("/").split("/") + for index in range(len(parts)): + current = "/" + "/".join(parts[:index + 1]) + node = fs.get(current) + if node.is_symlink(): + link = node.link + if not link.startswith("/"): + link = posixpath.join(posixpath.dirname(current), link) + path = posixpath.join(link, *parts[index + 1:]) + break + else: + return node + raise ValueError("Guest symlink loop: " + path) + + +def inspect(fs, table): + with resolve(fs, "/etc/os-release").open() as source: + release = source.read(65536).decode() + fields = {} + for line in release.splitlines(): + if "=" in line: + key, value = line.split("=", 1) + fields[key] = value.strip('"') + if fields.get("ID") != "steamos": + raise ValueError("The selected root is not identified as SteamOS") + binaries = {} + for name in PROBES: + try: + with resolve(fs, "/usr/bin/" + name).open() as binary: + header = binary.read(64) + except FileNotFoundError: + binaries[name] = {"present": False} + continue + if len(header) < 20 or header[:6] != b"\x7fELF\x02\x01": + raise ValueError("Expected a little-endian ELF64 binary: " + name) + machine = struct.unpack_from(" 1024 * 1024: + raise ValueError('Invalid bundled preferred probe source') + data = tar.extractfile(member).read() + build = json.load(tar.extractfile('steamos-gpu/BUILD.json')) + if hashlib.sha256(data).hexdigest() != build['probeSourceSHA256']: + raise ValueError('Bundled probe source identity mismatch') + return data + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--stage', type=Path, required=True) + p.add_argument('--mesa-source', type=Path, required=True) + p.add_argument('--probe', type=Path, required=True, help='AArch64 virgl_border_contract executable') + p.add_argument('--output', type=Path, required=True, help='New output directory') + p.add_argument('--bundle', action='store_true', help='Stage archive in the app GUI support bundle') + a = p.parse_args() + revision = subprocess.check_output(['git', '-C', str(a.mesa_source), 'rev-parse', 'HEAD'], text=True).strip() + if revision != MESA_REV or subprocess.check_output(['git', '-C', str(a.mesa_source), 'diff', 'HEAD', '--']): + p.error('Use the pinned, unmodified Mesa source') + stage = a.stage.resolve(strict=True) + for required in ['lib/libgbm.so.1', 'lib/libEGL_mesa.so.0', 'lib/libGLX_mesa.so.0', 'lib/dri/virtio_gpu_dri.so']: + if not (stage / required).is_file(): + p.error('Missing staged driver: ' + required) + a.output.mkdir(parents=True, exist_ok=False) + with tempfile.TemporaryDirectory(prefix='steamos-gpu-') as temporary: + prefix = Path(temporary) / 'steamos-gpu' + prefix.mkdir() + for directory in ['lib', 'share']: + shutil.copytree(stage / directory, prefix / directory, symlinks=True) + notices = prefix / 'share/licenses/mesa' + notices.mkdir(parents=True) + shutil.copy2(a.mesa_source / 'docs/license.rst', notices / 'license.rst') + shutil.copytree(a.mesa_source / 'licenses', notices / 'licenses') + (prefix / 'bin').mkdir() + shutil.copy2(a.probe, prefix / 'bin/graphics-check') + (prefix / 'bin/graphics-check').chmod(0o755) + probe_sources = prefix / 'share/linuxrunner' + probe_sources.mkdir() + probe_source = probe_source_bytes() + (probe_sources / 'virgl_border_contract.c').write_bytes(probe_source) + for name in ['LICENSE.md', 'LICENSE.IOS']: + shutil.copy2(REPO / name, probe_sources / name) + provenance = { + 'schema': 1, 'architecture': 'aarch64', 'sourceImageSHA256': SOURCE_PIN, + 'mesa': {'revision': MESA_REV, 'source': 'https://gitlab.freedesktop.org/mesa/mesa.git'}, + 'prefix': '/opt/linuxrunner/steamos-gpu', 'glibc': '2.39', + 'probeSourceSHA256': hashlib.sha256(probe_source).hexdigest(), + 'options': {'gallium-drivers': 'virgl', 'vulkan-drivers': '', 'llvm': 'disabled', + 'platforms': 'x11,wayland', 'glx': 'dri', 'glvnd': 'enabled', 'egl': 'enabled', 'gbm': 'enabled'}, + } + (prefix / 'BUILD.json').write_text(json.dumps(provenance, indent=2) + '\n') + files = sorted(prefix.rglob('*')) + hashes = [] + for entry in files: + name = entry.relative_to(prefix).as_posix() + if entry.is_symlink(): + target = entry.readlink().as_posix() + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(name), target)) + if target.startswith('/') or resolved == '..' or resolved.startswith('../'): + p.error('Driver symlink escapes its isolated prefix: ' + name) + elif entry.is_file(): + with entry.open('rb') as stream: + header = stream.read(20) + if header.startswith(b'\x7fELF') and (header[4:6] != b'\x02\x01' or header[18:20] != b'\xb7\x00'): + p.error('Driver contains a non-AArch64 ELF: ' + name) + hashes.append(digest(entry) + ' ' + name + '\n') + elif not entry.is_dir(): + p.error('Unsupported driver entry: ' + name) + (prefix / 'payload.sha256').write_text(''.join(hashes)) + archive = a.output / 'steamos-gpu.tar.gz' + with archive.open('xb') as raw, gzip.GzipFile(fileobj=raw, mode='wb', filename='', mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode='w', format=tarfile.GNU_FORMAT, dereference=False) as tar: + for entry in [prefix] + sorted(prefix.rglob('*')): + info = tar.gettarinfo(str(entry), str(entry.relative_to(prefix.parent))) + info.uid = info.gid = info.mtime = 0 + info.uname = info.gname = '' + info.mode = 0o755 if info.isdir() or (info.isfile() and entry.stat().st_mode & 0o111) else 0o644 + with entry.open('rb') if info.isfile() else io.BytesIO() as data: + tar.addfile(info, data if info.isfile() else None) + sums = a.output / 'steamos-gpu.sha256' + sums.write_text(digest(archive) + ' steamos-gpu.tar.gz\n') + (a.output / 'BUILD.json').write_text(json.dumps(provenance, indent=2) + '\n') + if a.bundle: + bundle = REPO / 'app/LinuxRunner/GUISupport.bundle' + for entry in [archive, sums]: + shutil.copy2(entry, bundle / entry.name) + subprocess.run(['python3', str(REPO / 'scripts/generate-gui-support-manifest.py')], check=True) + print(sums.read_text(), end='') + + +if __name__ == '__main__': + main() diff --git a/tools/steamos/prepare-device-image.py b/tools/steamos/prepare-device-image.py new file mode 100644 index 0000000..c73f971 --- /dev/null +++ b/tools/steamos/prepare-device-image.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Prepare a disposable SteamFrame fakefs import for LinuxRunner's device installer. + +Requires a case-sensitive --root from export-steamframe.py, a host interpreter, +and the fakefs exporter. Adds pinned display packages, builds guest helpers, +and exports the prepared archive and its app-side manifest. +""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tarfile +import urllib.request + +SOURCE_PIN = "3a4a077f1b1f40688ab3279affcb56776bd97c54db1573e7c65fc52a97106676" +# SHA-256 and detached signatures checked against the Arch Linux ARM Build +# System key 68B3537F39A313B3E574D06777193F152BDBE6A6. +PACKAGES = { + "x11vnc-1:0.9.17-1-aarch64.pkg.tar.xz": "28f757a6b8f495eda9a0c4d6385ad68d72ab143e5f0e613885ff8ca95cc58549", + "libvncserver-0.9.15-1-aarch64.pkg.tar.xz": "c941976dedf7898dd485a6880b83162a13b143e3c33f9c808ccd4b2896c0fe4d", +} +HELPERS = ("gui-test", "focus-manager", "desktop-probe", "window-observer") +REPO = Path(__file__).resolve().parents[2] + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as source: + while block := source.read(8 * 1024**2): + digest.update(block) + return digest.hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-image", type=Path, required=True, help="Original .img.bz2") + parser.add_argument("--root", type=Path, required=True, help="Disposable fakefs root") + parser.add_argument("--runtime", type=Path, default=REPO / "build/steamos-host/ish") + parser.add_argument("--exporter", type=Path, default=REPO / "build/steamos-host/tools/unfakefsify") + parser.add_argument("--cache", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True, help="New package directory") + parser.add_argument("--app-manifest", type=Path, required=True) + args = parser.parse_args() + if sha256(args.source_image) != SOURCE_PIN: + parser.error("Source image differs from the supplied SteamFrame image") + if not (args.root / "meta.db").is_file(): + parser.error("--root must be an imported fakefs filesystem") + args.output.mkdir(parents=True, exist_ok=False) + args.cache.mkdir(parents=True, exist_ok=True) + spec = importlib.util.spec_from_file_location("alarm", REPO / "tools/arch/alarm-bootstrap.py") + alarm = importlib.util.module_from_spec(spec) + spec.loader.exec_module(alarm) + support = REPO / "app/LinuxRunner/GUISupport.bundle" + made = set() + with tarfile.open(args.output / "overlay.tar", "w", format=tarfile.GNU_FORMAT) as overlay: + def add(name, data, mode=0o644): + alarm.add_bytes(overlay, name, data, made, mode) + + for name, expected in PACKAGES.items(): + package = args.cache / name + if not package.exists(): + request = "https://fl.us.mirror.archlinuxarm.org/aarch64/extra/" + name + with urllib.request.urlopen(request, timeout=120) as source, package.open("xb") as destination: + while block := source.read(1024**2): + destination.write(block) + if sha256(package) != expected: + raise ValueError("Package checksum mismatch: " + name) + with tarfile.open(package) as archive: + fields = {} + for line in archive.extractfile(".PKGINFO").read().decode().splitlines(): + if " = " in line: + key, value = line.split(" = ", 1) + fields.setdefault(key, []).append(value) + files = [] + for member in archive: + if member.name.startswith("."): + continue + if ".." in member.name.split("/") or member.name.startswith("/"): + raise ValueError("Invalid package member") + files.append(member.name + ("/" if member.isdir() else "")) + if member.isdir(): + alarm.add_dir(overlay, member.name.rstrip("/"), made) + else: + overlay.addfile(member, archive.extractfile(member) if member.isfile() else None) + mapping = {"pkgname": "NAME", "pkgver": "VERSION", "pkgdesc": "DESC", "arch": "ARCH", "url": "URL", "license": "LICENSE", "depend": "DEPENDS", "optdepend": "OPTDEPENDS", "provides": "PROVIDES", "builddate": "BUILDDATE", "packager": "PACKAGER", "size": "ISIZE"} + description = {mapping[key]: value for key, value in fields.items() if key in mapping} + local = "usr/lib/holo/pacmandb/local/" + fields["pkgname"][0] + "-" + fields["pkgver"][0] + add(local + "/desc", alarm.local_desc(description, True).encode()) + add(local + "/files", ("%FILES%\n" + "\n".join(files) + "\n\n").encode()) + for helper in HELPERS: + add("usr/lib/linuxrunner/prebuilt-gui/" + helper + ".c", (support / (helper + ".c")).read_bytes()) + sources = "".join(sha256(support / (name + ".c")) + " " + name + ".c\n" for name in HELPERS) + add("usr/lib/linuxrunner/prebuilt-gui/sources.sha256", sources.encode()) + init = b'#!/bin/sh\nset -eu\nmkdir -p /tmp /run /var/log /var/lib/linuxrunner /home/mobile/Downloads\nchmod 1777 /tmp\ndbus-uuidgen --ensure\ntouch /var/lib/linuxrunner/initialized\nwhile :; do sleep 3600; done\n' + add("usr/lib/linuxrunner/init.sh", init, 0o755) + environment = dict(os.environ, ISH_BIND_MOUNTS="/mnt=" + str(args.output.resolve())) + script = """set -eu +. /etc/os-release +[ "$ID" = steamos ] && [ "$(uname -m)" = aarch64 ] +tar -xf /mnt/overlay.tar -C / +x11vnc -version +pkg-config --exists x11 xcb +cd /usr/lib/linuxrunner/prebuilt-gui +sha256sum -c sources.sha256 +for helper in gui-test focus-manager desktop-probe window-observer; do + gcc "$helper.c" -O2 -Wall -Wextra -lX11 -o "$helper" + chmod 755 "$helper" +done +sha256sum gui-test focus-manager desktop-probe window-observer > binaries.sha256 +mkdir -p /var/lib/linuxrunner +touch /var/lib/linuxrunner/initialized +pacman -Q x11vnc libvncserver +echo STEAMOS_PREPARED=PASS +""" + with (args.output / "prepare.log").open("w") as log: + subprocess.run([str(args.runtime.resolve()), "-f", str(args.root.resolve()), "/bin/bash", "--noprofile", "--norc", "-ec", script], env=environment, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=900) + print("Guest helpers built; exporting prepared userspace", flush=True) + archive = args.output / "rootfs.tar.gz" + # fakefsify chooses export mode from argv[0]; preserve the unfakefsify alias. + subprocess.run([str(args.exporter.absolute()), str(args.root.resolve()), str(archive.resolve())], check=True, timeout=900) + installed = entries = 0 + with tarfile.open(archive) as image: + for member in image: + entries += 1 + if member.isfile(): + installed += member.size + manifest = {"schema": 1, "architecture": "arm64", "version": "0.3.0", "sourceImageSHA256": SOURCE_PIN, + "archiveSHA256": sha256(archive), "archiveBytes": archive.stat().st_size, "installedBytes": installed, "entries": entries} + text = json.dumps(manifest, indent=2) + "\n" + (args.output / "manifest.json").write_text(text) + args.app_manifest.parent.mkdir(parents=True, exist_ok=True) + args.app_manifest.write_text(text) + print(text) + + +if __name__ == "__main__": + main() diff --git a/tools/steamos/requirements.txt b/tools/steamos/requirements.txt new file mode 100644 index 0000000..2288a36 --- /dev/null +++ b/tools/steamos/requirements.txt @@ -0,0 +1,2 @@ +dissect.btrfs==1.10 +zstandard==0.25.0