diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a65c9a6..3e43b9b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,8 @@ jobs: run: python -m pip install pyinstaller==6.19.0 numpy==2.2.6 Pillow==11.3.0 - name: Build Windows package shell: pwsh + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} run: ./Build-Release.ps1 - uses: actions/upload-artifact@v4 with: @@ -25,10 +27,11 @@ jobs: path: | target/skate3rust-windows-x64.zip target/skate3rust-windows-x64.zip.sha256 + target/release.json - name: Attach to release if: github.event_name == 'release' shell: pwsh env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$env:RELEASE_TAG" target/skate3rust-windows-x64.zip target/skate3rust-windows-x64.zip.sha256 --clobber + run: gh release upload "$env:RELEASE_TAG" target/skate3rust-windows-x64.zip target/skate3rust-windows-x64.zip.sha256 target/release.json --clobber diff --git a/Build-Release.ps1 b/Build-Release.ps1 index 8fa7937d..dc047897 100644 --- a/Build-Release.ps1 +++ b/Build-Release.ps1 @@ -56,6 +56,22 @@ try { --add-data "$sourceStage/tools;tools" --add-data "$PSScriptRoot/docs/images/skating-crab.ico;docs/images" ` --distpath "$stage/support" --workpath target/setup-build/work --specpath target/setup-build tools/setup.py if ($LASTEXITCODE -ne 0) { throw 'Setup packaging failed' } + & $packagePython -m PyInstaller --noconfirm --clean --onefile --windowed --name skate3update ` + --distpath "$stage/support" --workpath target/updater-build/work --specpath target/updater-build tools/updater.py + if ($LASTEXITCODE -ne 0) { throw 'Updater packaging failed' } + # GitHub run number is monotonic across releases, including prereleases. Re-runs + # deliberately retain identity; publish a new run/tag for a new eligible build. + $build = if ($env:GITHUB_RUN_NUMBER) { [long]$env:GITHUB_RUN_NUMBER } else { 0 } + $tag = if ($env:RELEASE_TAG) { $env:RELEASE_TAG } else { 'development' } + $files = @{} + foreach ($name in @('skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe')) { + $files[$name] = (Get-FileHash -LiteralPath (Join-Path $stage $name) -Algorithm SHA256).Hash.ToLower() + } + @{ + schema = 1; repository = 'SK8-ENGINE/skate-3-rust-engine'; target = 'windows-x64' + build = $build; tag = $tag; revision = (& git rev-parse HEAD).Trim(); files = $files + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath "$stage/release.json" -Encoding utf8 + Copy-Item -LiteralPath "$stage/release.json" -Destination (Join-Path $PSScriptRoot 'target/release.json') Copy-Item -LiteralPath README.md,THIRD_PARTY_NOTICES.md -Destination $stage New-Item -ItemType Directory -Path "$stage/docs/images" -Force | Out-Null Copy-Item -LiteralPath docs/images/skating-crab.png -Destination "$stage/docs/images/skating-crab.png" diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 00bc203e..f2a67ee8 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -110,6 +110,7 @@ pub(crate) fn build( )); app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin)); app.add_plugins(crate::custom_models::CustomModelsPlugin); + app.add_plugins(crate::updater::UpdaterPlugin); app.add_plugins(crate::multiplayer::MultiplayerPlugin); app.add_plugins(crate::scoring_hud::ScoringHudPlugin); app.add_systems(Last, crate::crash_context::sample); diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index f4489487..b5c5eb7c 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -218,7 +218,7 @@ fn setup( BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { panel.spawn((Text::new("GAME MENU"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); - for i in 0..13 { + for i in 0..14 { panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(32),padding:UiRect::all(px(6)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| { row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE))); @@ -272,6 +272,7 @@ pub(crate) fn interact( mut exit: MessageWriter, mut net: ResMut, mut typing: MessageReader, + mut updater: ResMut, ) { if transition.busy() { menu.open = true; @@ -304,7 +305,7 @@ pub(crate) fn interact( } } if menu.open { - let rows = if menu.multiplayer { 11 } else { 13 }; + let rows = if menu.multiplayer { 11 } else { 14 }; if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { menu.selected = (menu.selected + rows - 1) % rows; } @@ -423,6 +424,7 @@ pub(crate) fn interact( menu.selected = 0; } 12 => custom_models.begin(), + 13 => menu.status = updater.open(false), _ => {} } } @@ -607,6 +609,7 @@ fn labels( 9 => "Quit game".into(), 10 => "Character customiser".into(), 12 => "Custom models".into(), + 13 => "Updates".into(), _ => "Multiplayer".into(), } }; diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index a73f6e3c..61ef3385 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -16,6 +16,7 @@ mod assets; mod camera; mod config; mod setup; +mod updater; mod map_library; mod map_render; mod map_transition; @@ -42,6 +43,11 @@ mod grind_world; mod skate_world; fn main() -> bevy::app::AppExit { + match updater::recover() { + Ok(true) => return bevy::app::AppExit::Success, + Err(error) => { eprintln!("{error}"); return bevy::app::AppExit::Success; }, + Ok(false) => {} + } if let Some(code) = crash_report::entry() { std::process::exit(code); } eprintln!("REPORT_META stage=configuration_and_installation"); let config = match config::Config::from_env() { diff --git a/crates/skate-game/src/updater.rs b/crates/skate-game/src/updater.rs new file mode 100644 index 00000000..2bd2aebc --- /dev/null +++ b/crates/skate-game/src/updater.rs @@ -0,0 +1,87 @@ +//! The packaged helper owns networking/UI/staging. Only its ready signal exits Bevy. +use bevy::prelude::*; +use std::{path::PathBuf, process::{Child, Command}, time::{SystemTime, UNIX_EPOCH}}; + +#[derive(Resource, Default)] +pub(crate) struct Updater { + child: Option, + signal: Option, + temporary: Option, +} + +fn helper_command(recover: bool, automatic: bool) -> Result<(Command, PathBuf, PathBuf), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let root = exe.parent().ok_or("Missing program directory")?; + if !root.join("release.json").is_file() { + return Err("Updates are available in packaged releases.".into()); + } + let unique = SystemTime::now().duration_since(UNIX_EPOCH).map_err(|e| e.to_string())?.as_nanos(); + let temp = std::env::temp_dir().join(format!("skate-update-{}-{unique}", std::process::id())); + std::fs::create_dir(&temp).map_err(|e| e.to_string())?; + let helper = temp.join("skate3update.exe"); + std::fs::copy(root.join("support/skate3update.exe"), &helper).map_err(|e| e.to_string())?; + let signal = temp.join("ready"); + let request = temp.join("request.json"); + let data = serde_json::json!({ + "root": root, "signal": signal, "automatic": automatic, "recover": recover, + "cwd": std::env::current_dir().map_err(|e| e.to_string())?, + "args": std::env::args().skip(1).collect::>() + }); + std::fs::write(&request, serde_json::to_vec(&data).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + let mut command = Command::new(helper); + command.arg("--request").arg(request); + #[cfg(windows)] { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + Ok((command, signal, temp)) +} + +/// Local recovery happens before the supervisor opens the executable again. +pub(crate) fn recover() -> Result { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + if !exe.parent().is_some_and(|p| p.join(".update-transaction/journal.json").exists()) { + return Ok(false); + } + let (mut command, _, _) = helper_command(true, false)?; + command.spawn().map_err(|e| e.to_string())?; + Ok(true) +} + +impl Updater { + pub(crate) fn open(&mut self, automatic: bool) -> String { + if self.child.is_some() { return "Updates window is already open.".into(); } + match helper_command(false, automatic).and_then(|(mut cmd, signal, temp)| { + cmd.spawn().map(|child| (child, signal, temp)).map_err(|e| e.to_string()) + }) { + Ok((child, signal, temp)) => { + self.child = Some(child); + self.signal = Some(signal); + self.temporary = Some(temp); + "Updates opened in a separate window.".into() + } + Err(error) => error, + } + } +} + +pub(crate) struct UpdaterPlugin; +impl Plugin for UpdaterPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, |mut updater: ResMut| { updater.open(true); }) + .add_systems(Update, poll); + } +} +fn poll(mut updater: ResMut, mut exit: MessageWriter) { + if updater.signal.as_ref().is_some_and(|p| p.is_file()) { + updater.signal = None; + // Success is intentional: the crash supervisor must not report an update. + exit.write(AppExit::Success); + } + if updater.child.as_mut().is_some_and(|c| matches!(c.try_wait(), Ok(Some(_)))) { + updater.child = None; + updater.signal = None; + if let Some(temp) = updater.temporary.take() { let _ = std::fs::remove_dir_all(temp); } + } +} diff --git a/tools/test_updater.py b/tools/test_updater.py new file mode 100644 index 00000000..56fc05ba --- /dev/null +++ b/tools/test_updater.py @@ -0,0 +1,107 @@ +"""Isolated tests: no UI, game process, real installation or network.""" +import hashlib +import io +import json +from pathlib import Path +import tempfile +import threading +import unittest +from unittest.mock import patch +import zipfile +import updater as u + + +def metadata(build=2): + return dict(schema=1, repository=u.REPO, target='windows-x64', build=build, + tag=f'v{build}', revision='a' * 40) + + +class UpdaterTests(unittest.TestCase): + def test_channels_and_identity(self): + release = dict(draft=False, published_at='2026-01-01', prerelease=True) + self.assertFalse(u.eligible(release, 'Stable')) + self.assertTrue(u.eligible(release, 'Latest')) + release['draft'] = True + self.assertFalse(u.eligible(release, 'Latest')) + with self.assertRaises(ValueError): + u.identity({**metadata(), 'target': 'linux'}) + + def test_pagination_order_and_channel(self): + def release(build, prerelease=False): + return dict(id=build, tag_name=f'v{build}', draft=False, prerelease=prerelease, + published_at='date', assets=[dict(name=n, state='uploaded', browser_download_url=f'{build}/{n}') + for n in (u.PACKAGE, u.PACKAGE+'.sha256', 'release.json')]) + def fetch(url, *args): + if url.endswith('&page=1'): + return json.dumps([release(4, True)] + [dict(draft=True)]*99).encode() + if url.endswith('&page=2'): + return json.dumps([release(3), release(2)]).encode() + return json.dumps(metadata(int(url.split('/')[0]))).encode() + with patch.object(u, 'fetch', fetch): + self.assertEqual(u.discover(metadata(1), 'Stable', threading.Event())[0], 3) + self.assertEqual(u.discover(metadata(1), 'Latest', threading.Event())[0], 4) + self.assertIsNone(u.discover(metadata(5), 'Latest', threading.Event())) + + def package(self, extra=None): + meta = metadata() + meta['files'] = {n: hashlib.sha256(b'new').hexdigest() for n in u.FILES[:-1]} + data = io.BytesIO() + with zipfile.ZipFile(data, 'w') as z: + for name in u.FILES: + z.writestr(u.PREFIX+name, json.dumps(meta) if name == 'release.json' else b'new') + if extra: + z.writestr(extra, b'evil') + archive = data.getvalue() + assets = {n: dict(browser_download_url=n) for n in (u.PACKAGE, u.PACKAGE+'.sha256')} + candidate = (2, 2, {}, assets, meta) + def fetch(url, *args): + return archive if url == u.PACKAGE else (hashlib.sha256(archive).hexdigest()+' '+u.PACKAGE).encode() + return candidate, fetch + + def test_safe_staging_and_traversal(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + candidate, fetch = self.package() + with patch.object(u, 'fetch', fetch): + u.stage(candidate, root, threading.Event(), lambda _: None) + self.assertEqual((root/'new/skate3rust.exe').read_bytes(), b'new') + for malicious in (u.PREFIX+'../evil', u.PREFIX+'skate3rust.exe', u.PREFIX+'C:evil'): + candidate, fetch = self.package(malicious) + with patch.object(u, 'fetch', fetch), self.assertRaises(ValueError): + u.stage(candidate, root, threading.Event(), lambda _: None) + + def test_checksum_failure_and_cancel(self): + candidate, fetch = self.package() + def corrupt(url, *args): + return b'bad' if url == u.PACKAGE else fetch(url, *args) + with tempfile.TemporaryDirectory() as temp: + with patch.object(u, 'fetch', corrupt), self.assertRaises(ValueError): + u.stage(candidate, Path(temp), threading.Event(), lambda _: None) + cancelled = threading.Event(); cancelled.set() + with patch.object(u, 'fetch', fetch), self.assertRaises(InterruptedError): + u.stage(candidate, Path(temp), cancelled, lambda _: None) + + def test_replacement_rollback_preserves_data(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp); tx = root/'.update-transaction' + for name in u.FILES: + (root/name).parent.mkdir(parents=True, exist_ok=True) + (root/name).write_bytes(b'old') + (tx/'new'/name).parent.mkdir(parents=True, exist_ok=True) + (tx/'new'/name).write_bytes(b'new') + (root/'player-data').write_bytes(b'untouched') + replace = u.os.replace + def fail(src, dest): + if str(src).endswith('new\\support\\skate3setup.exe') or str(src).endswith('new/support/skate3setup.exe'): + raise OSError('simulated locked file') + return replace(src, dest) + with patch.object(u.os, 'replace', fail), patch.object(u, 'retry', lambda op: op()), self.assertRaises(OSError): + u.install(root, tx) + for name in u.FILES: + self.assertEqual((root/name).read_bytes(), b'old') + self.assertEqual((root/'player-data').read_bytes(), b'untouched') + self.assertFalse((tx/'journal.json').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/updater.py b/tools/updater.py new file mode 100644 index 00000000..e4e4532b --- /dev/null +++ b/tools/updater.py @@ -0,0 +1,379 @@ +"""Release updater protocol 1. No game assets, shell commands or archive extractall.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import queue +import re +import shutil +import subprocess +import tempfile +import threading +import time +import urllib.request +import zipfile + +REPO = 'SK8-ENGINE/skate-3-rust-engine' +API = f'https://api.github.com/repos/{REPO}/releases' +PACKAGE = 'skate3rust-windows-x64.zip' +FILES = ('skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe', 'release.json') +PREFIX = 'skate3rust-windows-x64/' + + +def atomic(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_suffix('.tmp') + temp.write_text(json.dumps(value), encoding='utf-8') + os.replace(temp, path) + + +def read_json(path, default=None): + try: + return json.loads(path.read_text(encoding='utf-8-sig')) + except (OSError, ValueError): + return default + + +def identity(m): + if (m.get('schema') != 1 or m.get('target') != 'windows-x64' + or m.get('repository') != REPO or not isinstance(m.get('build'), int) + or m['build'] <= 0 or not isinstance(m.get('tag'), str) + or not re.fullmatch(r'[0-9a-f]{40}', m.get('revision', ''))): + raise ValueError('Incompatible release metadata') + return m['build'] + + +def fetch(url, cancel, limit, progress=lambda value: None): + # URLs originate only from the fixed repository API, never notes/manifest. + if not (url.startswith(API) or url.startswith(f'https://github.com/{REPO}/releases/download/')): + raise ValueError('Unexpected download URL') + req = urllib.request.Request(url, headers={'User-Agent': 'Skate3RustEngine-Updater/1', + 'X-GitHub-Api-Version': '2022-11-28'}) + deadline = time.monotonic() + 300 + result = bytearray() + with urllib.request.urlopen(req, timeout=15) as response: + if not response.url.startswith('https://'): + raise ValueError('Insecure redirect') + while True: + if cancel.is_set(): + raise InterruptedError('Cancelled') + if time.monotonic() > deadline: + raise TimeoutError('Download timed out') + block = response.read(256 * 1024) + if not block: + return bytes(result) + result.extend(block) + if len(result) > limit: + raise ValueError('Download exceeds size limit') + progress(f'Downloaded {len(result) // (1024 * 1024)} MB') + + +def eligible(release, channel): + return (not release.get('draft') and bool(release.get('published_at')) + and (channel == 'Latest' or not release.get('prerelease'))) + + +def discover(current, channel, cancel): + candidates = [] + deadline = time.monotonic() + 120 + # Exhaust pagination; never silently claim current after a truncated scan. + for page in range(1, 101): + if time.monotonic() > deadline: + raise TimeoutError('Release check timed out') + releases = json.loads(fetch(f'{API}?per_page=100&page={page}', cancel, 8 * 1024 * 1024)) + for release in releases: + if not eligible(release, channel): + continue + assets = {a['name']: a for a in release.get('assets', []) if a.get('state') == 'uploaded'} + if not {PACKAGE, PACKAGE + '.sha256', 'release.json'} <= assets.keys(): + continue + try: + meta = json.loads(fetch(assets['release.json']['browser_download_url'], cancel, 65536)) + build = identity(meta) + if meta['tag'] != release['tag_name'] or build <= identity(current): + continue + except (ValueError, KeyError): + continue + candidates.append((build, release['id'], release, assets, meta)) + if len(releases) < 100: + break + else: + raise ValueError('Too many release pages; check again later') + return max(candidates, key=lambda item: item[:2]) if candidates else None + + +def stage(candidate, directory, cancel, progress): + _, _, release, assets, meta = candidate + checksum = fetch(assets[PACKAGE + '.sha256']['browser_download_url'], cancel, 1024).decode('ascii').split() + if len(checksum) != 2 or checksum[1] != PACKAGE or not re.fullmatch('[0-9a-fA-F]{64}', checksum[0]): + raise ValueError('Invalid release checksum') + archive = fetch(assets[PACKAGE]['browser_download_url'], cancel, 1024 * 1024 * 1024, progress) + digest = hashlib.sha256(archive).hexdigest() + if digest != checksum[0].lower(): + raise ValueError('Package checksum mismatch') + api_digest = assets[PACKAGE].get('digest') + if api_digest and api_digest != 'sha256:' + digest: + raise ValueError('GitHub asset digest mismatch') + import io + with zipfile.ZipFile(io.BytesIO(archive)) as z: + names = set() + total = 0 + for info in z.infolist(): + name = info.filename + parts = name.rstrip('/').split('/') + if (not name.startswith(PREFIX) or '\\' in name or ':' in name + or any(p in ('', '.', '..') for p in parts) + or name.lower() in names or (info.external_attr >> 16) & 0o170000 == 0o120000): + raise ValueError('Unsafe archive path') + names.add(name.lower()) + total += info.file_size + if total > 2 * 1024 * 1024 * 1024: + raise ValueError('Expanded package too large') + for name in FILES: + if cancel.is_set(): + raise InterruptedError('Cancelled') + payload = z.read(PREFIX + name) + if name != 'release.json': + if hashlib.sha256(payload).hexdigest() != meta['files'][name]: + raise ValueError('Program checksum mismatch') + elif json.loads(payload.decode('utf-8-sig')) != meta: + raise ValueError('Package identity mismatch') + dest = directory / 'new' / name + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(payload) + + +def retry(operation): + deadline = time.monotonic() + 45 + while True: + try: + return operation() + except OSError: + if time.monotonic() >= deadline: + raise + time.sleep(.25) + + +def rollback(root, tx): + journal = read_json(tx / 'journal.json') + if journal is None: + return + # Fixed paths only, even when recovering a corrupted journal. + for name in reversed(FILES): + backup = tx / 'old' / name + if backup.is_file(): + retry(lambda: shutil.copy2(backup, root / name)) + (tx / 'journal.json').unlink() + + +def install(root, tx): + # Back up everything before the first replacement. Journal survives power loss. + for name in FILES: + backup = tx / 'old' / name + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(root / name, backup) + atomic(tx / 'journal.json', {'protocol': 1}) + try: + for name in FILES: + retry(lambda: os.replace(tx / 'new' / name, root / name)) + (tx / 'journal.json').unlink() + except Exception: + rollback(root, tx) + raise + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--request', type=Path, required=True) + args = parser.parse_args() + request = read_json(args.request) + root = Path(request['root']).resolve() + tx = root / '.update-transaction' + # OS releases the lock after crashes; concurrent game instances cannot install. + lock = (root / '.update.lock').open('a+b') + lock.write(b'0') + lock.flush() + lock.seek(0) + if os.name == 'nt': + import msvcrt + try: + msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1) + except OSError: + return + if request.get('recover'): + time.sleep(3) + try: + rollback(root, tx) + except Exception as error: + import tkinter.messagebox + tkinter.messagebox.showerror('Update recovery', 'Could not restore the previous program. Close other game instances and try again. Backups remain in .update-transaction/old.\n' + str(error)) + return + lock.close() + subprocess.Popen([str(root / 'skate3rust.exe'), *request['args']], cwd=request['cwd']) + return + import tkinter as tk + from tkinter import ttk + win = tk.Tk() + win.title('Skate 3 Rust Engine — Updates') + win.geometry('660x520') + automatic = request['automatic'] + if automatic: + win.withdraw() + settings_path = Path(os.environ.get('LOCALAPPDATA', str(Path.home()))) / 'Skate3RustEngine/settings/updates.json' + settings = read_json(settings_path, {}) + channel = tk.StringVar(value=settings.get('channel') if settings.get('channel') in ('Stable', 'Latest') else 'Stable') + status = tk.StringVar(value='Ready to check') + ttk.Label(win, text='Updates — Update downloads, then closes and restarts the game.').pack(pady=8) + choice = ttk.Combobox(win, textvariable=channel, values=('Stable', 'Latest'), state='readonly') + choice.pack() + ttk.Label(win, text='Stable: published releases. Latest: includes experimental prereleases.').pack() + notes = tk.Text(win, wrap='word', height=19) + notes.pack(fill='both', expand=True, padx=12, pady=8) + notes.configure(state='disabled') + ttk.Label(win, textvariable=status, wraplength=620).pack() + events = queue.Queue() + cancel = threading.Event() + candidate = None + busy = False + generation = 0 + closing = False + + def work(kind, operation): + nonlocal busy + busy = True + update.configure(state='disabled') + check.configure(state='disabled') + choice.configure(state='disabled') + token = generation + def run(): + try: + events.put((token, kind, operation())) + except Exception as e: + events.put((token, 'error', str(e))) + threading.Thread(target=run, daemon=True).start() + + def check_now(): + nonlocal candidate, generation + generation += 1 + candidate = None + cancel.clear() + settings['channel'] = channel.get() + settings[channel.get()] = time.time() + try: + atomic(settings_path, settings) + except OSError: + pass + status.set('Checking GitHub…') + selected = channel.get() + work('checked', lambda: discover(read_json(root / 'release.json', {}), selected, cancel)) + + def do_update(): + nonlocal automatic + automatic = False + if candidate is None or busy: + return + cancel.clear() + def prepare(): + if (tx / 'journal.json').exists(): + raise ValueError('An interrupted update needs recovery. Restart the game first.') + tx.mkdir(exist_ok=True) + stage(candidate, tx, cancel, lambda text: events.put((generation, 'progress', text))) + status.set('Downloading and verifying. Cancel keeps the game running.') + work('staged', prepare) + + def close(): + nonlocal closing + if busy: + cancel.set() + closing = True + status.set('Cancelling…') + else: + win.destroy() + + check = ttk.Button(win, text='Check now', command=check_now) + check.pack(side='left', padx=12, pady=12) + update = ttk.Button(win, text='Update', command=do_update, state='disabled') + update.pack(side='left', padx=12) + back = ttk.Button(win, text='Cancel', command=close) + back.pack(side='right', padx=12) + win.protocol('WM_DELETE_WINDOW', close) + choice.bind('<>', lambda _: check_now()) + + def poll(): + nonlocal candidate, busy, automatic + try: + while True: + token, kind, value = events.get_nowait() + if token != generation: + continue + if kind == 'progress': + status.set(value) + continue + busy = False + if closing: + win.destroy() + return + check.configure(state='normal') + choice.configure(state='readonly') + if kind == 'error': + back.configure(state='normal') + win.protocol('WM_DELETE_WINDOW', close) + status.set('Update unavailable: ' + value[:250] + '. You can keep playing and try again later.') + if automatic: + win.destroy() + return + elif kind == 'checked': + candidate = value + if candidate: + automatic = False + win.deiconify() + notes.configure(state='normal') + notes.delete('1.0', 'end') + notes.insert('end', candidate[4]['tag'] + '\n\n' + (candidate[2].get('body') or 'No release notes.')[:50000]) + notes.configure(state='disabled') + update.configure(state='normal') + status.set('New release available. Cancel keeps your current version.') + elif automatic: + win.destroy() + return + else: + status.set('No newer compatible release in this channel.') + elif kind == 'staged': + if cancel.is_set(): + continue + status.set('Closing game and installing… Please keep this window open.') + back.configure(state='disabled') + win.protocol('WM_DELETE_WINDOW', lambda: None) + def finish(): + Path(request['signal']).write_text('ready', encoding='ascii') + # Wait for game and supervisor to release the PE. Replacement retries + # are the lock authority; no PID reuse or process termination. + time.sleep(3) + try: + install(root, tx) + except Exception: + if not (tx / 'journal.json').exists(): + subprocess.Popen([str(root / 'skate3rust.exe'), *request['args']], cwd=request['cwd']) + raise + lock.close() + subprocess.Popen([str(root / 'skate3rust.exe'), *request['args']], cwd=request['cwd']) + work('installed', finish) + elif kind == 'installed': + win.destroy() + return + except queue.Empty: + pass + win.after(100, poll) + + if not automatic or time.time() - settings.get(channel.get(), 0) >= 21600: + check_now() + else: + win.destroy() + return + win.after(100, poll) + win.mainloop() + + +if __name__ == '__main__': + main()