diff --git a/Launcher/steam-frame-install.sh b/Launcher/steam-frame-install.sh index e59bd98..9b875bf 100755 --- a/Launcher/steam-frame-install.sh +++ b/Launcher/steam-frame-install.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash # Builds WiiCompiled VR for the Steam Frame from a release and your own disc, and installs it on the -# Frame. Run it again to update: it fetches the newest release and rebuilds only what changed. +# Frame. Run it again with --update to update: it fetches the newest release and rebuilds only what +# changed, with the options it was installed with. # # Launcher/steam-frame-install.sh --disc PATH [--frame HOST] [options] +# Launcher/steam-frame-install.sh --update # # Or without downloading anything first: # @@ -36,32 +38,84 @@ # --retro-rewind-pack DIR # use this RetroRewind6 folder instead of downloading one (implies # --retro-rewind); it is copied into the work dir and never changed +# --update update an earlier install: its options are reused (any given here win), and +# nothing is built when the Frame already has the newest release and Retro +# Rewind pack. Follows releases unless it was installed with --source. +# --check only say whether an update is available; nothing is changed # -h, --help +# +# Installed with --frame local, the game can update itself: Settings > Updates in the game checks +# for a new release, and its Update button runs this script with --update in the background, +# through a systemd user service this script sets up. The tab shows the update's progress while the +# game stays open, and a game closed meanwhile is opened again when the update is done. set -euo pipefail # Everything is in main, so bash has read the whole script before running any of it: when it comes # through curl | bash, a step that reads stdin cannot eat the rest. (Not indented, for the heredocs.) main() { -trap 'printf "\nsteam-frame-install.sh: stopped by a failed step (line %s); the output above says why.\n" "$LINENO" >&2' ERR +# What the error path below reads, set before anything can fail into it. +mode=install +steam_game_id="" +trap 'on_error "$LINENO"' ERR repo=mitch030504/Wiicompiled_VR_Frame game_id=WiiCompiled image=docker.io/library/debian:trixie container=wiicompiled-frame-build nodtool_version=v2.0.0-alpha.10 +# Where the game keeps its settings, and where an update it starts reports back to it. Resolved as +# the game resolves it (RuntimeConfigFile::ApplicationDataDirectory). +game_data="${XDG_DATA_HOME:-$HOME/.local/share}/$game_id" -say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } +say() { + printf '\n\033[1m==> %s\033[0m\n' "$*" + status running "$*" +} note() { printf ' %s\n' "$*"; } fail() { printf '\nsteam-frame-install.sh: error: %s\n' "$*" >&2 + finish_failed "${1%%$'\n'*}" +} +on_error() { + printf '\nsteam-frame-install.sh: stopped by a failed step (line %s); the output above says why.\n' "$1" >&2 + finish_failed "a step failed (line $1)" +} +finish_failed() { + trap - ERR + status failed "$1" + relaunch exit 1 } usage() { awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$script_path"; } +# The game's Updates tab reads this: "\t\t", state running, available, +# uptodate, done or failed. Written only when the game asked for this run +# (WIICOMPILED_UPDATE_STATUS names the file). +status() { + local file=${WIICOMPILED_UPDATE_STATUS:-} + [[ -n "$file" ]] || return 0 + { printf '%s\t%s\t%s\n' "$1" "$(date +%s)" "$2" > "$file.new" && mv -f "$file.new" "$file"; } 2>/dev/null || true +} + +# Progress goes to that file rather than to Steam notifications: on the Frame, SteamOS's +# steam_notif_daemon accepts a notify-send and Steam then shows nothing, even when given the +# notification directly with steam -ifrunning (tried on a Frame, 2026-10). +# An update the game started ends by opening the game again if it was closed meanwhile, which is +# how its result gets seen; a game still open shows the result in its Updates tab. +game_running() { pgrep -f "devkit-game/($game_id|RetroRewind)/" >/dev/null 2>&1; } +relaunch() { + [[ -n "$steam_game_id" ]] || return 0 + command -v steam >/dev/null 2>&1 || return 0 + if game_running; then return 0; fi + note "opening $game_id again" + timeout 60 steam "steam://rungameid/$steam_game_id" >/dev/null 2>&1 || true +} + script_path=${BASH_SOURCE[0]:-} script_dir="" if [[ -n "$script_path" && -f "$script_path" ]]; then script_dir=$(cd "$(dirname "$script_path")" && pwd) + script_path="$script_dir/$(basename "$script_path")" fi disc="" @@ -85,15 +139,151 @@ while [[ $# -gt 0 ]]; do --frame-disc) frame_disc=$2; shift 2 ;; --retro-rewind) retro_rewind=1; shift ;; --retro-rewind-pack) retro_rewind=1; retro_pack=$2; shift 2 ;; + --update) mode=update; shift ;; + --check) mode=check; shift ;; -h|--help) if [[ -n "$script_dir" ]]; then usage; else echo "See the comment at the top of the script."; fi exit 0 ;; *) fail "unknown argument: $1 (see --help)" ;; esac done +# What --update reuses: the options given, before defaults fill the rest in. +jobs_given=$jobs +source_given=$source_dir mkdir -p "$work_dir" work_dir=$(cd "$work_dir" && pwd) + +# The game's Update button leaves a request file; the service that runs this script moves it here. +if [[ -n "${WIICOMPILED_UPDATE_REQUEST:-}" && -f "$WIICOMPILED_UPDATE_REQUEST" ]]; then + steam_game_id=$(sed -n 's/^steam_game_id=\([0-9]*\)$/\1/p' "$WIICOMPILED_UPDATE_REQUEST" | head -n 1) + rm -f "$WIICOMPILED_UPDATE_REQUEST" +fi + +if [[ "$mode" != install && -f "$work_dir/install.conf" ]]; then + while IFS='=' read -r key value; do + case "$key" in + frame) [[ -n "$frame" ]] || frame=$value ;; + frame_disc) [[ -n "$frame_disc" ]] || frame_disc=$value ;; + jobs) [[ -n "$jobs" ]] || jobs=$value ;; + source) [[ -n "$source_dir" ]] || source_dir=$value ;; + retro_rewind) if [[ "$value" == 1 ]]; then retro_rewind=1; fi ;; + retro_rewind_pack) [[ -n "$retro_pack" ]] || retro_pack=$value ;; + esac + done < "$work_dir/install.conf" + jobs_given=$jobs + source_given=$source_dir +fi +save_settings() { + cat > "$work_dir/install.conf" </dev/null < "$f" || true +EOF +} +frame_game_dir="devkit-game/$game_id" + +newest_release() { + curl -fsSL "https://api.github.com/repos/$repo/releases?per_page=1" | + sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1 +} + +# Retro Rewind's own versioning, as its update server publishes it. +rr_server=https://update.rwfc.net/RetroRewind/ +rr_version_ok() { [[ "$1" =~ ^[0-9]+(\.[0-9]+)+$ ]]; } +rr_newer() { + # True when version $1 is newer than $2 (dotted numbers, as 6.12.7). + [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1)" == "$1" ]] +} +rr_fetch() { curl -fsSL --retry 2 "$1"; } +rr_published_version() { + rr_fetch "${rr_server}RetroRewindVersion.txt" | + awk 'NF >= 4 && $1 ~ /^[0-9]+(\.[0-9]+)+$/ { print $1 }' | sort -V | tail -n 1 +} +# Where the pack sits on the Frame: beside the disc, as the install below puts it. +frame_pack_path_for() { + local disc=${1#\~/} + [[ -n "$disc" ]] || disc=wiicompiled/disc + if [[ "$disc" == */* ]]; then printf '%s/RetroRewind6\n' "$(dirname "$disc")"; else printf 'RetroRewind6\n'; fi +} + +# --------------------------------------------------------------------------------------------- +# --update and --check: what the Frame has against what is published. Nothing is built when the +# Frame is already current, so an update that has nothing to do costs one web request. +if [[ "$mode" != install ]]; then + [[ -n "$frame" ]] || fail "--$mode updates an install made by this script, and there is none in + $work_dir. Install first (see --help), or pass --work-dir and --frame." + say "Checking for an update" + reasons=() + if [[ -n "$source_dir" ]]; then + # A source install has no published version to compare against; it rebuilds what changed. + reasons+=("it was installed from the source tree in $source_dir, which is rebuilt as it is") + else + if [[ -z "$release" ]]; then + release=$(newest_release) || + fail "could not reach github.com to ask for the newest release. Check this machine's + internet connection, then try again." + [[ -n "$release" ]] || fail "could not find the newest release on github.com/$repo" + fi + frame_installed_release=$(read_on_frame "$frame_game_dir/.release-tag") || + fail "cannot reach $frame over SSH to ask which release it has." + if [[ "$frame_installed_release" == "$release" ]]; then + note "the Frame has release $release" + elif [[ -z "$frame_installed_release" ]]; then + reasons+=("the Frame does not say which release it has; release $release would be installed") + else + reasons+=("release $release (the Frame has $frame_installed_release)") + fi + fi + if (( retro_rewind )) && [[ -z "$retro_pack" ]]; then + rr_published=$(rr_published_version) || + fail "could not reach Retro Rewind's update server (${rr_server})" + rr_installed=$(read_on_frame "$(frame_pack_path_for "$frame_disc")/version.txt") + if ! rr_version_ok "$rr_installed"; then + reasons+=("the Frame does not say which Retro Rewind pack it has") + elif rr_newer "$rr_published" "$rr_installed"; then + reasons+=("Retro Rewind $rr_published (the Frame has $rr_installed)") + else + note "the Frame has Retro Rewind $rr_installed" + fi + fi + + if (( ${#reasons[@]} == 0 )); then + say "Up to date" + status uptodate "Up to date${release:+ (}${release}${release:+)}" + relaunch + exit 0 + fi + note "an update is available:" + for line in "${reasons[@]}"; do note " $line"; done + if [[ "$mode" == check ]]; then + status available "${reasons[0]}" + exit 0 + fi + status running "Starting the update${release:+ to }$release" +fi + host_arch=$(uname -m) case "$host_arch" in x86_64|aarch64) ;; @@ -344,16 +534,10 @@ fi # --------------------------------------------------------------------------------------------- # Retro Rewind: its pack, as its update server publishes it, and the Retro-WFC payload its online # play runs. Same steps as the Quest app (android/.../RetroRewindPack.kt and GameBuild.kt). -rr_server=https://update.rwfc.net/RetroRewind/ +# Its server, versions and comparisons are up with the other version helpers. rr_pack="$work_dir/RetroRewind6" rr_payload="$work_dir/retro-wfc/binary/payload.RMCPD00.bin" -rr_version_ok() { [[ "$1" =~ ^[0-9]+(\.[0-9]+)+$ ]]; } -rr_newer() { - # True when version $1 is newer than $2 (dotted numbers, as 6.12.7). - [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1)" == "$1" ]] -} -rr_fetch() { curl -fsSL --retry 2 "$1"; } rr_unpack_update() { # rr_unpack_update URL DEST: downloads a published zip and lays its RetroRewind6/ tree over DEST. # Only that tree is kept; the Riivolution XML beside it belongs to a Wii setup. @@ -499,8 +683,31 @@ fi "$runtime" start "$container" >/dev/null note "container $container (mounts ${want_mounts//;/ })" +build_progress() { + # Passes the build's output on, and keeps the game's status file at the stage it is on and how + # far through it ninja is, from the "[done/total]" lines it prints. + local line stage=Building percent last=-1 + while IFS= read -r line; do + printf '%s\n' "$line" + [[ -n "${WIICOMPILED_UPDATE_STATUS:-}" ]] || continue + if [[ "$line" == "== "* ]]; then + stage=${line#== } + stage=${stage%% (*} + last=-1 + status running "$stage" + elif [[ "$line" =~ ^\[([0-9]+)/([0-9]+)\] ]] && (( BASH_REMATCH[2] > 0 )); then + percent=$(( BASH_REMATCH[1] * 100 / BASH_REMATCH[2] )) + if (( percent != last )); then + last=$percent + status running "$stage, $percent% built" + fi + fi + done +} + say "Building (the first time takes hours under emulation; a log is in $work_dir/build.log)" -if ! "$runtime" exec -e JOBS="$jobs" -e RETRO_REWIND="$retro_rewind" "$container" bash /work/container-build.sh 2>&1 | tee "$work_dir/build.log"; then +if ! "$runtime" exec -e JOBS="$jobs" -e RETRO_REWIND="$retro_rewind" "$container" bash /work/container-build.sh 2>&1 | + tee "$work_dir/build.log" | build_progress; then fail "the build stopped; the end of $work_dir/build.log says why. Run the script again to resume. A machine that froze ran out of memory: pass a lower --jobs." fi @@ -520,12 +727,7 @@ if [[ -z "$frame" ]]; then fi say "Installing on the Frame" -# One SSH connection for every step, so a password is asked for once. -ssh_opts=(-o ControlMaster=auto -o "ControlPath=${XDG_RUNTIME_DIR:-/tmp}/wiicompiled-ssh-%C" -o ControlPersist=600) -on_frame() { - # Runs a bash script, given on stdin, on the Frame; its arguments follow. - if [[ "$frame" == local ]]; then bash -s -- "$@"; else ssh "${ssh_opts[@]}" "$frame" bash -s -- "$@"; fi -} +# on_frame and the SSH options it uses are up with the version helpers. copy_to_frame() { # copy_to_frame SOURCE DEST: DEST is absolute or relative to the Frame's home. A DEST ending in # / is an existing folder SOURCE goes into; otherwise SOURCE is copied to that new name. @@ -563,6 +765,11 @@ EOF copy_to_frame "$out/$id" "$dir/$id.new" on_frame "$dir" "$id" <<'EOF' cd "$HOME/$1" && mv -f "$2.new" "$2" && chmod -R u=rwX,go=rX . && chmod 755 "$2" +EOF + # Which release is on the Frame, for --check and the game's Updates tab. A source build has no + # release to name, and the stamp is removed so neither claims a release this is not. + on_frame "$dir" "$release" <<'EOF' +cd "$HOME/$1" && if [[ -n "$2" ]]; then printf '%s\n' "$2" > .release-tag; else rm -f .release-tag; fi EOF } install_game "$game_id" "$work_dir/out" @@ -594,13 +801,8 @@ fi if (( retro_rewind )); then # The pack goes beside the disc; copied again only when its version changed. - frame_pack_path="$(dirname "$frame_disc_path")/RetroRewind6" - [[ "$frame_disc_path" == */* ]] || frame_pack_path=RetroRewind6 - frame_pack_version=$(on_frame "$frame_pack_path" <<'EOF' -[[ "$1" = /* ]] && d=$1 || d="$HOME/$1" -tr -d '[:space:]' 2>/dev/null < "$d/version.txt" || true -EOF -) + frame_pack_path=$(frame_pack_path_for "$frame_disc_path") + frame_pack_version=$(read_on_frame "$frame_pack_path/version.txt") local_pack_version=$(tr -d '[:space:]' 2>/dev/null < "$rr_pack/version.txt" || true) if [[ -z "$frame_pack_version" || "$frame_pack_version" != "$local_pack_version" ]]; then note "copying the Retro Rewind pack to $frame_pack_path (about 4 GB)" @@ -672,9 +874,53 @@ esac register_game "$game_id" "$work_dir/out" if (( retro_rewind )); then register_game RetroRewind "$work_dir/out-retro-rewind"; fi +# --------------------------------------------------------------------------------------------- +# Updating from inside the game. Only an install on the Frame itself can do it, since that is the +# only one with the build here; installed from a PC, the game's Updates tab says to update there. +if [[ "$frame" == local ]]; then + note "setting up the game's Update button" + update_script="$work_dir/steam-frame-install.sh" + # A copy, so an update never runs from the source tree it is about to overwrite. + cp -f "$source_dir/Launcher/steam-frame-install.sh" "$update_script" + chmod +x "$update_script" + mkdir -p "$game_data" "$HOME/.config/systemd/user" + cat > "$game_data/update.conf" < "$HOME/.config/systemd/user/wiicompiled-update.service" </dev/null 2>&1 || + note "(systemd did not reload, so the Update button will not work until the Frame restarts)" +fi + +save_settings + say "Done" -note "Start $game_id from your library in the headset. Settings: left shoulder button, VR tab." +status done "Updated${release:+ to }${release}" +if [[ "$mode" == update ]]; then + note "Updated${release:+ to }$release." +else + note "Start $game_id from your library in the headset. Settings: left shoulder button, VR tab." +fi if (( retro_rewind )); then note "RetroRewind is beside it, and shares its settings."; fi +relaunch } main "$@" ` builds a given release instead. +```bash +Launcher/steam-frame-install.sh --update +``` + +`--update` reuses the options the install was made with, so there is nothing to repeat: it reads +them from `install.conf` in the work dir, and any option you do pass wins over the saved one. It +downloads the newest release, puts only the files that changed over the old source, rebuilds what +they touch, and replaces the game on the Frame. Dawn is only rebuilt when a release changes its +patches (the release notes say so), so an update usually takes minutes. When the Frame already has +the newest release, and the newest Retro Rewind pack if you use one, it says so and builds nothing. + +`--check` only reports whether an update is available. `--release ` builds a given release +instead. Running the whole install command again works as it always did. + +### Updating from inside the game + +An install built on the Frame itself (`--frame local`, [below](#other-ways-to-build)) can update +itself: the settings panel grows an **Updates** tab with the release you are running and a **Check +for updates** button, and when there is one, **Update now** builds it in the background. The tab +shows each step and how far the build is while you keep playing (the game may stutter while it +compiles), and says when to restart the game to use the update. + +The update runs as a systemd user service (`wiicompiled-update.service`), behind the game in line +for the processor, so it carries on if you close the game, and then opens the game again when it is +done. Keep the Frame on its charger for it. Installed from a PC, the tab says so and the update is +run there. Progress is not shown as Steam notifications: on the Frame, Steam receives them from +SteamOS's notification service but does not display them. ## Recommended settings @@ -314,7 +337,8 @@ curl -fsSL https://raw.githubusercontent.com/mitch030504/Wiicompiled_VR_Frame/op ``` It is native ARM64, so no emulation, but the Frame has less memory and cooling than a PC; keep it -on its charger. The game then reads the disc straight from `~/wiicompiled-frame/disc`. By hand, the +on its charger. The game then reads the disc straight from `~/wiicompiled-frame/disc`, and this is +the install that can [update itself from inside the game](#updating-from-inside-the-game). By hand, the steps under [Building by hand](#building-by-hand) work the same in a container started without `--platform linux/arm64`, with `nodtool-linux-aarch64` in place of `nodtool-linux-x86_64`. diff --git a/docs/releases/frame-beta-3.md b/docs/releases/frame-beta-3.md index 2456328..5ec918d 100644 --- a/docs/releases/frame-beta-3.md +++ b/docs/releases/frame-beta-3.md @@ -16,6 +16,13 @@ and nothing built from it may be distributed, so there is no ready-built game he - **Adaptive resolution** (`[vr] adaptive_resolution`, off by default): lowers the race's eye resolution down to 70% while frames fall behind, and raises it again once they keep up. Experimental and not yet tried in a race on the Frame. From Wiicompiled_VR-PLUS. +- **Updating, from the PC or from inside the game.** `steam-frame-install.sh --update` reuses the + options the install was made with and builds nothing when the Frame already has the newest + release (and Retro Rewind pack); `--check` only reports. An install built on the Frame itself + (`--frame local`) gets an **Updates** tab in the settings panel: it checks for a new release and + builds it in the background while the game stays open, showing each step and how far the build + is. Close the game meanwhile and the update reopens it when done. Steam notifications were tried + for this and do not show on the Frame. - **`Launcher/frame-diagnostics.sh`** gathers the logs, crash files, settings, SteamVR logs and system state from the Frame into one archive, with a summary of how far startup got. Attach it to bug reports. @@ -51,6 +58,10 @@ Run the install command again; you can leave out `--disc`. Dawn's patches did no not rebuilt. Most of the game is, since the translator changed as well as the runtime and renderer, so expect a longer update than usual. The build now also downloads Mbed TLS. Add `--retro-rewind` to get Retro Rewind too. +frame-beta-2's script has no `--update`, so this one update is the full install command; it saves +its options, and from then on `steam-frame-install.sh --update` is enough. To update from inside +the game later, make this install on the Frame itself with `--frame local`. + ## Known issues Unchanged from frame-beta-2: diff --git a/runtime/include/update_check.h b/runtime/include/update_check.h new file mode 100644 index 0000000..4e4de1b --- /dev/null +++ b/runtime/include/update_check.h @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +// Settings > Updates: whether a newer release of the game exists, and, on a Frame that holds its own +// build, updating to it without leaving the headset. +// +// Both answers come from Launcher/steam-frame-install.sh, which already knows how to compare +// versions and how to build: `--check` reports, `--update` installs. The update reports its progress +// through a status file rather than Steam notifications, which the Frame's Steam does not show. +// The install writes update.conf beside the player's configuration when it put a build on this +// machine, so the Update button appears only where it can actually work; installed from a PC, the +// tab says to update there. +namespace update_check { + +enum class State { + // This install cannot update itself: it was built on a PC, which is where updates are run. + PcInstall, + // It can, and nothing has been asked of it yet this session. + Idle, + Checking, + UpToDate, + Available, + // An update is running beside the game, as a service of its own: it carries on if the game is + // closed, and then opens it again when it is done. + Updating, + Failed, +}; + +struct Status { + State state = State::PcInstall; + // The release this build came from, or empty when it was built from a source tree. + std::string installedRelease; + // One line under the state: which release is available, what failed, or what step is running. + std::string detail; + // An update has installed a different build since this one started: it takes effect when the + // game is started again. + bool restartNeeded = false; +}; + +// What the Updates tab draws. Cheap enough to call every frame: a check or update in flight is +// answered from memory, and otherwise the files behind it are re-read at most once a second. +Status Current(); + +// Whether there is an Updates tab to show at all: this install can update itself, or it at least +// knows which release it is running. Neither is true of a build that came from somewhere else. +inline bool Relevant(const Status& status) { + return status.state != State::PcInstall || !status.installedRelease.empty(); +} + +// Asks the install script whether a newer release exists, in the background. Returns at once. +void StartCheck(); + +// Starts the update in the background: its own systemd user service runs the install script, which +// reports each step to the file Current() reads, so the game can stay open and show the progress. +// On false, `error` says why, for display. +bool StartUpdate(std::string& error); + +} // namespace update_check diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index ecc5926..c45e7c6 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -11,6 +11,7 @@ #include "music_attenuation.h" #include "runtime_config.h" #include "runtime_log.h" +#include "update_check.h" #include "vr/camera_toggle.h" #include "vr/mkw_vr_culling.h" #include "vr/mkw_vr_first_person.h" @@ -2094,6 +2095,107 @@ void DrawDiagnosticsSettings() { } } +bool g_updatePromptOpen = false; +std::string g_updateError; + +void DrawUpdatePrompt() { + constexpr const char* kTitle = "Update"; + if (g_updatePromptOpen && !ImGui::IsPopupOpen(kTitle)) ImGui::OpenPopup(kTitle); + if (!ImGui::BeginPopupModal(kTitle, &g_updatePromptOpen, ImGuiWindowFlags_AlwaysAutoResize)) return; + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f)); + ImGui::TextUnformatted("The update is built here on the Frame, in the background, which takes a " + "while: keep the Frame on its charger. You can keep playing meanwhile, " + "though the game may stutter while it compiles. Close the game whenever " + "you like: the update carries on and opens it again when it is done."); + ImGui::PopTextWrapPos(); + if (ImGui::Button("Start the update", ImVec2(Scaled(180.0f), 0.0f))) { + update_check::StartUpdate(g_updateError); + g_updatePromptOpen = false; + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(Scaled(120.0f), 0.0f))) g_updatePromptOpen = false; + if (!g_updatePromptOpen) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); +} + +void DrawUpdateSettings() { + const update_check::Status status = update_check::Current(); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f)); + if (status.installedRelease.empty()) { + ImGui::TextUnformatted("This build came from a source tree rather than a release."); + } else { + ImGui::Text("Release %s", status.installedRelease.c_str()); + } + + const bool busy = status.state == update_check::State::Checking || + status.state == update_check::State::Updating; + switch (status.state) { + case update_check::State::PcInstall: + ImGui::TextDisabled("The PC that built this installs its updates. Run the installer there " + "again with --update."); + break; + case update_check::State::Idle: + break; + case update_check::State::Checking: + ImGui::TextDisabled("Looking for a newer release..."); + break; + case update_check::State::UpToDate: + ImGui::TextDisabled("%s", status.detail.c_str()); + break; + case update_check::State::Available: + ImGui::TextUnformatted(status.detail.c_str()); + break; + case update_check::State::Updating: + ImGui::Text("Updating: %s", status.detail.c_str()); + ImGui::TextDisabled("Keep playing, or close the game: the update carries on and opens it " + "again when it is done."); + break; + case update_check::State::Failed: + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.35f, 1.0f), "%s", status.detail.c_str()); + break; + } + if (!g_updateError.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.35f, 1.0f), "%s", g_updateError.c_str()); + } + if (status.restartNeeded && status.state != update_check::State::Updating) { + // The update replaced the build on disk; this process is still the old one. + ImGui::TextUnformatted("The update is installed. Quit and start the game again to use it."); + } + ImGui::PopTextWrapPos(); + + if (status.state == update_check::State::PcInstall) { + return; + } + if (status.restartNeeded && status.state != update_check::State::Updating) { + if (ImGui::Button("Quit")) g_exitPromptOpen = true; + ImGui::SameLine(); + } + ImGui::BeginDisabled(busy); + if (ImGui::Button(status.state == update_check::State::Idle ? "Check for updates" : "Check again")) { + g_updateError.clear(); + update_check::StartCheck(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Asks github.com for the newest release, and Retro Rewind's server for\n" + "its newest pack when this install has one. Nothing is downloaded\n" + "until you choose to update."); + } + if (status.state == update_check::State::Available) { + ImGui::SameLine(); + if (ImGui::Button("Update now")) { + g_updateError.clear(); + g_updatePromptOpen = true; + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Builds the update here on the Frame, in the background. The game\n" + "can stay open and shows the progress here."); + } + } + ImGui::EndDisabled(); + // The prompt itself is drawn at the top level, as the exit prompt is: the tab lives both in the + // headset's panel and in a desktop menu, and a modal must outlive whichever opened it. +} + void DrawFpsOverlay() { AuroraPresentTiming presentTiming{}; aurora_get_present_timing(&presentTiming); @@ -2291,6 +2393,11 @@ void DrawTopBar() { ImGui::EndMenu(); } + if (update_check::Relevant(update_check::Current()) && ImGui::BeginMenu("Updates")) { + DrawUpdateSettings(); + ImGui::EndMenu(); + } + const ImGuiStyle& style = ImGui::GetStyle(); const float hideWidth = ImGui::CalcTextSize("Hide (F10)").x + style.FramePadding.x * 2.0f; const float exitWidth = ImGui::CalcTextSize("X").x + style.FramePadding.x * 2.0f; @@ -2479,6 +2586,9 @@ void DrawVrSettingsPanelWindow() { tab("Controllers", DrawControllerSettings); tab("Audio", DrawAudioSettings); tab("Diagnostics", DrawDiagnosticsSettings); + if (update_check::Relevant(update_check::Current())) { + tab("Updates", DrawUpdateSettings); + } ImGui::EndTabBar(); } } @@ -2687,6 +2797,7 @@ void Draw() noexcept { DrawFpsOverlay(); DrawTopBar(); DrawExitPrompt(); + DrawUpdatePrompt(); controller_mapping_wizard::Draw(); // The wizard captures raw presses; keep them out of the game. const bool inputBlocked = controller_mapping_wizard::IsActive() || g_rebind.active; diff --git a/runtime/src/update_check.cpp b/runtime/src/update_check.cpp new file mode 100644 index 0000000..8025c5a --- /dev/null +++ b/runtime/src/update_check.cpp @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "update_check.h" + +#include "runtime_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Updating itself is the Frame's build, installed by Launcher/steam-frame-install.sh: a desktop +// Linux game that holds its own build directory. Everywhere else the tab only reports the release +// it is running, which needs none of the machinery below. +#if defined(__linux__) && !defined(__ANDROID__) +#define MKW_UPDATE_CHECK_SUPPORTED 1 +#include +#else +#define MKW_UPDATE_CHECK_SUPPORTED 0 +#endif + +namespace update_check { +namespace { + +// Written by Launcher/steam-frame-install.sh when it built on this machine, naming itself and the +// service that runs it. Its absence is what tells the tab this install is updated from a PC. +constexpr std::string_view kConfigFileName = "update.conf"; +// An update's progress, and a check's answer. Separate files, so a check never overwrites the +// result of the update that opened the game. +constexpr std::string_view kStatusFileName = "update-status"; +constexpr std::string_view kCheckStatusFileName = "update-status-check"; +// What the Update button leaves for the update to pick up. +constexpr std::string_view kRequestFileName = "update-request"; +// Stamped beside the executable by the install; absent for a build from a source tree. +constexpr std::string_view kReleaseTagFileName = ".release-tag"; + +// A finished update's status belongs on screen when the game opens right afterwards, which is what +// the update does. Older than this it is history, and the tab starts clean. +constexpr auto kStatusLifetime = std::chrono::hours(1); +// A running update rewrites its status at every step and every percent of its build, so one that +// has said nothing for this long was stopped without a chance to say so (the Frame lost power). +constexpr auto kRunningStatusLifetime = std::chrono::hours(2); +// The tab is drawn every frame; the files behind it are not read anywhere near that often. +constexpr auto kReadInterval = std::chrono::seconds(1); + +std::string Trimmed(std::string_view text) { + const auto first = text.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) { + return {}; + } + return std::string(text.substr(first, text.find_last_not_of(" \t\r\n") - first + 1)); +} + +std::string ReadFirstLine(const std::filesystem::path& file) { + std::ifstream stream(file); + std::string line; + if (!stream || !std::getline(stream, line)) { + return {}; + } + return Trimmed(line); +} + +struct Config { + std::filesystem::path script; + // The install's work directory, which holds the build the update reuses. The script defaults to + // the same place, but an install given --work-dir does not live there. + std::filesystem::path workDir; + std::string service; + + bool Usable() const { return !script.empty() && !service.empty(); } +}; + +Config ReadConfig() { + Config config; +#if !MKW_UPDATE_CHECK_SUPPORTED + return config; +#else + std::ifstream stream(RuntimeConfigFile::ApplicationDataDirectory() / kConfigFileName); + if (!stream) { + return config; + } + std::string line; + while (std::getline(stream, line)) { + const auto separator = line.find('='); + if (line.empty() || line.front() == '#' || separator == std::string::npos) { + continue; + } + const std::string key = Trimmed(std::string_view(line).substr(0, separator)); + const std::string value = Trimmed(std::string_view(line).substr(separator + 1)); + if (key == "script") { + config.script = value; + } else if (key == "work_dir") { + config.workDir = value; + } else if (key == "service") { + config.service = value; + } + } + std::error_code ec; + if (!config.script.empty() && !std::filesystem::is_regular_file(config.script, ec)) { + // The work directory was removed, so there is nothing here to update from any more. + config.script.clear(); + } + return config; +#endif +} + +std::string InstalledRelease() { + if (const auto executableDirectory = RuntimeConfigFile::ExecutableDirectory()) { + return ReadFirstLine(*executableDirectory / kReleaseTagFileName); + } + return {}; +} + +// What this session has been told, which outranks the files until the game is next started: a check +// or update it asked for, and what came back. +std::mutex g_mutex; +bool g_sessionStateSet = false; +State g_sessionState = State::Idle; +std::string g_sessionDetail; +// What was last read from disk, and when, so drawing the tab does not re-read it every frame. +std::chrono::steady_clock::time_point g_lastRead; +bool g_haveRead = false; +bool g_haveFileState = false; +State g_fileState = State::Idle; +std::string g_fileDetail; +bool g_canUpdate = false; +// The release this process was started from, read once: an update replaces the stamp on disk while +// the old build is still the one running. The stamp as it is now tells whether that has happened. +std::string g_runningRelease; +bool g_haveRunningRelease = false; +std::string g_releaseOnDisk; + +void SetSessionState(State state, std::string detail) { + const std::lock_guard lock(g_mutex); + g_sessionStateSet = true; + g_sessionState = state; + g_sessionDetail = std::move(detail); +} + +std::string ShellQuoted(const std::string& text) { + std::string quoted = "'"; + for (const char character : text) { + if (character == '\'') { + quoted += "'\\''"; + } else { + quoted += character; + } + } + quoted += '\''; + return quoted; +} + +// Runs `command`, returning its exit status and keeping the last line of its output for a message. +int Run(const std::string& command, std::string& lastLine) { + lastLine.clear(); +#if !MKW_UPDATE_CHECK_SUPPORTED + (void)command; + return -1; +#else + FILE* pipe = popen(command.c_str(), "r"); + if (pipe == nullptr) { + return -1; + } + std::array buffer{}; + while (std::fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { + if (const std::string line = Trimmed(buffer.data()); !line.empty()) { + lastLine = line; + } + } + const int status = pclose(pipe); + if (status == -1 || !WIFEXITED(status)) { + return -1; + } + return WEXITSTATUS(status); +#endif +} + +// Reads a "\t\t" line the script wrote. False when there is none, it is too +// old to be about now, or it cannot be understood. +bool ReadStatusFile(const std::filesystem::path& file, State& state, std::string& detail) { + const std::string line = ReadFirstLine(file); + if (line.empty()) { + return false; + } + const auto firstTab = line.find('\t'); + const auto secondTab = line.find('\t', firstTab + 1); + if (firstTab == std::string::npos || secondTab == std::string::npos) { + return false; + } + const std::string written = line.substr(0, firstTab); + long long seconds = 0; + const char* const secondsBegin = line.data() + firstTab + 1; + if (std::from_chars(secondsBegin, line.data() + secondTab, seconds).ec != std::errc{}) { + return false; + } + const auto age = std::chrono::system_clock::now() - + std::chrono::system_clock::time_point(std::chrono::seconds(seconds)); + const auto lifetime = written == "running" ? kRunningStatusLifetime : kStatusLifetime; + if (age > lifetime || age < -lifetime) { + return false; + } + if (written == "running") { + state = State::Updating; + } else if (written == "available") { + state = State::Available; + } else if (written == "uptodate" || written == "done") { + state = State::UpToDate; + } else if (written == "failed") { + state = State::Failed; + } else { + return false; + } + detail = line.substr(secondTab + 1); + return true; +} + +} // namespace + +Status Current() { + const std::lock_guard lock(g_mutex); + // Drawn every frame, so the files behind it are read on a timer instead. + const auto now = std::chrono::steady_clock::now(); + if (!g_haveRead || now - g_lastRead > kReadInterval) { + g_releaseOnDisk = InstalledRelease(); + if (!g_haveRunningRelease) { + g_runningRelease = g_releaseOnDisk; + g_haveRunningRelease = true; + } + g_lastRead = now; + g_haveRead = true; + g_canUpdate = ReadConfig().Usable(); + g_fileState = State::Idle; + g_fileDetail.clear(); + g_haveFileState = ReadStatusFile(RuntimeConfigFile::ApplicationDataDirectory() / kStatusFileName, + g_fileState, g_fileDetail); + } + + Status status; + status.installedRelease = g_runningRelease; + status.restartNeeded = g_releaseOnDisk != g_runningRelease; + if (!g_canUpdate) { + status.state = State::PcInstall; + return status; + } + // An update's own reports outrank what this session last said: one started here (the session + // says Updating until the update writes its first step), or one this game was reopened after. + const bool followUpdate = !g_sessionStateSet || g_sessionState == State::Updating; + if (followUpdate && g_haveFileState) { + status.state = g_fileState; + status.detail = g_fileDetail; + } else if (g_sessionStateSet) { + status.state = g_sessionState; + status.detail = g_sessionDetail; + } else { + status.state = State::Idle; + } + return status; +} + +void StartCheck() { + { + const std::lock_guard lock(g_mutex); + if (g_sessionStateSet && (g_sessionState == State::Checking || g_sessionState == State::Updating)) { + return; + } + } + const Config config = ReadConfig(); + if (!config.Usable()) { + return; + } + SetSessionState(State::Checking, "Asking github.com for the newest release"); + + // Detached: it talks to the network and must not hold up a frame, and there is no shutdown + // ordering to manage because it touches nothing the game owns. + std::thread([config] { + const auto statusFile = RuntimeConfigFile::ApplicationDataDirectory() / kCheckStatusFileName; + std::error_code ec; + std::filesystem::remove(statusFile, ec); + std::string command = "WIICOMPILED_UPDATE_STATUS=" + ShellQuoted(statusFile.string()) + ' ' + + ShellQuoted(config.script.string()) + " --check"; + if (!config.workDir.empty()) { + command += " --work-dir " + ShellQuoted(config.workDir.string()); + } + command += " 2>&1"; + std::string lastLine; + const int exitStatus = Run(command, lastLine); + + State state = State::Failed; + std::string detail; + if (ReadStatusFile(statusFile, state, detail)) { + SetSessionState(state, detail); + } else if (exitStatus == 0) { + // It ran and said nothing this can read, which is still an answer: ask again later. + SetSessionState(State::Idle, "The check said nothing this version understands"); + } else { + std::cerr << "[update] the check failed (exit " << exitStatus << "): " << lastLine << std::endl; + SetSessionState(State::Failed, + lastLine.empty() ? "The check could not be run" : lastLine); + } + std::filesystem::remove(statusFile, ec); + }).detach(); +} + +bool StartUpdate(std::string& error) { + error.clear(); + const Config config = ReadConfig(); + if (!config.Usable()) { + error = "This build was installed from a PC, so the update is run there."; + return false; + } + + const auto dataDirectory = RuntimeConfigFile::ApplicationDataDirectory(); + std::error_code ec; + std::filesystem::create_directories(dataDirectory, ec); + // How the update opens the game again when it is done. Steam names the running title; without + // one (a build started from a terminal) the update still runs and the game is started by hand. + std::string steamGameId; + for (const char* variable : {"SteamGameId", "SteamAppId"}) { + if (const char* value = std::getenv(variable); value != nullptr && *value != '\0') { + steamGameId = value; + break; + } + } + { + std::ofstream request(dataDirectory / kRequestFileName, std::ios::trunc); + if (!request) { + error = "Could not write to " + dataDirectory.string(); + return false; + } + request << "# Written by the game's Update button; the update removes it.\n"; + if (!steamGameId.empty()) { + request << "steam_game_id=" << steamGameId << '\n'; + } + } + // The previous update's result must not be read as this one's before it has written anything. + std::filesystem::remove(dataDirectory / kStatusFileName, ec); + // The update must outlive the game if the player closes it meanwhile, and Steam ends the game's + // own processes when it closes, so it runs as a service of its own. --no-block: started, not + // waited for. + std::string lastLine; + const std::string command = + "systemctl --user start --no-block " + ShellQuoted(config.service) + " 2>&1"; + if (const int exitStatus = Run(command, lastLine); exitStatus != 0) { + std::filesystem::remove(dataDirectory / kRequestFileName, ec); + std::cerr << "[update] could not start " << config.service << " (exit " << exitStatus + << "): " << lastLine << std::endl; + error = lastLine.empty() ? "Could not start the update service" : lastLine; + return false; + } + SetSessionState(State::Updating, "Starting the update"); + // Read the files again at once, rather than show the removed status for up to a second. + const std::lock_guard lock(g_mutex); + g_haveRead = false; + return true; +} + +} // namespace update_check + +#undef MKW_UPDATE_CHECK_SUPPORTED