Add opt-in release updater and pause-menu entry with staged replacement

This commit is contained in:
chazmm committed 2026-09-08 20:10:13 +01:00
1 parent ba929487c1
commit 069f153038
8 files changed
+605 -3

No files matched your search

+4 -1
View File
@@ -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
+16
View File
@@ -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"
+1
View File
@@ -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);
+5 -2
View File
@@ -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<AppExit>,
mut net: ResMut<crate::multiplayer::Multiplayer>,
mut typing: MessageReader<bevy::input::keyboard::KeyboardInput>,
mut updater: ResMut<crate::updater::Updater>,
) {
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(),
}
};
+6
View File
@@ -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() {
+87
View File
@@ -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<Child>,
signal: Option<PathBuf>,
temporary: Option<PathBuf>,
}
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::<Vec<_>>()
});
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<bool, String> {
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::<Updater>()
.add_systems(Startup, |mut updater: ResMut<Updater>| { updater.open(true); })
.add_systems(Update, poll);
}
}
fn poll(mut updater: ResMut<Updater>, mut exit: MessageWriter<AppExit>) {
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); }
}
}
+107
View File
@@ -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()
+379
View File
@@ -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('<<ComboboxSelected>>', 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()