mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Add opt-in release updater and pause-menu entry with staged replacement
This commit is contained in:
1 parent
ba929487c1
commit
069f153038
8 files changed
+605
-3
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user