Files
skate-3-rust-engine/tools/updater.py
T
Ethanw05andCursor 9eaabf198e Fix updater branch status messages and Skyline remote VFX init.
Branch channel now reports already-current and missing-release cases clearly instead of a generic 404 error, and remote smoke/skids initialize without requiring local F10 spawn.

Co-authored-by: Cursor <[email protected]>
2026-09-12 01:30:05 -06:00

713 lines
30 KiB
Python

"""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 threading
import time
import urllib.request
import urllib.error
import zipfile
import update_install as installer
from update_processes import close_programs
REPO = 'SK8-ENGINE/skate-3-rust-engine'
API = f'https://api.github.com/repos/{REPO}/releases'
ACTIONS_API = f'https://api.github.com/repos/{REPO}/actions'
BRANCHES_API = f'https://api.github.com/repos/{REPO}/branches'
WORKFLOW_FILE = 'release.yml'
ARTIFACT_NAME = 'skate3rust-windows-x64'
PACKAGE = 'skate3rust-windows-x64.zip'
FILES = ('skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe',
'steam-relay/skate-steam-relay.exe', 'steam-relay/steam_api64.dll', 'release.json')
PREFIX = 'skate3rust-windows-x64/'
DEFAULT_BRANCH_CHOICES = ('skyline-driving-update', 'main')
class DownloadRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if not newurl.startswith('https://'):
raise ValueError('Insecure redirect')
redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
# Private asset requests redirect to signed CDN URLs. Never forward a PAT.
if redirected is not None:
redirected.remove_header('Authorization')
return redirected
def asset_url(asset):
return asset['url'] if os.environ.get('SKATE_UPDATE_GITHUB_TOKEN') else asset['browser_download_url']
def atomic(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix('.tmp')
with temp.open('w', encoding='utf-8') as output:
json.dump(value, output)
output.flush()
os.fsync(output.fileno())
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 not isinstance(m, dict):
raise ValueError('Invalid release metadata')
build = m.get('build')
tag = m.get('tag')
if (m.get('schema') != 1 or m.get('target') != 'windows-x64'
or m.get('repository') != REPO or not isinstance(build, int)
or (build <= 0 and tag != 'development') or not isinstance(tag, str)
or not isinstance(m.get('revision'), str)
or not re.fullmatch(r'[0-9a-f]{40}', m['revision'])):
raise ValueError('Incompatible release metadata')
return build
def program_metadata(m):
identity(m)
installer.manifest_files(m)
def safe_local_paths(root):
# Never follow a user junction/symlink into asset stores or another install.
paths = [root / name for name in FILES]
tx = root / '.update-transaction'
paths += [tx / side / name for side in ('old', 'new') for name in FILES]
for path in paths:
while path != root:
if path.is_symlink() or path.is_junction():
raise ValueError('Updater program directories cannot contain links or junctions')
path = path.parent
def allowed_fetch_url(url):
if url.startswith(API) or url.startswith(f'https://github.com/{REPO}/releases/download/'):
return True
if url.startswith(ACTIONS_API) or url.startswith(BRANCHES_API):
return True
return False
def github_request_headers(url):
headers = {'User-Agent': 'Skate3RustEngine-Updater/1', 'X-GitHub-Api-Version': '2022-11-28'}
if url.startswith(API):
headers['Accept'] = 'application/octet-stream' if '/assets/' in url else 'application/vnd.github+json'
elif url.startswith('https://api.github.com/'):
headers['Accept'] = 'application/vnd.github+json'
token = os.environ.get('SKATE_UPDATE_GITHUB_TOKEN')
if token and url.startswith('https://api.github.com/'):
headers['Authorization'] = 'Bearer ' + token
return headers
def fetch(url, cancel, limit, progress=lambda value: None):
if cancel.is_set():
raise InterruptedError('Cancelled')
# URLs originate only from the fixed repository API, never notes/manifest.
if not allowed_fetch_url(url):
raise ValueError('Unexpected download URL')
req = urllib.request.Request(url, headers=github_request_headers(url))
deadline = time.monotonic() + 300
result = bytearray()
try:
response = urllib.request.build_opener(DownloadRedirect()).open(req, timeout=15)
except urllib.error.HTTPError as error:
if error.code == 401:
raise ValueError(
'GitHub sign-in is required for CI artifact downloads. '
'Use a published branch release, or set SKATE_UPDATE_GITHUB_TOKEN.'
) from error
if error.code in (403, 429):
raise ValueError('GitHub rate limit or access restriction; try again later') from error
if error.code == 404:
if '/releases/tags/' in url:
raise
raise ValueError('Releases are not publicly available, or private-release access is missing') from error
raise
with 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 package_name(meta):
# Rolling asset names are derived from a validated integer, never a path
# supplied by release notes or an arbitrary manifest field.
return f'skate3rust-windows-x64-build-{identity(meta)}.zip' if meta['tag'] == 'experimental' else PACKAGE
def release_candidates(assets, rolling):
if not rolling:
return [('release.json', PACKAGE)]
builds = sorted((int(m[1]) for name in assets
if (m := re.fullmatch(r'release-([1-9][0-9]*)\.json', name))), reverse=True)
return [(f'release-{n}.json', f'skate3rust-windows-x64-build-{n}.zip') for n in builds]
def merge_branch_names(names, preferred=''):
values = [name for name in (names or DEFAULT_BRANCH_CHOICES) if isinstance(name, str) and name]
preferred = (preferred or '').strip()
if preferred and preferred not in values:
values.insert(0, preferred)
for fallback in DEFAULT_BRANCH_CHOICES:
if fallback not in values:
values.append(fallback)
return values
def list_branches(cancel, limit=200):
branches = []
for page in range(1, 6):
batch = json.loads(fetch(f'{BRANCHES_API}?per_page=100&page={page}', cancel, 4 * 1024 * 1024))
if not batch:
break
branches.extend(item['name'] for item in batch if isinstance(item, dict) and item.get('name'))
if len(batch) < 100 or len(branches) >= limit:
break
return branches[:limit]
def branch_choices(cancel, preferred=''):
try:
return merge_branch_names(list_branches(cancel), preferred)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError, json.JSONDecodeError, OSError):
return merge_branch_names([], preferred)
def branch_workflow_runs(branch, cancel):
from urllib.parse import quote
query = (
f'{ACTIONS_API}/workflows/{quote(WORKFLOW_FILE, safe="")}/runs'
f'?branch={quote(branch, safe="")}&status=success&per_page=20'
)
payload = json.loads(fetch(query, cancel, 8 * 1024 * 1024))
return payload.get('workflow_runs', [])
def download_branch_artifact(run_id, cancel, progress=lambda value: None):
artifacts = json.loads(fetch(f'{ACTIONS_API}/runs/{run_id}/artifacts', cancel, 4 * 1024 * 1024))
artifact = next((item for item in artifacts.get('artifacts', [])
if item.get('name') == ARTIFACT_NAME and not item.get('expired')), None)
if artifact is None:
raise ValueError(f'Workflow run {run_id} has no {ARTIFACT_NAME} artifact')
return fetch(artifact['archive_download_url'], cancel, 1024 * 1024 * 1024, progress)
def package_from_artifact(artifact_zip, package):
import io
with zipfile.ZipFile(io.BytesIO(artifact_zip)) as archive:
checksum_name = package + '.sha256'
names = {info.filename for info in archive.infolist()}
if package not in names or checksum_name not in names:
raise ValueError('Branch artifact is missing the release package')
payload = archive.read(package)
checksum = archive.read(checksum_name).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 branch artifact checksum')
return payload, checksum
def branch_release_meta(branch, cancel):
from urllib.parse import quote
branch = (branch or '').strip()
try:
release = json.loads(fetch(f'{API}/tags/{quote(branch, safe="")}', cancel, 8 * 1024 * 1024))
except urllib.error.HTTPError as error:
if error.code == 404:
return None
raise
assets = {a['name']: a for a in release.get('assets', []) if a.get('state') == 'uploaded'}
required = {PACKAGE, PACKAGE + '.sha256', 'release.json'}
if not required <= assets.keys():
return None
try:
meta = json.loads(fetch(asset_url(assets['release.json']), cancel, 65536))
identity(meta)
program_metadata(meta)
except (ValueError, KeyError, urllib.error.HTTPError):
return None
return release, assets, meta
def discover_branch_release(current, branch, cancel, repair=False):
loaded = branch_release_meta(branch, cancel)
if loaded is None:
return None
release, assets, meta = loaded
if meta['revision'] == current.get('revision') and not repair:
return None
notes = dict(
tag_name=branch,
body=(f'Branch `{branch}`\n'
f'Build {meta["build"]} · revision {meta.get("revision", "")[:12]}'),
)
return (meta['build'], release['id'], notes, assets, meta)
def branch_release_status(current, branch, cancel):
loaded = branch_release_meta(branch, cancel)
if loaded is None:
branch = (branch or '').strip()
if branch == 'main':
return ('Branch `main` has no published installer. Use `skyline-driving-update`.', None)
return (f'No published release found for branch `{branch}`.', None)
_, _, meta = loaded
revision = meta.get('revision', '')
if revision and revision == current.get('revision'):
return (f'Branch `{branch}` build {meta["build"]} is already installed ({revision[:12]}).', revision)
return (None, revision)
def discover_branch(current, branch, cancel, repair=False):
branch = (branch or '').strip()
if not branch or not re.fullmatch(r'[A-Za-z0-9._/-]{1,120}', branch):
raise ValueError('Enter a valid GitHub branch name')
candidate = discover_branch_release(current, branch, cancel, repair=repair)
if candidate is not None:
return candidate
return None
def discover(current, channel, cancel, repair=False):
current_build = identity(current)
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 time.monotonic() > deadline:
raise TimeoutError('Release check timed out')
if not eligible(release, channel):
continue
assets = {a['name']: a for a in release.get('assets', []) if a.get('state') == 'uploaded'}
for manifest_name, package in release_candidates(assets, release.get('tag_name') == 'experimental'):
if not {package, package + '.sha256', manifest_name} <= assets.keys():
continue
try:
meta = json.loads(fetch(asset_url(assets[manifest_name]), cancel, 65536))
build = identity(meta)
program_metadata(meta)
if (meta['tag'] != release['tag_name'] or build < current_build or (build == current_build and not repair)
or package_name(meta) != package):
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 release_package_name(assets, meta):
package = package_name(meta)
if assets and package + '.sha256' not in assets and PACKAGE + '.sha256' in assets:
package = PACKAGE
return package
def stage(candidate, directory, cancel, progress):
_, _, release, assets, meta = candidate[:5]
artifact_zip = candidate[5] if len(candidate) > 5 else None
package = release_package_name(assets, meta)
if artifact_zip is not None:
archive, checksum = package_from_artifact(artifact_zip, package)
else:
checksum = fetch(asset_url(assets[package + '.sha256']), 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(asset_url(assets[package]), cancel, 1024 * 1024 * 1024, progress)
digest = hashlib.sha256(archive).hexdigest()
if digest != checksum[0].lower():
raise ValueError('Package checksum mismatch')
if assets is not None:
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 [*installer.manifest_files(meta), "release.json"]:
installer.safe_paths(directory.parent, [name])
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):
installer.rollback(root, tx)
def install(root, tx):
installer.install(root, tx)
def main(request=None):
parser = argparse.ArgumentParser()
parser.add_argument('--request', type=Path, required=True)
args = parser.parse_args()
request = request or read_json(args.request)
args.request.unlink(missing_ok=True)
root = Path(request['root']).resolve()
safe_local_paths(root)
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', 'Branch') else 'Stable')
branch = tk.StringVar(value=settings.get('branch') or 'skyline-driving-update')
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', 'Branch'), state='readonly')
choice.pack()
branch_row = ttk.Frame(win)
ttk.Label(branch_row, text='Branch:').pack(side='left', padx=(0, 6))
branch_choice = ttk.Combobox(branch_row, textvariable=branch, width=42)
branch_choice.pack(side='left', fill='x', expand=True)
branch_row.pack(fill='x', padx=12, pady=(0, 4))
channel_help = ttk.Label(
win,
text='Stable: published releases. Latest: rolling Experimental. Branch: published build for a GitHub branch (e.g. skyline-driving-update).',
wraplength=620,
)
channel_help.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
repair_files = []
repair_after_update = False
def branch_ui_enabled():
return channel.get() == 'Branch'
def apply_branch_choices(names):
branch_choice.configure(values=merge_branch_names(names, branch.get().strip()))
def refresh_branch_ui():
enabled = branch_ui_enabled()
branch_choice.configure(state='normal' if enabled else 'disabled')
if enabled:
apply_branch_choices(branch_choice.cget('values') or DEFAULT_BRANCH_CHOICES)
def load_branch_list():
token = generation
def run():
try:
events.put((token, 'branches', branch_choices(cancel, branch.get().strip())))
except Exception:
events.put((token, 'branches', merge_branch_names([], branch.get().strip())))
threading.Thread(target=run, daemon=True).start()
def work(kind, operation):
nonlocal busy
busy = True
update.configure(state='disabled')
check.configure(state='disabled')
choice.configure(state='disabled')
branch_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
notes.configure(state='normal')
notes.delete('1.0', 'end')
notes.configure(state='disabled')
cancel.clear()
settings['channel'] = channel.get()
settings['branch'] = branch.get().strip()
settings[channel.get()] = time.time()
try:
atomic(settings_path, settings)
except OSError:
pass
status.set('Checking GitHub…')
selected = channel.get()
def perform_check():
nonlocal repair_files, repair_after_update
current = read_json(root / 'release.json', {})
program_metadata(current)
with (root / 'skate3rust.exe').open('rb') as executable:
digest = hashlib.file_digest(executable, 'sha256').hexdigest()
if (current['revision'] != request['revision'] or str(current['build']) != request['build']
or digest != current['files']['skate3rust.exe']):
raise ValueError('This executable does not match its release metadata')
repair_files = installer.mismatches(root, current)
previous = read_json(tx/'old/release.json', {})
repair_after_update = (bool(repair_files) and isinstance(previous.get('build'), int)
and previous['build'] < current['build'])
if selected == 'Branch':
selected_branch = branch.get().strip() or DEFAULT_BRANCH_CHOICES[0]
names = branch_choices(cancel, selected_branch)
candidate = discover_branch(current, selected_branch, cancel, repair=bool(repair_files))
branch_status = branch_release_status(current, selected_branch, cancel) if candidate is None else None
return names, candidate, branch_status
return None, discover(current, selected, cancel, repair=bool(repair_files)), None
work('checked', perform_check)
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)
def channel_changed(_=None):
refresh_branch_ui()
if branch_ui_enabled():
load_branch_list()
check_now()
choice.bind('<<ComboboxSelected>>', channel_changed)
refresh_branch_ui()
if branch_ui_enabled():
load_branch_list()
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
if kind == 'branches':
apply_branch_choices(value)
continue
busy = False
if closing:
win.destroy()
return
check.configure(state='normal')
choice.configure(state='readonly')
refresh_branch_ui()
if kind == 'error':
atomic(root / '.update-error.json', {'time': time.time(), 'error': value})
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':
branch_names, candidate, branch_status = value
if branch_names:
apply_branch_choices(branch_names)
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', text='Repair' if repair_files else 'Update')
if channel.get() == 'Branch':
detail = f'Branch build available for `{branch.get().strip()}`.'
elif repair_files:
detail = 'Repair needed: ' + ', '.join(repair_files) + '. Download will repair the installation.'
else:
detail = 'New release available. Cancel keeps your current version.'
status.set(detail)
if repair_after_update and candidate[0] == identity(read_json(root/'release.json', {})):
# Finish the update the user already accepted in the old helper.
do_update()
elif automatic:
win.destroy()
return
else:
if channel.get() == 'Branch' and branch_status and branch_status[0]:
status.set(branch_status[0])
elif channel.get() == 'Branch':
status.set('No published branch release found, or you already have that revision.')
elif repair_files:
status.set('Repair needed, but this build is not available in the selected channel. Select its channel or a newer release.')
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')
# Allow normal shutdown, then release stale processes owned by this copy.
time.sleep(3)
close_programs(root, set(installer.manifest_files(read_json(root/'release.json', {})))
| set(installer.manifest_files(read_json(tx/'new/release.json', {}))))
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)
needs_repair = bool(installer.mismatches(root, read_json(root/'release.json', {})))
if needs_repair:
automatic = False
win.deiconify()
if not automatic or needs_repair or time.time() - settings.get(channel.get(), 0) >= 21600:
check_now()
else:
win.destroy()
return
win.after(100, poll)
win.mainloop()
if __name__ == '__main__':
# Suppress PyInstaller's generic exception/crash dialog for routine updater
# failures, including unwritable portable directories before UI creation.
import sys
request = read_json(Path(sys.argv[2]), {}) if len(sys.argv) == 3 and sys.argv[1] == '--request' else {}
try:
main(request)
except Exception as error:
if request and not request.get('automatic', True):
try:
import tkinter.messagebox
tkinter.messagebox.showerror('Updates unavailable', str(error)[:300] + '\nYou can keep playing. Try again later.')
except Exception:
pass