diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f91512f..d6cdf391 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: - name: Check updater and publisher without starting game run: python -m unittest discover -s tools -p "test_*release*.py" -v - name: Check updater protocol - run: python -m unittest discover -s tools -p test_updater.py -v + run: python -m unittest discover -s tools -p "test_update*.py" -v - name: Check isolated asset refresh and failure recovery run: python -m unittest tools.asset_pipeline.test_versions -v - name: Build Windows package diff --git a/docs/updates.md b/docs/updates.md index 4c4c8d01..c85fb7d5 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -4,7 +4,7 @@ Pause → **Updates** opens a native desktop update window. Select **Stable** (default) or **Latest** (includes experimental prereleases), then **Check now**. Release notes appear as plain, scrollable text. **Update** authorizes downloading, verification, a clean game exit, replacement and restart. **Cancel** closes the -offer, or cancels a download before shutdown. No installation occurs on a check. +offer, or cancels a download before shutdown. Ordinary checks do not install updates. An incomplete update already accepted in an older updater is completed automatically. Save any gameplay you want to retain before choosing Update. Startup checks run in a separate process, at most once per six hours per channel. @@ -15,9 +15,12 @@ Switching to Stable never downgrades an already installed experimental build; it waits for a newer stable build. Cancelled offers are checked again on a later eligible startup, or through the menu. -The helper replaces only `skate3rust.exe`, `support/skate3setup.exe`, -`support/skate3update.exe`, and `release.json`. Assets, maps, settings, mods, -character libraries and setup's installation marker are never replaced. The same +The release manifest owns every shipped program file: executables, helper tools, +DLLs, documentation and licenses. Updates add new components, replace changed +files and remove components owned by the previous release that are no longer +shipped. Assets, maps, saves, settings, user mods and character libraries remain +outside this manifest. Bundled mod archives are user-editable defaults and are +not overwritten by program updates. The same working directory and arguments are used for restart; existing asset selection continues to work. Each package owns its `data` installation. On the next normal launch, changed extractor fingerprints trigger an update of affected asset groups; @@ -29,7 +32,7 @@ silently adopt a downloaded build. `release.json` accompanies the ZIP and is also inside it. Schema 1 identifies the repository, `windows-x64` target, tag, full source revision, monotonically increasing -release-workflow run number (`build`), and SHA-256 of all three program files. +release-workflow run number (`build`), and SHA-256 of every managed program file. The executable embeds its revision and build number; its local metadata and hash must match before checking. Local builds use build 0 and are not update sources. @@ -38,7 +41,7 @@ the workspace's currently fixed Cargo version, determine upgrade ordering. Never reset/replace the release workflow's run-number sequence without a protocol migration. Rerunning a build preserves its identity and does not offer an upgrade to an installation of that build. Publish a new release/run to ship another build. -No downgrades are offered across channels. Equal build IDs are not upgrades. +No downgrades are offered across channels. Equal build IDs are not upgrades, but the same package can repair missing or mismatched components. The updater scans pages of 100 published releases, rejects drafts, filters the channel, and requires the ZIP, checksum and compatible metadata. It selects the @@ -132,3 +135,27 @@ Player manual checklist (use a disposable copy of a packaged installation): GitHub references: [release listing, pagination and latest-release semantics](https://docs.github.com/en/rest/releases/releases), [asset digests](https://docs.github.com/en/rest/releases/assets). + +## Integrity and recovery + +At startup the helper checks the installed files against `release.json` in its +own process. Damaged or incomplete installs bypass the normal six-hour check +interval and offer Repair, even when the installed build is already the latest. +When an older updater changed the EXE and manifest but skipped other files, the +new helper finishes that already-approved update using the same verified release. +This transition can download the package a second time; it needs no manual ZIP +extraction. Future updates use one complete transaction. + +Before replacement, the helper backs up every affected existing file and writes +a recovery journal recording which files did not previously exist. It gives the +game time to exit, then closes leftover executables belonging to this installation. +It never closes another copy installed elsewhere. Version metadata is replaced +last, after all installed program checksums pass. A failed replacement restores +removed/replaced files and removes newly added files. Interrupted transactions are +recovered on the next launch. Failed recovery retains its journal and backups. +Updater errors are recorded in `.update-error.json` beside the executable. + +New program files require no changes to the updater file list: packaging discovers +them from the release staging directory. Extracted retail content remains managed +by the separate versioned asset pipelines; changed extractors refresh only their +affected asset groups after the program update. diff --git a/scripts/Build-Release.ps1 b/scripts/Build-Release.ps1 index 0c4b4b5b..146f3e27 100644 --- a/scripts/Build-Release.ps1 +++ b/scripts/Build-Release.ps1 @@ -81,18 +81,6 @@ try { $assetPipelinesJson = & $packagePython "$sourceStage/tools/asset_pipeline/versions.py" --tools "$sourceStage/tools" if ($LASTEXITCODE -ne 0) { throw 'Could not identify asset extractors' } $assetPipelines = $assetPipelinesJson | ConvertFrom-Json - $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() - } - foreach ($name in @('steam-relay/skate-steam-relay.exe', 'steam-relay/steam_api64.dll')) { - $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; asset_pipelines = $assetPipelines - } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath "$stage/release.json" -Encoding utf8 - Copy-Item -LiteralPath "$stage/release.json" -Destination (Join-Path $ProjectRoot 'target/release.json') Copy-Item -LiteralPath README.md,docs/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 +98,19 @@ try { foreach ($license in Get-ChildItem -LiteralPath "$pythonBase/tcl" -Filter license.terms -Recurse -ErrorAction SilentlyContinue) { Copy-Item -LiteralPath $license.FullName -Destination "$stage/licenses/$($license.Directory.Name).txt" } + # Own every shipped program component, including future tools and libraries. + # Mods are user-editable content and remain outside program replacement. + $files = @{} + foreach ($file in Get-ChildItem -LiteralPath $stage -File -Recurse) { + $name = [IO.Path]::GetRelativePath($stage, $file.FullName).Replace('\', '/') + if ($name -eq 'release.json' -or $name.StartsWith('mods/')) { continue } + $files[$name] = (Get-FileHash -LiteralPath $file.FullName -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; asset_pipelines = $assetPipelines + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath "$stage/release.json" -Encoding utf8 + Copy-Item -LiteralPath "$stage/release.json" -Destination (Join-Path $ProjectRoot 'target/release.json') $zip = Join-Path $ProjectRoot 'target/skate3rust-windows-x64.zip' Compress-Archive -LiteralPath $stage -DestinationPath $zip -Force (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLower() + ' skate3rust-windows-x64.zip' | diff --git a/tools/test_update_install.py b/tools/test_update_install.py new file mode 100644 index 00000000..bba2d846 --- /dev/null +++ b/tools/test_update_install.py @@ -0,0 +1,89 @@ +"""Synthetic update transactions; no game, Steam or user installation.""" +import hashlib,json,tempfile,unittest +from pathlib import Path +from unittest.mock import patch +import update_install as i + +BASE={n:b'old' for n in i.REQUIRED} +def layout(root,files,build=1): + for name,data in files.items(): + p=root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data) + meta={'files':{n:hashlib.sha256(b).hexdigest() for n,b in files.items()},'build':build} + i.atomic(root/'release.json',meta) + return meta + +class Transactions(unittest.TestCase): + def test_add_replace_remove_and_preserve_unowned_content(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);tx=root/'.update-transaction' + layout(root,{**BASE,'obsolete/tool.dll':b'old'}) + new={n:b'new' for n in BASE};new['new-tool/bin/helper.exe']=b'helper' + meta=layout(tx/'new',new,2) + for name in ['data/maps/owned.skate','mods/my-mod.zip','personal.txt']: + p=root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(b'user') + i.install(root,tx) + self.assertEqual(i.mismatches(root,meta),[]) + self.assertFalse((root/'obsolete/tool.dll').exists()) + self.assertEqual(i.read(root/'release.json')['build'],2) + for name in ['data/maps/owned.skate','mods/my-mod.zip','personal.txt']: + self.assertEqual((root/name).read_bytes(),b'user') + + def test_failure_restores_deleted_files_and_removes_new_files(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);tx=root/'.update-transaction' + old=layout(root,{**BASE,'a-obsolete.dll':b'old'}) + layout(tx/'new',{**{n:b'new' for n in BASE},'b-new.dll':b'new'},2) + replace=i.os.replace + def fail(src,dest): + if Path(src)==tx/'new/support/skate3setup.exe':raise OSError('locked') + return replace(src,dest) + with patch.object(i.os,'replace',fail),patch.object(i,'retry',lambda op:op()),self.assertRaises(OSError): + i.install(root,tx) + self.assertEqual(i.mismatches(root,old),[]) + self.assertFalse((root/'b-new.dll').exists()) + self.assertEqual(i.read(root/'release.json'),old) + self.assertFalse((tx/'journal.json').exists()) + + def test_missing_component_is_repaired(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);tx=root/'.update-transaction' + meta=layout(root,{**BASE,'steam-relay/helper.exe':b'right'}) + (root/'steam-relay/helper.exe').unlink() + self.assertEqual(i.mismatches(root,meta),['steam-relay/helper.exe']) + layout(tx/'new',{**BASE,'steam-relay/helper.exe':b'right'}) + i.install(root,tx) + self.assertEqual(i.mismatches(root,meta),[]) + + def test_recover_interrupted_transaction(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);tx=root/'.update-transaction' + old=layout(root,BASE) + for name in [*BASE,'release.json']: + p=tx/'old'/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes((root/name).read_bytes()) + (root/'added.dll').write_bytes(b'new');(root/'skate3rust.exe').write_bytes(b'new') + i.atomic(tx/'journal.json',{'protocol':2,'names':[*BASE,'added.dll','release.json'],'present':[*BASE,'release.json']}) + i.rollback(root,tx) + self.assertEqual(i.mismatches(root,old),[]);self.assertFalse((root/'added.dll').exists()) + + def test_legacy_recovery(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);tx=root/'.update-transaction' + layout(root,BASE);layout(tx/'old',BASE) + (root/'skate3rust.exe').write_bytes(b'partial') + i.atomic(tx/'journal.json',{'protocol':1});i.rollback(root,tx) + self.assertEqual((root/'skate3rust.exe').read_bytes(),b'old') + + def test_corrupt_staging_does_not_modify_install(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);tx=root/'.update-transaction';old=layout(root,BASE) + layout(tx/'new',BASE);(tx/'new/skate3rust.exe').write_bytes(b'bad') + with self.assertRaises(ValueError):i.install(root,tx) + self.assertEqual(i.mismatches(root,old),[]) + self.assertFalse((tx/'journal.json').exists()) + + def test_manifest_rejects_user_data_and_windows_path_aliases(self): + for name in ['../outside','C:/oops','data/map.skate','mods/user.zip','a/../b','support/NUL.dll','a./b','AUX','/absolute','a\\b','release.json']: + with self.subTest(name=name),self.assertRaises(ValueError): + i.manifest_files({'files':{**{n:'a'*64 for n in BASE},name:'b'*64}}) + +if __name__=='__main__':unittest.main() diff --git a/tools/test_updater.py b/tools/test_updater.py index ac22dcdd..82c1a6b3 100644 --- a/tools/test_updater.py +++ b/tools/test_updater.py @@ -68,12 +68,14 @@ class UpdaterTests(unittest.TestCase): self.assertIsNone(u.discover(current, 'Stable', threading.Event())) self.assertIsNone(u.discover({**current, 'build': 3}, 'Latest', threading.Event())) - def package(self, extra=None): + def package(self, extra=None, program=None): meta = metadata() meta['files'] = {n: hashlib.sha256(b'new').hexdigest() for n in u.FILES[:-1]} + if program: + meta['files'][program] = hashlib.sha256(b'new').hexdigest() data = io.BytesIO() with zipfile.ZipFile(data, 'w') as z: - for name in u.FILES: + for name in [*meta['files'], 'release.json']: z.writestr(u.PREFIX+name, json.dumps(meta) if name == 'release.json' else b'new') if extra: z.writestr(extra, b'evil') @@ -84,6 +86,15 @@ class UpdaterTests(unittest.TestCase): return archive if url == u.PACKAGE else (hashlib.sha256(archive).hexdigest()+' '+u.PACKAGE).encode() return candidate, fetch + def test_new_program_component_is_staged_without_updater_changes(self): + with tempfile.TemporaryDirectory() as temp: + root=Path(temp) + name='future-tools/importer/new.dll' + candidate, fetch=self.package(program=name) + with patch.object(u,'fetch',fetch): + u.stage(candidate,root,threading.Event(),lambda _:None) + self.assertEqual((root/'new'/name).read_bytes(),b'new') + def test_safe_staging_and_traversal(self): with tempfile.TemporaryDirectory() as temp: root = Path(temp) @@ -96,6 +107,22 @@ class UpdaterTests(unittest.TestCase): with patch.object(u, 'fetch', fetch), self.assertRaises(ValueError): u.stage(candidate, root, threading.Event(), lambda _: None) + def test_same_build_repair_is_available_without_downgrade(self): + candidate, fetch = self.package() + meta = candidate[4] + release = dict(id=1, tag_name=meta['tag'], draft=False, + published_at='date', prerelease=False, + assets=[dict(name=n, state='uploaded', browser_download_url=n) + for n in (u.PACKAGE, u.PACKAGE+'.sha256', 'release.json')]) + def api(url, *args): + if url.startswith(u.API): return json.dumps([release]).encode() + if url=='release.json': return json.dumps(meta).encode() + return fetch(url,*args) + with patch.object(u,'fetch',api): + self.assertIsNone(u.discover(meta,'Stable',threading.Event())) + self.assertEqual(u.discover(meta,'Stable',threading.Event(),repair=True)[0],meta['build']) + self.assertIsNone(u.discover({**meta,'build':meta['build']+1},'Stable',threading.Event(),repair=True)) + def test_checksum_failure_and_cancel(self): candidate, fetch = self.package() def corrupt(url, *args): @@ -107,43 +134,6 @@ class UpdaterTests(unittest.TestCase): 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()) - - def test_success_and_interrupted_recovery(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') - u.install(root, tx) - self.assertTrue(all((root/n).read_bytes() == b'new' for n in u.FILES)) - # Simulate interrupted replacement with durable backups/journal. - u.atomic(tx/'journal.json', {'protocol': 1}) - u.rollback(root, tx) - self.assertTrue(all((root/n).read_bytes() == b'old' for n in u.FILES)) - self.assertFalse((tx/'journal.json').exists()) - def test_offline_and_rate_limit(self): with patch.object(u, 'fetch', side_effect=OSError('offline')), self.assertRaises(OSError): u.discover(metadata(1), 'Stable', threading.Event()) diff --git a/tools/update_install.py b/tools/update_install.py new file mode 100644 index 00000000..23211195 --- /dev/null +++ b/tools/update_install.py @@ -0,0 +1,130 @@ +"""Manifest-driven program updates. User data is never release-owned.""" +import hashlib +import json +import os +import re +import shutil +import time + +PROTECTED = {'data', 'assets', 'maps', 'logs', 'saves', 'settings', 'mods', 'custom-models', 'screenshots'} +REQUIRED = {'skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe'} + +def safe_name(name): + if not isinstance(name, str) or not name or '\\' in name or ':' in name or name.startswith('/'): + raise ValueError('Unsafe program path') + parts = name.split('/') + if any(not p or p in ('.', '..') or p[-1] in ' .' or any(ord(c)<32 for c in p) + or p.split('.')[0].upper() in {'CON','PRN','AUX','NUL',*(f'COM{i}' for i in range(10)),*(f'LPT{i}' for i in range(10))} for p in parts): + raise ValueError('Unsafe program path') + if parts[0].lower() in PROTECTED or parts[0].startswith('.'): + raise ValueError('Release cannot manage user data') + return name + +def manifest_files(meta): + files = meta.get('files') + if not isinstance(files, dict) or not REQUIRED <= files.keys() or len(files)>20000: + raise ValueError('Missing program hashes') + seen = set() + for name, digest in files.items(): + safe_name(name) + if name.lower()=='release.json' or name.lower() in seen or not isinstance(digest,str) or not re.fullmatch('[0-9a-f]{64}',digest): + raise ValueError('Invalid program manifest') + seen.add(name.lower()) + for name in seen: + if any('/'.join(name.split('/')[:i]) in seen for i in range(1,len(name.split('/')))): + raise ValueError('Program file/directory conflict') + return files + +def safe_paths(root, names): + for name in names: + safe_name(name) + for path in (root/name, root/'.update-transaction/new'/name, root/'.update-transaction/old'/name): + while path != root: + if path.is_symlink() or path.is_junction(): + raise ValueError('Updater paths cannot contain links or junctions') + path=path.parent + +def mismatches(root, meta): + files=manifest_files(meta) + safe_paths(root, files) + bad=[] + for name,digest in files.items(): + try: + with (root/name).open('rb') as f: actual=hashlib.file_digest(f,'sha256').hexdigest() + except OSError: actual=None + if actual!=digest: bad.append(name) + return bad + +def read(path, default=None): + try: return json.loads(path.read_text(encoding='utf-8-sig')) + except (OSError,ValueError): return default + +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 f: + json.dump(value,f);f.flush();os.fsync(f.fileno()) + os.replace(temp,path) + +def retry(op): + deadline=time.monotonic()+45 + while True: + try:return op() + except OSError: + if time.monotonic()>=deadline:raise + time.sleep(.25) + +def rollback(root,tx): + journal=read(tx/'journal.json') + if journal is None:return + if journal.get('protocol')==1: + # Earlier updaters only backed up this fixed list. + names=['skate3rust.exe','support/skate3setup.exe','support/skate3update.exe', + 'steam-relay/skate-steam-relay.exe','steam-relay/steam_api64.dll','release.json'] + present=[n for n in names if (tx/'old'/n).is_file()] + elif journal.get('protocol')==2: + names=journal['names'];present=journal['present'] + if not isinstance(names,list) or not isinstance(present,list) or len(names)>40001 or not set(present)<=set(names): + raise ValueError('Invalid recovery journal') + else:raise ValueError('Unknown update journal') + safe_paths(root,names) + for name in reversed(names): + dest=root/name + if name in present: + dest.parent.mkdir(parents=True,exist_ok=True) + retry(lambda: shutil.copy2(tx/'old'/name,dest)) + else:retry(lambda: dest.unlink(missing_ok=True)) + (tx/'journal.json').unlink() + +def install(root,tx): + if (tx/'journal.json').exists():raise ValueError('Recover interrupted update first') + meta=read(tx/'new/release.json',{}) + new=manifest_files(meta) + old_meta=read(root/'release.json',{}) + old=manifest_files(old_meta) + names=sorted(set(new)|set(old))+['release.json'] + safe_paths(root,names) + if mismatches(tx/'new',meta):raise ValueError('Staged program checksum mismatch') + present=[] + # Keep the journal absent until every backup has completed. + for name in names: + src=root/name + if src.exists(): + if not src.is_file():raise ValueError('Program path is not a file: '+name) + backup=tx/'old'/name;backup.parent.mkdir(parents=True,exist_ok=True) + shutil.copy2(src,backup);present.append(name) + atomic(tx/'journal.json',{'protocol':2,'names':names,'present':present}) + try: + for name in names[:-1]: + dest=root/name + if name in new: + dest.parent.mkdir(parents=True,exist_ok=True) + retry(lambda: os.replace(tx/'new'/name,dest)) + else:retry(lambda: dest.unlink(missing_ok=True)) + if mismatches(root,meta):raise ValueError('Installed program checksum mismatch') + # Publish version only after the entire program verifies. + retry(lambda: os.replace(tx/'new/release.json',root/'release.json')) + (tx/'journal.json').unlink() + except Exception: + rollback(root,tx) + raise diff --git a/tools/update_processes.py b/tools/update_processes.py new file mode 100644 index 00000000..5a73064a --- /dev/null +++ b/tools/update_processes.py @@ -0,0 +1,31 @@ +"""Release file locks only for executables owned by this installation.""" +import os + +def close_programs(root, names): + if os.name != 'nt': return + import ctypes as c + from ctypes import wintypes as w + root=root.resolve() + targets={os.path.normcase(str(root/name)) for name in names if name.lower().endswith('.exe')} + k=c.WinDLL('kernel32',use_last_error=True) + ps=c.WinDLL('psapi',use_last_error=True) + k.OpenProcess.argtypes=[w.DWORD,w.BOOL,w.DWORD];k.OpenProcess.restype=w.HANDLE + k.QueryFullProcessImageNameW.argtypes=[w.HANDLE,w.DWORD,w.LPWSTR,c.POINTER(w.DWORD)] + k.TerminateProcess.argtypes=[w.HANDLE,w.UINT] + k.WaitForSingleObject.argtypes=[w.HANDLE,w.DWORD] + k.CloseHandle.argtypes=[w.HANDLE] + ps.EnumProcesses.argtypes=[c.POINTER(w.DWORD),w.DWORD,c.POINTER(w.DWORD)] + pids=(w.DWORD*65536)();used=w.DWORD() + if not ps.EnumProcesses(pids,c.sizeof(pids),c.byref(used)): + raise OSError('Could not check running game processes') + for pid in pids[:used.value//c.sizeof(w.DWORD)]: + if pid==os.getpid():continue + # Query and terminate through the same handle, avoiding PID-reuse races. + h=k.OpenProcess(0x1000|0x0001|0x100000,False,pid) + if not h:continue + try: + text=c.create_unicode_buffer(32768);size=w.DWORD(len(text)) + if k.QueryFullProcessImageNameW(h,0,text,c.byref(size)) and os.path.normcase(text.value) in targets: + if not k.TerminateProcess(h,0):raise OSError('Could not close '+text.value) + if k.WaitForSingleObject(h,10000)!=0:raise OSError('Timed out closing '+text.value) + finally:k.CloseHandle(h) diff --git a/tools/updater.py b/tools/updater.py index 3b62d8ff..92716739 100644 --- a/tools/updater.py +++ b/tools/updater.py @@ -13,6 +13,8 @@ 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' @@ -68,10 +70,7 @@ def identity(m): def program_metadata(m): identity(m) - files = m.get('files') - if not isinstance(files, dict) or any(not isinstance(files.get(name), str) - or not re.fullmatch('[0-9a-f]{64}', files[name]) for name in FILES[:-1]): - raise ValueError('Missing program hashes') + installer.manifest_files(m) def safe_local_paths(root): @@ -145,7 +144,7 @@ def release_candidates(assets, rolling): return [(f'release-{n}.json', f'skate3rust-windows-x64-build-{n}.zip') for n in builds] -def discover(current, channel, cancel): +def discover(current, channel, cancel, repair=False): current_build = identity(current) candidates = [] deadline = time.monotonic() + 120 @@ -167,7 +166,7 @@ def discover(current, channel, cancel): 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 + 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): @@ -208,7 +207,8 @@ def stage(candidate, directory, cancel, progress): total += info.file_size if total > 2 * 1024 * 1024 * 1024: raise ValueError('Expanded package too large') - for name in FILES: + 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) @@ -234,33 +234,11 @@ def retry(operation): def rollback(root, tx): - safe_local_paths(root) - 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() + installer.rollback(root, tx) def install(root, tx): - safe_local_paths(root) - # 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 + installer.install(root, tx) def main(request=None): @@ -320,6 +298,8 @@ def main(request=None): busy = False generation = 0 closing = False + repair_files = [] + repair_after_update = False def work(kind, operation): nonlocal busy @@ -352,6 +332,7 @@ def main(request=None): 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: @@ -359,7 +340,11 @@ def main(request=None): 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') - return discover(current, selected, cancel) + 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']) + return discover(current, selected, cancel, repair=bool(repair_files)) work('checked', perform_check) def do_update(): @@ -411,6 +396,7 @@ def main(request=None): check.configure(state='normal') choice.configure(state='readonly') 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.') @@ -426,13 +412,16 @@ def main(request=None): 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.') + update.configure(state='normal', text='Repair' if repair_files else 'Update') + status.set(('Repair needed: ' + ', '.join(repair_files) + '. Download will repair the installation.') if repair_files else 'New release available. Cancel keeps your current version.') + 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: - status.set('No newer compatible release in this channel.') + status.set('Repair needed, but this build is not available in the selected channel. Select its channel or a newer release.' if repair_files else 'No newer compatible release in this channel.') elif kind == 'staged': if cancel.is_set(): continue @@ -441,9 +430,10 @@ def main(request=None): 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. + # 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: @@ -460,7 +450,11 @@ def main(request=None): pass win.after(100, poll) - if not automatic or time.time() - settings.get(channel.get(), 0) >= 21600: + 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()