Support signed deb self-updates with system authorization in 0.1.4

This commit is contained in:
Emil
2026-09-10 03:28:51 +03:00
parent bb4b1f8051
commit 260e4577d0
19 changed files with 1202 additions and 65 deletions
+17 -2
View File
@@ -72,8 +72,21 @@ payload are in `/root/shacraft` on the ShaCraft host; see
Downloads require HTTPS without redirects on exact `shacraft.ru`, below
`/downloads/shacraft-launcher/<signed-version>/`, and are bounded to 256 MiB.
Stable versions must increase. The native layer owns every candidate.
- Linux self-update is supported for AppImage only. Preserve executable
permissions and use same-directory atomic replacement after verification.
- Linux self-update supports AppImage and an installed `sha-craft-launcher`
deb. AppImage preserves executable permissions and uses same-directory
atomic replacement after verification. Deb selects only the signed
`linux-x86_64-deb` entry; retain legacy `linux-x86_64` AppImage metadata for
installed 0.1.3 clients. Never silently switch installation formats.
- Deb elevation uses only `/usr/bin/pkexec --disable-internal-agent` and the
root-owned installed `/usr/bin/shacraft-launcher --shacraft-install-deb`.
This early helper mode never starts GTK/Tauri or account/network code. It
receives bounded metadata/package bytes over stdin, not paths or commands,
and re-verifies both signatures with the embedded key as root. Before the
fixed dpkg installation, require exact package name, architecture and signed
version, root-only staging and monotonic installed-package version; pass
`--refuse-downgrade` to dpkg to close concurrent-update races. No system
password collection, sudo fallback or permissive polkit policy is allowed.
Cancellation, denied authorization and a busy package manager stay distinct.
Windows/macOS use the pinned Tauri installer implementation; the vendored
updater change only exposes construction from already verified metadata to
avoid a second, unbounded remote JSON request. See its patch notes.
@@ -122,6 +135,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
must remain outside its argument substitution and JVM argfile paths.
- `updater.rs`, `commands/updater.rs` — authenticated release metadata,
bounded package download, platform installation and guarded restart.
- `deb_updater.rs` — installed-package checks, one system authentication
prompt and the bounded, signature-verifying non-GUI root helper.
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
compatibility with already-written JSON.
- `docs/manifest-v1.md` — signed manifest envelope and payload contract
+24
View File
@@ -91,6 +91,30 @@
- [ ] Проверить установку и самообновление Windows/macOS перед публикацией
пакетов этих платформ; Authenticode/notarization остаются отдельными задачами.
## Обновление DEB 0.1.4: 2026-09-10
- [x] Отдельный подписанный deb entry, определение установленного формата
и сохранение AppImage-совместимости для клиентов 0.1.3.
- [x] Одно системное подтверждение через pkexec. Root helper до запуска GUI
повторно проверяет подписи и точные Package/Version/Architecture, получает
только bounded bytes через stdin и устанавливает пакет из root-only staging.
Пароль не попадает в лаунчер; отмена не открывает запасные окна авторизации.
- [x] Dpkg отказывается от downgrade и сохраняет системную блокировку пакетов.
Ошибки частичной установки не маскируются; автоматических повторов нет.
Read-only inspection имеет ограничение вывода и времени для группы процессов;
работающий dpkg не прерывается таймаутом посреди изменения пакета.
- [x] 32 UI-теста, TypeScript/Vite и 12 браузерных сценариев с mock IPC;
18 publisher-тестов с настоящими minisign и deb, включая переход feed 0.1.3.
- [x] 84 native-теста прошли. Реальный root-helper в изолированном Ubuntu 26.04
прошёл 10 сценариев: установка подписанного 0.1.4, повреждения, неверные
права/временный каталог, занятый dpkg, повтор и защита от downgrade.
Dpkg подтвердил версию, тестовый маркер профиля сохранён. GUI-подтверждение
PolicyKit этим контейнерным прогоном не проверялось; отмена покрыта UI/unit.
- [x] Опубликованы подписанные AppImage/deb 0.1.4, проверены байты feed и deb,
обе кнопки сайта и системная инструкция. Backend: 404 tests + 48 subtests,
Ruff clean. Minecraft не перезапускался, данные аккаунтов не менялись.
Deb 0.1.3 и ниже требуют первого ручного обновления до 0.1.4.
## Связанные серверные риски
Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны
+5 -2
View File
@@ -48,8 +48,11 @@ push/PR; workflow на main-push/ручном запуске собирает Wi
их только по кнопке. Подписи пакета и сведений о версии обязательны.
Закройте запущенный Minecraft перед установкой обновления.
В Linux используйте AppImage; deb и dev-бинарник обновляются вручную.
С версии 0.1.2 нужен один ручной переход на новый AppImage. Пакеты Windows/macOS
В Linux обновляются AppImage и установленный deb (с версии 0.1.4).
Для deb система запрашивает права администратора; пароль не передаётся лаунчеру.
Deb 0.1.3 и ниже нужно один раз обновить вручную до 0.1.4. Dev-бинарник
обновляется вручную. С версии 0.1.2 нужен ручной переход на новый AppImage.
Пакеты Windows/macOS
с этим механизмом ещё требуют публикации и проверки установки.
## Навигация
+36 -1
View File
@@ -23,7 +23,8 @@ read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
the result never controls files, versions, URLs, or the launch command.
Version 0.1.3 adds signed application updates, separate from modpack sync.
Linux AppImage replacement is supported; the first upgrade from 0.1.2 is manual.
Version 0.1.4 adds installed deb updates with system administrator confirmation.
The first AppImage upgrade from 0.1.2 and deb upgrade from 0.1.3 are manual.
Windows/macOS packages still require publication and actual installation tests.
Not yet implemented: a user-selectable profile directory and a "reset managed
files only" recovery action. OS code signing/notarization is separate from the
@@ -195,6 +196,24 @@ same-directory temporary file, signature verification, preserved permissions,
atomic rename and file/directory fsync. Windows/macOS retain Tauri's platform
installers. Unsupported Linux formats show manual installation instructions.
For installed deb packages, 0.1.4 selects only `linux-x86_64-deb`. The feed also
retains the identical legacy `linux-x86_64` and explicit `linux-x86_64-appimage`
AppImage entries so installed 0.1.3 readers remain compatible. Remote metadata
cannot switch a deb installation into an AppImage installation.
`deb_updater.rs` checks root ownership of the installed executable and its
parents and dpkg's ownership/version record. One `pkexec` invocation starts an
early non-GUI mode of `/usr/bin/shacraft-launcher`; no password is collected by
the launcher and no fallback prompt runs after cancellation. Bounded stdin
framing carries the signed envelope and package bytes, never user paths.
The helper authenticates both again as root, checks exact package identity
`sha-craft-launcher`, architecture and version, stages the package under a
root-only temporary directory and invokes the fixed dpkg installer. An explicit
`--refuse-downgrade` protects against another installation winning the version
race. Failed/partial package transactions require honest system-package recovery;
they are not reported as completed or automatically retried. Restart launches
the fixed installed executable even after dpkg replaces the running inode.
The native updater holds installation, account and game permits while installing
and until restart. The game permit remains held until the launched Java child
exits. These guards cover this launcher process, not other launcher instances.
@@ -248,3 +267,19 @@ The original source AppImage was retained. The installed 0.1.3 AppImage was
then started from `~/Applications` and its captured runtime paths verified.
This is not a full GUI update/restart cycle or a Windows/macOS installation test.
The Linux build host remains Ubuntu 26.04.
The 0.1.4 checkpoint passed 84 native tests (6 ignored), 32 UI tests and 18
publisher tests; 12 browser scenarios use mocked IPC. In a disposable Ubuntu
26.04 Docker container without network or production mounts, the actual signed
deb helper passed 10 scenarios: unprivileged invocation, truncated/trailing
input, damaged metadata/package, dpkg lock, unsafe temporary directory,
successful installation, replay and downgrade refusal. The fixture installed
the genuine old 0.1.3 package and bootstrapped the new verifier binary over its
package record; it then installed the genuine signed 0.1.4. It did not relabel
signed versions. Dpkg reported 0.1.4 and a fixture profile marker survived.
This tests the elevated helper and dpkg, not a real desktop PolicyKit dialog.
Cancellation/error rendering is covered by unit/browser scenarios. Published
metadata and deb bytes match the locally verified files; both website download
buttons target 0.1.4. No user host package installation was performed for QA.
The live native AppImage smoke also passed against the published 0.1.4 feed,
including corruption rejection and replacement of only a temporary source copy.
+85 -14
View File
@@ -9,9 +9,13 @@ updater verifies signatures before installing; an unavailable or invalid feed
does not prevent playing with the installed launcher.
The first version containing the updater must be installed manually. Version
0.1.2 has no code capable of installing this feature itself. On Linux, automatic
replacement is for AppImage installations; deb installations and development
binaries use the manual download path. Windows/macOS publication and actual
0.1.2 has no code capable of installing this feature itself. AppImage supports
self-updates from 0.1.3; deb adds them in 0.1.4. An existing 0.1.3 deb therefore
needs one manual upgrade to 0.1.4 before its own update button can work. A deb
installation keeps its package format and asks for system administrator
authorization when installing an update. The launcher itself runs as the normal
user. Development binaries use the manual download path. Windows/macOS
publication and actual
installation tests remain separate release work; supporting a platform in the
feed schema does not certify a working release on it.
@@ -22,7 +26,7 @@ The archived 2026-09-09 review bundle at
unreleased updater prototype: GitHub-hosted `latest.json`, a different pinned
key and the former LoginSystem/Game Bridge proof flow. Its successful CI and
prototype version numbers do not establish compatibility with the deployed
admission protocol. The current 0.1.3 release follows the deployed 0.1.2
admission protocol. The 0.1.3 and 0.1.4 releases follow the deployed 0.1.2
admission line based on `bf43254`, using the ShaCraft-hosted feed described here.
Never publish the archived `CI-NOT-FOR-RELEASE`/`CI_NOT_FOR_RELEASE` packages or
@@ -56,13 +60,65 @@ verified through Tauri's built-in updater signature check. The current version
must increase; there is no unsigned or automatic downgrade fallback.
The stable publisher accepts only plain `MAJOR.MINOR.PATCH` versions and these
platforms: `linux-x86_64` (`.AppImage`), `windows-x86_64` (`.exe` or `.msi`),
platforms: `linux-x86_64` (legacy `.AppImage`), `linux-x86_64-appimage`
(`.AppImage`), `linux-x86_64-deb` (`.deb`), `windows-x86_64` (`.exe` or `.msi`),
`darwin-x86_64` and `darwin-aarch64` (`.app.tar.gz`). Artifact filenames contain
only ASCII letters, digits, dots, underscores and hyphens. Files must already
exist in the matching version directory, must not be symlinks and must be
between 1 byte and 256 MiB. A platform without a tested signed artifact is
omitted, never represented by an empty signature or another platform's file.
Format-aware Linux feeds must contain both AppImage keys with exactly the same
URL and signature. This keeps 0.1.3 clients on their original AppImage path.
New AppImage clients prefer `linux-x86_64-appimage` and can read the legacy key;
deb clients require `linux-x86_64-deb` and never fall back to an AppImage.
Preserve all three entries when publishing a release that supports both formats.
After verifying each signature, the publisher also checks Linux package format.
AppImage must have the ELF64 little-endian x86_64 and type-2 AppImage header.
For deb, `/usr/bin/dpkg-deb` must report package `sha-craft-launcher`, architecture
`amd64` and the exact signed release version. Inspection uses fixed arguments,
no shell, a cleared environment, a 10-second timeout and a 4 KiB output limit.
It does not install a package or execute its maintainer scripts. This protects
against accidental publication of the wrong signed package; an installer
signature remains mandatory and is checked before package inspection.
## Debian installation boundary
The installed launcher must be `/usr/bin/shacraft-launcher`, owned by root in
root-owned directories that other users cannot write. The package database
must assign that file to an installed `sha-craft-launcher` of the expected
architecture. The updater needs the system `pkexec` authorization agent; it
does not collect a password or fall back to running a shell with privileges.
After the normal-user downloader verifies the update, `pkexec` launches the
fixed installed binary with `--shacraft-install-deb`. This mode runs before
Tauri/GTK initialization. It accepts only length-bounded signed metadata and
package bytes over stdin, never a user-provided package path. The root helper
independently verifies the metadata, selects only the exact deb target, checks
the package signature and requires a higher version than the current dpkg
database. It writes the verified bytes to a root-created mode-0700 temporary
directory under the validated `/var/tmp`; the file has mode 0600.
The helper checks the package's exact name, version and architecture with
`dpkg-deb`, then invokes fixed `dpkg --refuse-downgrade --install` arguments in
an environment without inherited variables. Dpkg's own downgrade refusal
protects against a competing newer installation between the version check and
the package-manager lock. A successful result also requires the package
database to report the intended version as installed. The temporary package
is removed on completion. A signed deb may include maintainer scripts, which
dpkg runs with administrator privileges as part of normal installation: review
release package contents before signing.
Cancellation of system authorization, missing authorization support, signature
rejection, a busy package manager and installation failure have distinct
messages. There is no automatic retry with weaker checks. Dpkg installation
is not an atomic file replacement: dependency/configuration failures or power
loss can require normal package-manager recovery. The launcher reports failure
instead of claiming the old installation is intact. Successful deb updates
restart the fixed installed binary as the ordinary user. These guarantees
are separate from AppImage's same-directory atomic replacement.
## Keys and builds
The production private key stays **only on the operator's local machine** at
@@ -88,33 +144,40 @@ package signatures; passing updater checks does not establish those assurances.
## Local preparation and signing
The publisher requires Python 3.10+ and `minisign`. It performs verification
The publisher requires Python 3.10+ and `minisign`; releases containing deb also
require `/usr/bin/dpkg-deb` (Debian/Ubuntu's `dpkg` package). It performs verification
through the standard minisign CLI, without implementing cryptography in Python.
`--minisign /absolute/path/to/minisign` supports a locally extracted tool without
installing a global package. Run these examples from the launcher repository,
substituting the actual release version and tested filenames.
1. Stage immutable, tested packages below a local downloads root. The following
example assumes the Linux artifact already exists at
`/tmp/shacraft-release/downloads/0.1.3/ShaCraft.Launcher_0.1.3_amd64.AppImage`
example assumes both tested Linux artifacts already exist below
`/tmp/shacraft-release/downloads/0.1.4/`
and release notes exist at `/tmp/shacraft-release/notes.txt`. Create signatures
with the Tauri CLI; `.sig` is written beside each artifact:
```bash
npm run tauri -- signer sign \
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
/tmp/shacraft-release/downloads/0.1.3/ShaCraft.Launcher_0.1.3_amd64.AppImage
/tmp/shacraft-release/downloads/0.1.4/ShaCraft.Launcher_0.1.4_amd64.AppImage
npm run tauri -- signer sign \
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
/tmp/shacraft-release/downloads/0.1.4/ShaCraft.Launcher_0.1.4_amd64.deb
```
2. Prepare a deterministic payload after verifying every package signature.
Repeat `--artifact PLATFORM=FILENAME` for each tested platform included in this
release. Do not list a deb, dmg, nonexistent package or untested architecture:
release. Keep the legacy AppImage alias. Do not list a dmg, nonexistent
package or untested architecture:
```bash
python3 scripts/publish_launcher_update.py prepare \
--version 0.1.3 \
--version 0.1.4 \
--downloads-root /tmp/shacraft-release/downloads \
--artifact linux-x86_64=ShaCraft.Launcher_0.1.3_amd64.AppImage \
--artifact linux-x86_64=ShaCraft.Launcher_0.1.4_amd64.AppImage \
--artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.4_amd64.AppImage \
--artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.4_amd64.deb \
--notes-file /tmp/shacraft-release/notes.txt \
--public-key /home/emil/.local/share/shacraft-updater/production.key.pub \
--payload /tmp/shacraft-release/release.payload.json
@@ -173,8 +236,12 @@ must validate against the actual deployed feed, not an empty staging directory.
Caddy should serve this feed as JSON with `Cache-Control: no-store`. Check the
public response, decoded metadata, signatures and downloadable artifact hashes
after deployment. Exercise a real installed AppImage updating to a higher
version, including relaunch and retained settings/account state. Unit tests,
packaging or a browser mock alone do not establish successful installation.
version, including relaunch and retained settings/account state. For deb, also
exercise administrator cancellation, package-manager lock conflicts, failed
installation and a successful package upgrade/relaunch. Use an isolated system
for destructive package-manager failure cases; never modify player data as a
test fixture. Unit tests, packaging or a browser mock alone do not establish
successful installation or distribution compatibility.
If a release is faulty, stop offering it and publish a corrected higher version;
do not weaken signature checks or silently downgrade users.
@@ -187,6 +254,10 @@ python3 -m unittest discover -s scripts -p 'test_*.py'
Publisher tests exercise the real minisign CLI with temporary test keys,
including valid publication, modified packages and metadata, authenticated
previous-version checks, downgrade refusal, URL/path restrictions and dry-run.
Linux tests also build real temporary deb packages with `dpkg-deb`, validate
package/version/architecture, ensure inspection never executes maintainer
scripts, retain the legacy AppImage feed alias, and reject malformed signed
Linux packages. Package-inspection output and time bounds are exercised.
No test private key is checked into the repository. CI installs minisign so
the signature tests run; locally they explicitly skip if the tool is absent.
Set `SHACRAFT_TEST_MINISIGN` to use an extracted executable.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "shacraft-launcher-ui",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shacraft-launcher-ui",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"@tauri-apps/api": "2.11.1",
"lucide-react": "1.41.0",
+2 -2
View File
@@ -2,13 +2,13 @@
"name": "shacraft-launcher-ui",
"license": "MIT",
"private": true,
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"typecheck": "tsc --noEmit",
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx src/state/updater.test.ts",
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx src/state/updater.test.ts src/components/updater.test.tsx",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
+70
View File
@@ -15,13 +15,17 @@ import json
import os
from pathlib import Path
import re
import selectors
import stat
import subprocess
import tempfile
import time
ORIGIN = "https://shacraft.ru/downloads/shacraft-launcher/"
PLATFORMS = {
"linux-x86_64": (".AppImage",),
"linux-x86_64-appimage": (".AppImage",),
"linux-x86_64-deb": (".deb",),
"windows-x86_64": (".exe", ".msi"),
"darwin-x86_64": (".app.tar.gz",),
"darwin-aarch64": (".app.tar.gz",),
@@ -29,6 +33,9 @@ PLATFORMS = {
FIELDS = {"version", "notes", "pub_date", "platforms"}
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
MAX_METADATA_BYTES = 64 * 1024
DEB_PACKAGE = "sha-craft-launcher"
DPKG_DEB = "/usr/bin/dpkg-deb"
PACKAGE_TOOL_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C"}
class InvalidRelease(ValueError):
@@ -110,6 +117,67 @@ def verify_signature(artifact, signature, public_key, minisign):
raise InvalidRelease("signature verification failed")
def bounded_command_output(command, limit=4096, timeout=10):
"""Run a fixed package inspector without shell, inherited hooks or unbounded output."""
process = None
try:
process = subprocess.Popen(
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, env=PACKAGE_TOOL_ENV,
)
deadline = time.monotonic() + timeout
output = bytearray()
with selectors.DefaultSelector() as selector:
selector.register(process.stdout, selectors.EVENT_READ)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0 or not selector.select(remaining):
raise InvalidRelease("package inspection timed out")
chunk = os.read(process.stdout.fileno(), min(4096, limit + 1 - len(output)))
if not chunk:
break
output.extend(chunk)
if len(output) > limit:
raise InvalidRelease("package inspection exceeds output limit")
returncode = process.wait(timeout=max(0.001, deadline - time.monotonic()))
if returncode != 0:
raise InvalidRelease("package inspection failed")
return bytes(output)
except (OSError, subprocess.TimeoutExpired) as exc:
raise InvalidRelease("package inspection could not run") from exc
finally:
if process is not None:
if process.poll() is None:
process.kill()
process.wait()
process.stdout.close()
def validate_artifact_format(platform, path, version):
if platform in {"linux-x86_64", "linux-x86_64-appimage"}:
with path.open("rb") as stream:
header = stream.read(64)
if (len(header) < 64 or header[:7] != b"\x7fELF\x02\x01\x01"
or header[8:11] != b"AI\x02" or header[18:20] != b"\x3e\x00"):
raise InvalidRelease("AppImage must be a type-2 x86_64 ELF image")
elif platform == "linux-x86_64-deb":
# Inspect only authenticated package bytes; dpkg-deb does not run maintainer scripts.
output = bounded_command_output([
DPKG_DEB, "--showformat=${Package}\n${Version}\n${Architecture}\n", "--show", str(path),
])
expected = f"{DEB_PACKAGE}\n{version}\namd64\n".encode("ascii")
if output != expected:
raise InvalidRelease("deb identity must match sha-craft-launcher, signed version and amd64")
def validate_linux_aliases(platforms):
if "linux-x86_64-appimage" in platforms or "linux-x86_64-deb" in platforms:
legacy = platforms.get("linux-x86_64")
exact = platforms.get("linux-x86_64-appimage")
if legacy is None or exact is None or legacy != exact:
raise InvalidRelease("format-aware Linux releases require identical legacy and AppImage entries")
def strict_json(data):
def unique(pairs):
result = {}
@@ -163,6 +231,7 @@ def validate_payload(payload, downloads_root, public_key, minisign):
platforms = payload["platforms"]
if not isinstance(platforms, dict) or not platforms:
raise InvalidRelease("at least one signed updater artifact is required")
validate_linux_aliases(platforms)
release_dir = downloads_root.resolve() / payload["version"]
if release_dir.is_symlink() or not release_dir.is_dir():
raise InvalidRelease("release directory must be an existing real directory")
@@ -177,6 +246,7 @@ def validate_payload(payload, downloads_root, public_key, minisign):
local_path = release_dir / filename
before = regular_file(local_path, MAX_ARTIFACT_BYTES)
verify_signature(local_path, artifact["signature"], public_key, minisign)
validate_artifact_format(platform, local_path, payload["version"])
after = regular_file(local_path, MAX_ARTIFACT_BYTES)
if (before.st_ino, before.st_size, before.st_mtime_ns) != (
after.st_ino, after.st_size, after.st_mtime_ns
+132 -1
View File
@@ -7,6 +7,7 @@ import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
@@ -15,6 +16,14 @@ import publish_launcher_update as publisher
MINISIGN = os.environ.get("SHACRAFT_TEST_MINISIGN", "minisign")
def appimage_fixture():
header = bytearray(64)
header[:7] = b"\x7fELF\x02\x01\x01"
header[8:11] = b"AI\x02"
header[18:20] = b"\x3e\x00"
return bytes(header) + b"isolated format fixture; not a runnable launcher"
class PolicyTests(unittest.TestCase):
def test_stable_versions_are_strict_and_order_numerically(self):
self.assertGreater(publisher.version_tuple("0.1.10"), publisher.version_tuple("0.1.9"))
@@ -24,10 +33,14 @@ class PolicyTests(unittest.TestCase):
def test_platform_filename_policy(self):
publisher.artifact_name("linux-x86_64", "ShaCraft.Launcher_0.1.3_amd64.AppImage")
publisher.artifact_name("linux-x86_64-appimage", "ShaCraft.Launcher_0.1.4_amd64.AppImage")
publisher.artifact_name("linux-x86_64-deb", "ShaCraft.Launcher_0.1.4_amd64.deb")
for platform, filename in (
("linux-x86_64", "../bad.AppImage"), ("linux-x86_64", "foo.AppImage?secret"),
("linux-x86_64", "%2e%2e.AppImage"), ("linux-x86_64", "install.exe"),
("unknown", "test.AppImage"), ("darwin-aarch64", "installer.dmg"),
("linux-x86_64", "install.deb"), ("linux-x86_64-appimage", "install.deb"),
("linux-x86_64-deb", "install.AppImage"),
):
with self.subTest(filename=filename), self.assertRaises(publisher.InvalidRelease):
publisher.artifact_name(platform, filename)
@@ -36,6 +49,20 @@ class PolicyTests(unittest.TestCase):
with self.assertRaises(publisher.InvalidRelease):
publisher.strict_json(b'{"version":"0.1.3","version":"9.0.0"}')
def test_package_inspection_is_bounded_and_clears_environment(self):
with self.assertRaisesRegex(publisher.InvalidRelease, "output limit"):
publisher.bounded_command_output([sys.executable, "-c", "print('x' * 8192)"], limit=128)
with self.assertRaisesRegex(publisher.InvalidRelease, "timed out"):
publisher.bounded_command_output([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.1)
os.environ["SHACRAFT_INSPECTION_SECRET_TEST"] = "must-not-be-inherited"
try:
output = publisher.bounded_command_output([
sys.executable, "-c", "import os; print(os.getenv('SHACRAFT_INSPECTION_SECRET_TEST', 'clean'))",
])
finally:
del os.environ["SHACRAFT_INSPECTION_SECRET_TEST"]
self.assertEqual(output, b"clean\n")
@unittest.skipUnless(shutil.which(MINISIGN), "minisign CLI required for signature integration tests")
class SignatureTests(unittest.TestCase):
@@ -54,7 +81,7 @@ class SignatureTests(unittest.TestCase):
release = self.downloads / "0.1.3"
release.mkdir(parents=True)
self.artifact = release / "fixture.AppImage"
self.artifact.write_bytes(b"isolated ShaCraft updater fixture; not an executable")
self.artifact.write_bytes(appimage_fixture())
self.payload = {
"version": "0.1.3", "notes": "Проверка обновления", "pub_date": "2026-09-10T00:00:00Z",
"platforms": {"linux-x86_64": {
@@ -151,6 +178,110 @@ class SignatureTests(unittest.TestCase):
self.publish(dry_run=True)
self.assertFalse(self.output.exists())
def make_deb(self, package="sha-craft-launcher", version=None, architecture="amd64"):
if not Path(publisher.DPKG_DEB).is_file():
self.skipTest("dpkg-deb required for real deb validation")
version = version or self.payload["version"]
tree = self.root / "deb-tree"
control = tree / "DEBIAN"
control.mkdir(parents=True, exist_ok=True)
(control / "control").write_text(
f"Package: {package}\nVersion: {version}\nArchitecture: {architecture}\n"
"Maintainer: Test <test@example.invalid>\nDescription: isolated updater fixture\n",
encoding="ascii",
)
# Inspection must not execute a package script, even for an authenticated package.
script = control / "preinst"
script.write_text(f"#!/bin/sh\ntouch '{self.root / 'script-executed'}'\n", encoding="ascii")
script.chmod(0o755)
deb = self.artifact.with_name("fixture.deb")
subprocess.run(
[publisher.DPKG_DEB, "--build", "--root-owner-group", str(tree), str(deb)],
env=publisher.PACKAGE_TOOL_ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=10, check=True,
)
self.payload["platforms"]["linux-x86_64-appimage"] = copy.deepcopy(
self.payload["platforms"]["linux-x86_64"]
)
self.payload["platforms"]["linux-x86_64-deb"] = {
"url": publisher.ORIGIN + self.payload["version"] + "/fixture.deb", "signature": self.sign(deb),
}
return deb
def test_format_aware_release_preserves_legacy_appimage_and_verifies_real_deb(self):
self.make_deb()
notes = self.root / "notes.txt"
notes.write_text(self.payload["notes"], encoding="utf-8")
args = argparse.Namespace(
version="0.1.3", artifact=[
"linux-x86_64=fixture.AppImage", "linux-x86_64-appimage=fixture.AppImage",
"linux-x86_64-deb=fixture.deb",
], downloads_root=self.downloads, notes_file=notes, payload=self.payload_path,
pub_date=self.payload["pub_date"], minisign=MINISIGN,
)
self.assertEqual(publisher.prepare(args, self.public_key), self.payload)
self.publish()
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
self.assertEqual(feed["platforms"]["linux-x86_64"], feed["platforms"]["linux-x86_64-appimage"])
self.assertEqual(set(feed["platforms"]), {"linux-x86_64", "linux-x86_64-appimage", "linux-x86_64-deb"})
self.assertFalse((self.root / "script-executed").exists())
def test_authenticated_legacy_feed_advances_to_format_aware_release(self):
self.publish()
old_release = self.artifact.parent
old_bytes = self.artifact.read_bytes()
new_release = self.downloads / "0.1.4"
shutil.copytree(old_release, new_release)
self.artifact = new_release / self.artifact.name
self.payload["version"] = "0.1.4"
self.payload["platforms"]["linux-x86_64"]["url"] = publisher.ORIGIN + "0.1.4/fixture.AppImage"
self.make_deb()
self.publish()
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
self.assertEqual(feed["version"], "0.1.4")
self.assertEqual(len(feed["platforms"]), 3)
self.assertEqual((old_release / self.artifact.name).read_bytes(), old_bytes)
def test_linux_format_release_cannot_drop_or_repoint_legacy_entry(self):
self.make_deb()
for key in ("linux-x86_64", "linux-x86_64-appimage"):
payload = copy.deepcopy(self.payload)
del payload["platforms"][key]
with self.subTest(key=key), self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
self.publish(payload)
payload = copy.deepcopy(self.payload)
payload["platforms"]["linux-x86_64-appimage"]["url"] = publisher.ORIGIN + "0.1.3/other.AppImage"
with self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
self.publish(payload)
self.assertFalse(self.output.exists())
def test_deb_identity_must_match_application_signed_version_and_architecture(self):
for changes in ({"package": "another-launcher"}, {"version": "9.0.0"}, {"architecture": "arm64"}):
with self.subTest(changes=changes):
self.make_deb(**changes)
with self.assertRaisesRegex(publisher.InvalidRelease, "deb identity"):
self.publish()
self.assertFalse(self.output.exists())
def test_signed_invalid_deb_is_rejected_without_running_package_scripts(self):
deb = self.make_deb()
deb.write_bytes(b"not a Debian archive")
self.payload["platforms"]["linux-x86_64-deb"]["signature"] = self.sign(deb)
with self.assertRaisesRegex(publisher.InvalidRelease, "package inspection failed"):
self.publish()
self.assertFalse((self.root / "script-executed").exists())
self.assertFalse(self.output.exists())
def test_signed_wrong_appimage_format_is_rejected(self):
for changed_slice, replacement in ((slice(8, 11), b"AI\x01"), (slice(18, 20), b"\xb7\x00")):
malformed = bytearray(appimage_fixture())
malformed[changed_slice] = replacement
self.artifact.write_bytes(malformed)
self.payload["platforms"]["linux-x86_64"]["signature"] = self.sign(self.artifact)
with self.subTest(replacement=replacement), self.assertRaisesRegex(publisher.InvalidRelease, "type-2 x86_64"):
self.publish()
self.assertFalse(self.output.exists())
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -3361,12 +3361,13 @@ dependencies = [
[[package]]
name = "shacraft-launcher"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
"flate2",
"getrandom 0.3.4",
"libc",
"md-5",
"minisign-verify",
"reqwest 0.12.28",
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "shacraft-launcher"
version = "0.1.3"
version = "0.1.4"
description = "ShaCraft Minecraft launcher"
authors = ["ShaCraft"]
license = "MIT"
@@ -23,6 +23,8 @@ base64 = "0.22"
ed25519-dalek = { version = "2", features = ["pkcs8"] }
getrandom = "0.3"
zeroize = "1"
libc = "0.2"
tempfile = "3"
tauri = { version = "2", features = [] }
tauri-plugin-updater = { version = "=2.11.0", path = "../vendor/tauri-plugin-updater", default-features = false, features = ["rustls-tls", "zip"] }
reqwest-updater = { package = "reqwest", version = "0.13", default-features = false }
@@ -36,4 +38,3 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
tempfile = "3"
+9
View File
@@ -37,6 +37,7 @@ pub(crate) async fn check_launcher_update(
updater::trusted_builder(&app)?,
&key,
&app.package_info().version.to_string(),
updater::installation_kind(&app),
)
.await
}
@@ -190,5 +191,13 @@ pub(crate) fn restart_launcher_after_update(
return Err("Сначала установите обновление лаунчера.".into());
}
}
#[cfg(target_os = "linux")]
if updater::installation_kind(&app) == updater::InstallationKind::Deb
|| crate::deb_updater::is_deleted_installed_binary()
{
crate::deb_updater::restart()?;
app.exit(0);
return Ok(());
}
app.restart()
}
+555
View File
@@ -0,0 +1,555 @@
//! The only elevated updater operation. pkexec starts this installed, root-owned
//! binary in an early non-GUI mode. Input is untrusted until BOTH signatures
//! are verified again here. No user-supplied path, command or password is used.
use crate::updater::{self, InstallationKind, MAX_ARTIFACT_BYTES, MAX_METADATA_BYTES};
use serde_json::Value;
use std::{
fs,
io::{self, Read, Write},
os::unix::{
fs::{MetadataExt, PermissionsExt},
process::CommandExt,
},
path::Path,
process::{Command, ExitStatus, Stdio},
sync::mpsc,
time::{Duration, Instant},
};
use tauri_plugin_updater::Update;
use url::Url;
const BINARY: &str = "/usr/bin/shacraft-launcher";
const HELPER_FLAG: &str = "--shacraft-install-deb";
const PACKAGE: &str = "sha-craft-launcher";
const PKEXEC: &str = "/usr/bin/pkexec";
const DPKG: &str = "/usr/bin/dpkg";
const QUERY: &str = "/usr/bin/dpkg-query";
const DEB: &str = "/usr/bin/dpkg-deb";
const OUTPUT_LIMIT: usize = 16 * 1024;
const INPUT_MAGIC: &[u8; 8] = b"SCDUPD01";
const REJECTED: i32 = 20;
const LOCKED: i32 = 21;
const INSTALL_FAILED: i32 = 22;
const INVALID_HOST: i32 = 23;
fn architecture() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
_ => "unsupported",
}
}
/// Validate the full path without following symlinks. The executable and every
/// parent must be root-owned and not writable by group/other users.
fn trusted_root_path(path: &Path, executable: bool) -> bool {
if !path.is_absolute()
|| path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return false;
}
let mut leaf = true;
for item in path.ancestors() {
let Ok(meta) = fs::symlink_metadata(item) else {
return false;
};
if meta.file_type().is_symlink() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
return false;
}
if leaf && executable {
if !meta.is_file() || meta.mode() & 0o111 == 0 {
return false;
}
} else if !meta.is_dir() {
return false;
}
leaf = false;
}
true
}
fn safe_sticky_temporary_parent(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|meta| {
meta.is_dir()
&& !meta.file_type().is_symlink()
&& meta.uid() == 0
&& (meta.mode() & 0o022 == 0 || meta.mode() & 0o1000 != 0)
})
}
fn fixed_command(path: &str) -> Command {
let mut command = Command::new(path);
command
.env_clear()
.env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
.env("LC_ALL", "C");
command
}
fn drain_capped(mut source: impl Read) -> io::Result<Vec<u8>> {
let mut result = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = source.read(&mut buffer)?;
if read == 0 {
return Ok(result);
}
let remaining = OUTPUT_LIMIT.saturating_sub(result.len());
result.extend_from_slice(&buffer[..read.min(remaining)]);
}
}
struct Captured {
status: ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
/// Drain both pipes concurrently; output can never make the root helper buffer
/// unbounded data or deadlock dpkg while it is changing the package database.
fn capture(command: &mut Command) -> io::Result<Captured> {
capture_with_deadline(command, Duration::from_secs(15))
}
fn capture_with_deadline(command: &mut Command, deadline: Duration) -> io::Result<Captured> {
// Read-only inspection has a deadline. Never kill dpkg during mutation:
// interrupting it could leave a partially configured installed package.
let inspection = command.get_program() != DPKG;
if inspection {
command.process_group(0);
}
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let (out_send, out_receive) = mpsc::channel();
let (err_send, err_receive) = mpsc::channel();
std::thread::spawn(move || {
let _ = out_send.send(drain_capped(stdout));
});
std::thread::spawn(move || {
let _ = err_send.send(drain_capped(stderr));
});
let start = Instant::now();
let mut stdout = None;
let mut stderr = None;
loop {
if stdout.is_none() {
stdout = out_receive.try_recv().ok();
}
if stderr.is_none() {
stderr = err_receive.try_recv().ok();
}
// Do not reap the parent before its pipes close. Its unreaped PID
// reserves the process-group id until a possible timeout kill below.
if stdout.is_some() && stderr.is_some() {
if let Some(status) = child.try_wait()? {
return Ok(Captured {
status,
stdout: stdout.unwrap()?,
stderr: stderr.unwrap()?,
});
}
}
if inspection && start.elapsed() > deadline {
// Also terminate dpkg-deb's decompressor descendants so they cannot
// retain the pipes after the inspection parent has been killed.
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
let _ = child.wait();
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"package inspection timed out",
));
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn installed_version() -> Result<String, ()> {
let result = capture(fixed_command(QUERY).args([
"--show",
"--showformat=${db:Status-Status}\n${Version}\n${Architecture}\n",
PACKAGE,
]))
.map_err(|_| ())?;
if !result.status.success() {
return Err(());
}
let fields = std::str::from_utf8(&result.stdout)
.map_err(|_| ())?
.lines()
.collect::<Vec<_>>();
if fields.len() != 3 || fields[0] != "installed" || fields[2] != architecture() {
return Err(());
}
let version = semver::Version::parse(fields[1]).map_err(|_| ())?;
if version.to_string() != fields[1] || !version.pre.is_empty() || !version.build.is_empty() {
return Err(());
}
// dpkg's database must also assign the precise executable to our package.
let owner = capture(fixed_command(QUERY).args(["--search", BINARY])).map_err(|_| ())?;
if !owner.status.success() || owner.stdout != format!("{PACKAGE}: {BINARY}\n").as_bytes() {
return Err(());
}
Ok(fields[1].to_owned())
}
pub(crate) fn installed_binary_supported() -> bool {
std::env::current_exe().is_ok_and(|path| path == Path::new(BINARY))
&& trusted_root_path(Path::new(BINARY), true)
&& [QUERY, DEB, DPKG]
.iter()
.all(|path| trusted_root_path(Path::new(path), true))
&& installed_version().is_ok()
}
pub(crate) fn unsupported_reason() -> Option<String> {
if trusted_root_path(Path::new(PKEXEC), true) {
None
} else {
Some("Для обновления deb нужен системный компонент pkexec (PolicyKit). Установите его или скачайте новый deb с shacraft.ru/help#launcher.".into())
}
}
pub(crate) fn is_deleted_installed_binary() -> bool {
std::env::current_exe()
.is_ok_and(|path| path == Path::new("/usr/bin/shacraft-launcher (deleted)"))
}
pub(crate) fn restart() -> Result<(), String> {
if !trusted_root_path(Path::new(BINARY), true) {
return Err("Установленный лаунчер недоступен. Запустите его из меню приложений.".into());
}
Command::new(BINARY).spawn().map_err(|_| {
"Не удалось перезапустить лаунчер. Запустите его из меню приложений.".to_string()
})?;
Ok(())
}
fn write_input(mut output: impl Write, metadata: &[u8], bytes: &[u8]) -> io::Result<()> {
if metadata.len() > MAX_METADATA_BYTES || bytes.len() > MAX_ARTIFACT_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"update exceeds input limit",
));
}
output.write_all(INPUT_MAGIC)?;
output.write_all(&(metadata.len() as u64).to_be_bytes())?;
output.write_all(metadata)?;
output.write_all(&(bytes.len() as u64).to_be_bytes())?;
output.write_all(bytes)
}
fn read_part(input: &mut impl Read, maximum: usize) -> io::Result<Vec<u8>> {
let mut length = [0_u8; 8];
input.read_exact(&mut length)?;
let length = u64::from_be_bytes(length);
if length == 0 || length > maximum as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid update input length",
));
}
let mut bytes = vec![0; length as usize];
input.read_exact(&mut bytes)?;
Ok(bytes)
}
fn read_input(mut input: impl Read) -> io::Result<(Value, Vec<u8>)> {
let mut magic = [0_u8; 8];
input.read_exact(&mut magic)?;
if &magic != INPUT_MAGIC {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid protocol",
));
}
let metadata = read_part(&mut input, MAX_METADATA_BYTES)?;
let bytes = read_part(&mut input, MAX_ARTIFACT_BYTES)?;
let mut trailing = [0_u8];
if input.read(&mut trailing)? != 0 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "trailing input"));
}
let raw = serde_json::from_slice(&metadata)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid metadata"))?;
Ok((raw, bytes))
}
fn exit_message(code: Option<i32>) -> String {
match code {
Some(0) => "",
Some(126) => "Установка отменена в системном окне. Текущая версия лаунчера сохранена.",
Some(127) => "Система не разрешила установку. Подтвердите права администратора в системном окне; при его отсутствии проверьте PolicyKit.",
Some(REJECTED) => "Системная проверка подписи или версии deb не пройдена. Установка отменена.",
Some(LOCKED) => "Пакетный менеджер занят другой установкой. Дождитесь её завершения и нажмите «Обновить» ещё раз.",
Some(INVALID_HOST) => "Системная установка ShaCraft не подтверждена. Установите новый deb вручную с shacraft.ru/help#launcher.",
_ => "Пакетный менеджер не завершил установку. Проверьте состояние пакетов в системе и повторите попытку; при необходимости установите deb вручную.",
}.to_owned()
}
pub(crate) fn install(update: &Update, bytes: &[u8]) -> Result<(), String> {
if !installed_binary_supported() {
return Err(exit_message(Some(INVALID_HOST)));
}
if let Some(reason) = unsupported_reason() {
return Err(reason);
}
let metadata =
serde_json::to_vec(&update.raw_json).map_err(|_| exit_message(Some(REJECTED)))?;
let mut child = Command::new(PKEXEC)
.args(["--disable-internal-agent", BINARY, HELPER_FLAG])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|_| exit_message(Some(127)))?;
// Always wait even on EPIPE: declining the system dialog closes stdin, and
// its exit status is the useful cancellation result, not "broken pipe".
let write_result = write_input(
child.stdin.take().expect("piped helper input"),
&metadata,
bytes,
);
let status = child.wait().map_err(|_| exit_message(None))?;
if status.success() && write_result.is_ok() {
Ok(())
} else {
Err(exit_message(status.code().filter(|code| *code != 0)))
}
}
fn verify_deb_release(raw: &Value, bytes: &[u8], key: &str, installed: &str) -> Result<String, ()> {
let metadata = updater::verified_metadata(raw, key).map_err(|_| ())?;
if !updater::newer_version(&metadata, installed).map_err(|_| ())? {
return Err(());
}
let version = metadata["version"].as_str().ok_or(())?;
let target = format!("linux-{}-deb", std::env::consts::ARCH);
let artifact = metadata["platforms"].get(&target).ok_or(())?;
let url = Url::parse(artifact["url"].as_str().ok_or(())?).map_err(|_| ())?;
updater::validate_download_url(&url, version, InstallationKind::Deb).map_err(|_| ())?;
updater::verify_signature(bytes, artifact["signature"].as_str().ok_or(())?, key)
.map_err(|_| ())?;
Ok(version.to_owned())
}
fn valid_package_fields(output: &[u8], version: &str) -> bool {
std::str::from_utf8(output)
.is_ok_and(|text| text == format!("{PACKAGE}\n{version}\n{}\n", architecture()))
}
fn lock_error(stderr: &[u8]) -> bool {
let text = String::from_utf8_lossy(stderr).to_ascii_lowercase();
(text.contains("lock")
&& (text.contains("locked")
|| text.contains("another process")
|| text.contains("resource temporarily unavailable")
|| text.contains("unable to acquire")))
|| text.contains("dpkg frontend lock was locked")
}
fn embedded_key() -> Result<String, ()> {
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).map_err(|_| ())?;
config["plugins"]["updater"]["pubkey"]
.as_str()
.map(str::to_owned)
.ok_or(())
}
fn run_helper() -> Result<(), i32> {
// pkexec cleans the environment before executing this root-owned program.
// Never initialize Tauri/GTK or network/account code in privileged mode.
if unsafe { libc::geteuid() } != 0 || !installed_binary_supported() {
return Err(INVALID_HOST);
}
let installed = installed_version().map_err(|_| INVALID_HOST)?;
let (raw, bytes) = read_input(io::stdin().lock()).map_err(|_| REJECTED)?;
let version = verify_deb_release(
&raw,
&bytes,
&embedded_key().map_err(|_| REJECTED)?,
&installed,
)
.map_err(|_| REJECTED)?;
// No untrusted filesystem object crosses the privilege boundary. This
// directory is created by root, mode 0700, after all signature checks.
if !trusted_root_path(Path::new("/var"), false)
|| !safe_sticky_temporary_parent(Path::new("/var/tmp"))
{
return Err(INVALID_HOST);
}
let temp = tempfile::Builder::new()
.prefix("shacraft-update-")
.tempdir_in("/var/tmp")
.map_err(|_| INSTALL_FAILED)?;
fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))
.map_err(|_| INSTALL_FAILED)?;
let package = temp.path().join("release.deb");
let mut output = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&package)
.map_err(|_| INSTALL_FAILED)?;
output
.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(|_| INSTALL_FAILED)?;
output
.write_all(&bytes)
.and_then(|_| output.sync_all())
.map_err(|_| INSTALL_FAILED)?;
drop(output);
let fields = capture(
fixed_command(DEB)
.arg("--show")
.arg("--showformat=${Package}\n${Version}\n${Architecture}\n")
.arg(&package),
)
.map_err(|_| REJECTED)?;
if !fields.status.success() || !valid_package_fields(&fields.stdout, &version) {
return Err(REJECTED);
}
// Check again immediately before mutation: another updater might have
// installed the release while the authentication dialog was open.
if !updater::newer_version(
&serde_json::json!({"version": version}),
&installed_version().map_err(|_| INVALID_HOST)?,
)
.map_err(|_| REJECTED)?
{
return Err(REJECTED);
}
let result = capture(
fixed_command(DPKG)
.args(["--refuse-downgrade", "--install"])
.arg(&package),
)
.map_err(|_| INSTALL_FAILED)?;
if !result.status.success() {
return Err(if lock_error(&result.stderr) {
LOCKED
} else {
INSTALL_FAILED
});
}
if installed_version().map_err(|_| INSTALL_FAILED)? != version {
return Err(INSTALL_FAILED);
}
Ok(())
}
/// The special flag is never registered as IPC and does not accept filenames.
/// Even manually invoking it cannot bypass signatures, package identity or
/// privilege checks. Errors intentionally print no package/metadata contents.
pub(crate) fn run_helper_if_requested() -> Option<i32> {
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
if !arguments.iter().any(|argument| argument == HELPER_FLAG) {
return None;
}
if arguments.len() != 1 || arguments[0] != HELPER_FLAG {
return Some(REJECTED);
}
Some(match run_helper() {
Ok(()) => 0,
Err(code) => code,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn framed_input_rejects_oversized_truncated_and_trailing_data() {
let mut bytes = Vec::new();
write_input(&mut bytes, b"{}", b"package").unwrap();
let (metadata, package) = read_input(&bytes[..]).unwrap();
assert_eq!(metadata, serde_json::json!({}));
assert_eq!(package, b"package");
assert!(read_input(&bytes[..bytes.len() - 1]).is_err());
bytes.push(0);
assert!(read_input(&bytes[..]).is_err());
let mut oversized = INPUT_MAGIC.to_vec();
oversized.extend_from_slice(&u64::MAX.to_be_bytes());
assert!(read_input(&oversized[..]).is_err());
}
#[test]
fn package_identity_version_architecture_are_exact() {
let good = format!("{PACKAGE}\n0.1.4\n{}\n", architecture());
assert!(valid_package_fields(good.as_bytes(), "0.1.4"));
for wrong in [
good.replace(PACKAGE, "another-package"),
good.replace("0.1.4", "0.1.5"),
good.replace(architecture(), "all"),
format!("{good}extra\n"),
] {
assert!(!valid_package_fields(wrong.as_bytes(), "0.1.4"));
}
}
#[test]
fn cancellation_authorization_and_package_lock_remain_distinct() {
assert!(exit_message(Some(126)).contains("отменена"));
assert!(exit_message(Some(127)).contains("не разрешила"));
assert!(exit_message(Some(LOCKED)).contains("занят"));
assert!(lock_error(
b"dpkg: error: dpkg frontend lock was locked by another process"
));
assert!(!lock_error(
b"dpkg: dependency problems prevent configuration"
));
}
#[test]
fn privileged_path_rejects_user_owned_files_symlinks_and_relative_paths() {
let directory = tempfile::tempdir().unwrap();
let file = directory.path().join("launcher");
fs::write(&file, b"file").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o777)).unwrap();
assert!(!trusted_root_path(&file, true));
let link = directory.path().join("link");
std::os::unix::fs::symlink("/usr/bin/dpkg", &link).unwrap();
assert!(!trusted_root_path(&link, true));
assert!(!trusted_root_path(Path::new("usr/bin/dpkg"), true));
}
#[test]
fn root_verification_does_not_accept_legacy_appimage_as_deb() {
let fixture: Value =
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap();
assert!(verify_deb_release(
&fixture["metadata"],
fixture["artifactText"].as_str().unwrap().as_bytes(),
fixture["publicKey"].as_str().unwrap(),
"0.0.0"
)
.is_err());
}
#[test]
fn inspection_timeout_kills_descendants_holding_output_pipes() {
let start = Instant::now();
let result = capture_with_deadline(
Command::new("/bin/sh").args(["-c", "sleep 30 & exit 0"]),
Duration::from_millis(100),
);
assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::TimedOut));
assert!(start.elapsed() < Duration::from_secs(3));
let output = capture(fixed_command(DEB).arg("--version")).unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("Debian"));
}
#[test]
fn command_output_is_bounded_and_fully_drained() {
let input = vec![b'x'; OUTPUT_LIMIT * 4];
assert_eq!(drain_capped(input.as_slice()).unwrap().len(), OUTPUT_LIMIT);
}
}
+6
View File
@@ -1,4 +1,6 @@
mod admission;
#[cfg(target_os = "linux")]
mod deb_updater;
mod download;
mod java;
mod launch;
@@ -20,6 +22,10 @@ mod commands;
mod operations;
pub fn run() {
#[cfg(target_os = "linux")]
if let Some(code) = deb_updater::run_helper_if_requested() {
std::process::exit(code);
}
use tauri::Manager;
tauri::Builder::default()
.manage(operations::LauncherOperations::default())
+160 -34
View File
@@ -15,13 +15,21 @@ use tauri_plugin_updater::{Update, UpdaterBuilder, UpdaterExt};
use url::Url;
pub(crate) const UPDATE_ENDPOINT: &str = "https://shacraft.ru/launcher/updates/stable.json";
const MAX_METADATA_BYTES: usize = 192 * 1024;
pub(crate) const MAX_METADATA_BYTES: usize = 192 * 1024;
const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024;
pub(crate) const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024;
const BAD_METADATA: &str =
"Не удалось подтвердить подлинность сведений об обновлении. Повторите проверку позже.";
const BAD_SIGNATURE: &str = "Подпись обновления не прошла проверку. Установка отменена.";
#[derive(Clone, Copy, Serialize, PartialEq, Eq, Debug)]
#[serde(rename_all = "lowercase")]
pub(crate) enum InstallationKind {
Appimage,
Deb,
Other,
}
#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Stage {
@@ -53,6 +61,7 @@ pub(crate) struct LauncherUpdater {
pub(crate) struct UpdateStatus {
pub current_version: String,
pub supported: bool,
pub installation_kind: InstallationKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub stage: Stage,
@@ -81,6 +90,7 @@ impl LauncherUpdater {
Ok(UpdateStatus {
current_version: app.package_info().version.to_string(),
supported: reason.is_none(),
installation_kind: installation_kind(app),
reason,
stage: state.stage,
version: state
@@ -103,28 +113,39 @@ impl LauncherUpdater {
}
}
pub(crate) fn unsupported_reason<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
pub(crate) fn installation_kind<R: Runtime>(app: &AppHandle<R>) -> InstallationKind {
#[cfg(target_os = "linux")]
{
let env = app.env();
let valid = match (
if let (Some(image), Some(directory), Ok(executable)) = (
env.appimage.as_ref(),
env.appdir.as_ref(),
std::env::current_exe(),
) {
(Some(image), Some(directory), Ok(executable)) => linux_appimage_supported(
std::path::Path::new(image),
std::path::Path::new(directory),
&executable,
),
_ => false,
};
if !valid {
return Some("Автообновление в Linux доступно в AppImage. Установите AppImage с shacraft.ru и запускайте его.".into());
if linux_appimage_supported(image.as_ref(), directory.as_ref(), &executable) {
return InstallationKind::Appimage;
}
}
if crate::deb_updater::installed_binary_supported() {
return InstallationKind::Deb;
}
}
let _ = app;
InstallationKind::Other
}
pub(crate) fn unsupported_reason<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
#[cfg(target_os = "linux")]
match installation_kind(app) {
InstallationKind::Appimage => return None,
InstallationKind::Deb => return crate::deb_updater::unsupported_reason(),
InstallationKind::Other => return Some("Для автообновления установите deb-пакет или запустите AppImage с shacraft.ru/help#launcher.".into()),
}
#[cfg(not(target_os = "linux"))]
let _ = app;
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
return Some("Для этой платформы доступна только ручная установка обновлений.".into());
#[cfg(not(target_os = "linux"))]
None
}
@@ -169,6 +190,9 @@ pub(crate) fn installation_path<R: Runtime>(
) -> Result<Option<std::path::PathBuf>, String> {
#[cfg(target_os = "linux")]
{
if installation_kind(app) == InstallationKind::Deb {
return Ok(None);
}
let image = app
.env()
.appimage
@@ -250,7 +274,7 @@ async fn bounded_response(
/// Same Minisign format and verification semantics as Tauri's updater. The
/// signed metadata and artifact each need a valid signature under the embedded
/// release key. A signed old artifact cannot be labelled as a new version.
fn verify_signature(
pub(crate) fn verify_signature(
bytes: &[u8],
encoded_signature: &str,
encoded_key: &str,
@@ -271,7 +295,7 @@ fn verify_signature(
.map_err(|_| BAD_SIGNATURE.into())
}
fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
pub(crate) fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
let object = raw.as_object().ok_or(BAD_METADATA)?;
if object.len() != 6 {
return Err(BAD_METADATA.into());
@@ -305,7 +329,7 @@ fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
Ok(parsed)
}
fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
pub(crate) fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
let announced = metadata
.get("version")
.and_then(Value::as_str)
@@ -319,7 +343,7 @@ fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
Ok(version > current)
}
fn require_platform(metadata: &Value) -> Result<(), String> {
fn require_platform(metadata: &Value, kind: InstallationKind) -> Result<String, String> {
let os = if cfg!(target_os = "macos") {
"darwin"
} else {
@@ -330,17 +354,34 @@ fn require_platform(metadata: &Value) -> Result<(), String> {
.get("platforms")
.and_then(Value::as_object)
.ok_or(BAD_METADATA)?;
let available = platforms.contains_key(&target)
|| ["appimage", "nsis", "msi", "app"]
#[cfg(target_os = "linux")]
let targets = match kind {
InstallationKind::Deb => vec![format!("{target}-deb")],
InstallationKind::Appimage => vec![format!("{target}-appimage"), target],
InstallationKind::Other => {
return Err("Формат установленного лаунчера не поддерживает обновление.".into())
}
};
#[cfg(not(target_os = "linux"))]
let targets = {
let _ = kind;
["nsis", "msi", "app"]
.iter()
.any(|bundle| platforms.contains_key(&format!("{target}-{bundle}")));
if !available {
return Err("Обновление для вашей платформы пока не опубликовано.".into());
}
Ok(())
.map(|bundle| format!("{target}-{bundle}"))
.chain(std::iter::once(target))
.collect::<Vec<_>>()
};
targets
.into_iter()
.find(|target| platforms.contains_key(target))
.ok_or_else(|| "Обновление для вашего формата установки пока не опубликовано.".into())
}
fn validate_download_url(url: &Url, version: &str) -> Result<(), String> {
pub(crate) fn validate_download_url(
url: &Url,
version: &str,
kind: InstallationKind,
) -> Result<(), String> {
let prefix = format!("/downloads/shacraft-launcher/{version}/");
let filename = url.path().strip_prefix(&prefix).ok_or(BAD_METADATA)?;
if url.scheme() != "https"
@@ -359,7 +400,11 @@ fn validate_download_url(url: &Url, version: &str) -> Result<(), String> {
return Err(BAD_METADATA.into());
}
let correct_extension = if cfg!(target_os = "linux") {
filename.ends_with(".AppImage")
match kind {
InstallationKind::Appimage => filename.ends_with(".AppImage"),
InstallationKind::Deb => filename.ends_with(".deb"),
InstallationKind::Other => false,
}
} else if cfg!(target_os = "macos") {
filename.ends_with(".app.tar.gz")
} else if cfg!(target_os = "windows") {
@@ -373,6 +418,18 @@ fn validate_download_url(url: &Url, version: &str) -> Result<(), String> {
Ok(())
}
fn candidate_kind(update: &Update) -> InstallationKind {
if cfg!(target_os = "linux") {
if update.target == format!("linux-{}-deb", std::env::consts::ARCH) {
InstallationKind::Deb
} else {
InstallationKind::Appimage
}
} else {
InstallationKind::Other
}
}
/// Fetch and authenticate a bounded static manifest before asking the vendored
/// upstream plugin's small offline constructor to create an Update. Its normal
/// HTTP check is intentionally unused because it buffers unbounded JSON.
@@ -380,6 +437,7 @@ pub(crate) async fn check_candidate(
builder: UpdaterBuilder,
key: &str,
current: &str,
kind: InstallationKind,
) -> Result<Option<Update>, String> {
let response = http_client(Duration::from_secs(20))?
.get(UPDATE_ENDPOINT)
@@ -391,10 +449,14 @@ pub(crate) async fn check_candidate(
let bytes = bounded_response(response, MAX_METADATA_BYTES, |_, _| {}).await?;
let raw: Value = serde_json::from_slice(&bytes).map_err(|_| BAD_METADATA)?;
let metadata = verified_metadata(&raw, key)?;
require_platform(&metadata)?;
let target = require_platform(&metadata, kind)?;
if !newer_version(&metadata, current)? {
return Ok(None);
}
#[cfg(target_os = "linux")]
let builder = builder.target(target);
#[cfg(not(target_os = "linux"))]
let _ = target;
let update = builder
.build()
.map_err(updater_error)?
@@ -409,7 +471,11 @@ pub(crate) async fn check_candidate(
}
// Retain the exact signed envelope with the native-only candidate.
verified_metadata(&update.raw_json, key)?;
validate_download_url(&update.download_url, &update.version)?;
validate_download_url(
&update.download_url,
&update.version,
candidate_kind(&update),
)?;
Ok(Some(update))
}
@@ -418,7 +484,11 @@ pub(crate) async fn download_verified(
key: &str,
progress: impl FnMut(u64, Option<u64>),
) -> Result<Vec<u8>, String> {
validate_download_url(&update.download_url, &update.version)?;
validate_download_url(
&update.download_url,
&update.version,
candidate_kind(update),
)?;
verified_metadata(&update.raw_json, key)?;
let response = http_client(Duration::from_secs(600))?
.get(update.download_url.clone())
@@ -441,10 +511,17 @@ pub(crate) fn install_verified(
key: &str,
destination: Option<&std::path::Path>,
) -> Result<(), String> {
validate_download_url(&update.download_url, &update.version)?;
validate_download_url(
&update.download_url,
&update.version,
candidate_kind(update),
)?;
verify_signature(bytes, &update.signature, key)?;
#[cfg(target_os = "linux")]
{
if candidate_kind(update) == InstallationKind::Deb {
return crate::deb_updater::install(update, bytes);
}
let destination = destination.ok_or("Файл AppImage недоступен.")?;
install_appimage_atomic(destination, bytes).map_err(|_| {
"Не удалось заменить AppImage. Проверьте свободное место и права на папку лаунчера."
@@ -522,7 +599,12 @@ mod tests {
#[test]
fn download_policy_pins_origin_version_plain_path_and_package_type() {
assert!(validate_download_url(&Url::parse(artifact_url()).unwrap(), "0.2.0").is_ok());
assert!(validate_download_url(
&Url::parse(artifact_url()).unwrap(),
"0.2.0",
InstallationKind::Appimage
)
.is_ok());
for value in [
artifact_url().replace("https:", "http:"),
artifact_url().replace("shacraft.ru/", "evil.example/"),
@@ -536,12 +618,52 @@ mod tests {
format!("{}.sh", artifact_url()),
] {
assert!(
validate_download_url(&Url::parse(&value).unwrap(), "0.2.0").is_err(),
validate_download_url(
&Url::parse(&value).unwrap(),
"0.2.0",
InstallationKind::Appimage
)
.is_err(),
"{value}"
);
}
}
#[cfg(target_os = "linux")]
#[test]
fn linux_selects_package_family_without_deb_fallback() {
let base = format!("linux-{}", std::env::consts::ARCH);
let legacy = serde_json::json!({"platforms": {base.clone(): {}}});
assert_eq!(
require_platform(&legacy, InstallationKind::Appimage).unwrap(),
base
);
assert!(require_platform(&legacy, InstallationKind::Deb).is_err());
let exact_image = format!("{base}-appimage");
let exact_deb = format!("{base}-deb");
let all = serde_json::json!({"platforms": {base.clone(): {}, exact_image.clone(): {}, exact_deb.clone(): {}}});
assert_eq!(
require_platform(&all, InstallationKind::Appimage).unwrap(),
exact_image
);
assert_eq!(
require_platform(&all, InstallationKind::Deb).unwrap(),
exact_deb
);
let deb = Url::parse(
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0_amd64.deb",
)
.unwrap();
assert!(validate_download_url(&deb, "0.2.0", InstallationKind::Deb).is_ok());
assert!(validate_download_url(&deb, "0.2.0", InstallationKind::Appimage).is_err());
assert!(validate_download_url(
&Url::parse(artifact_url()).unwrap(),
"0.2.0",
InstallationKind::Deb
)
.is_err());
}
#[test]
fn stable_channel_never_downgrades_or_installs_equal_aliases() {
assert!(newer_version(&serde_json::json!({"version":"0.2.0"}), "0.1.3").unwrap());
@@ -621,7 +743,11 @@ mod tests {
#[test]
fn unavailable_platform_is_not_reported_as_latest() {
assert!(require_platform(&serde_json::json!({"platforms":{}})).is_err());
assert!(require_platform(
&serde_json::json!({"platforms":{}}),
InstallationKind::Appimage
)
.is_err());
}
#[cfg(target_os = "linux")]
@@ -698,7 +824,7 @@ mod tests {
.unwrap()
.executable_path(&destination);
tauri::async_runtime::block_on(async {
let update = check_candidate(builder, &key, "0.1.2")
let update = check_candidate(builder, &key, "0.1.2", InstallationKind::Appimage)
.await
.unwrap()
.expect("newer published version");
+11 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ShaCraft Launcher",
"version": "0.1.3",
"version": "0.1.4",
"identifier": "ru.shacraft.launcher",
"build": {
"beforeDevCommand": "npm run dev",
@@ -36,7 +36,16 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"linux": {
"deb": {
"depends": [
"libwebkit2gtk-4.1-0",
"libgtk-3-0",
"pkexec"
]
}
}
},
"plugins": {
"updater": {
+7 -2
View File
@@ -10,6 +10,7 @@ export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof
const working = phase === 'downloading' || phase === 'installing'
const ready = phase === 'ready' || phase === 'restarting'
const checking = phase === 'loading' || phase === 'checking'
const deb = status?.installationKind === 'deb'
return <section className="launcher-update" aria-labelledby="launcher-update-title">
<div className="launcher-update-heading">
@@ -19,20 +20,24 @@ export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof
<div className="launcher-update-status" aria-live="polite">
{!isNative() ? <p>Обновления доступны в приложении лаунчера.</p>
: ready ? <p className="update-success">Обновление установлено. Перезапустите лаунчер.</p>
: working ? <p>{phase === 'installing' ? 'Проверяем подпись и устанавливаем…'
: working ? <p>{phase === 'installing' ? deb
? 'Подтвердите установку в системном окне и дождитесь завершения.'
: 'Проверяем подпись и устанавливаем…'
: `Скачиваем обновление${percent === null ? '…' : ` · ${percent}%`}`}</p>
: checking ? <p>Проверяем обновления…</p>
: status?.supported === false ? <p>{status.reason || 'Для этой установки обновление доступно вручную на shacraft.ru/help#launcher.'}</p>
: status?.version ? <p className="update-success">Доступна версия {status.version}</p>
: state.checked && !error ? <p>У вас последняя версия.</p>
: <p>Проверка новой версии лаунчера.</p>}
{working && <progress aria-label="Загрузка обновления лаунчера" max={100} value={phase === 'installing' ? undefined : percent ?? undefined} />}
{working && <progress aria-label={phase === 'installing' ? 'Установка обновления лаунчера' : 'Загрузка обновления лаунчера'} max={100} value={phase === 'installing' ? undefined : percent ?? undefined} />}
</div>
{status?.notes && status.version && !working && !ready && <details className="update-notes">
<summary>Что нового</summary><p>{status.notes.slice(0, 1600)}</p>
</details>}
{error && <p className="status-error update-error" role="status">{error}</p>}
{eventError && <p className="status-error update-error" role="status">{eventError} Перезапустите лаунчер, чтобы включить установку обновлений.</p>}
{deb && status?.supported && status.version && !working && !ready &&
<p className="update-hint">Для обновления deb потребуется подтверждение администратора в системном окне.</p>}
{isNative() && <div className="update-actions">
{ready ? <button className="update-primary" disabled={blocked || phase === 'restarting'} onClick={() => void updater.restart()}>
<RefreshCw />{phase === 'restarting' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
+75
View File
@@ -0,0 +1,75 @@
import { doesNotMatch, match } from 'node:assert/strict'
import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
import { initialUpdaterState, updaterReducer, updateBlocksOperations, type UpdaterState } from '../state/updater'
function render(state: UpdaterState): string {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousTauri = Object.getOwnPropertyDescriptor(globalThis, 'isTauri')
Object.defineProperty(globalThis, 'window', { configurable: true, value: { isTauri: true } })
Object.defineProperty(globalThis, 'isTauri', { configurable: true, value: true })
try {
const updater: ReturnType<typeof useLauncherUpdate> = {
state, blocked: false, eventsReady: true, eventError: null,
locksOperations: updateBlocksOperations(state), check: async () => {},
install: async () => {}, restart: async () => {},
}
return renderToStaticMarkup(<LauncherUpdateSettings updater={updater} />)
} finally {
if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window')
if (previousTauri) Object.defineProperty(globalThis, 'isTauri', previousTauri)
else Reflect.deleteProperty(globalThis, 'isTauri')
}
}
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
status: { currentVersion: '0.1.4', supported: true, installationKind: 'deb', version: '0.1.5' } })
test('deb announces system authorization before installation without collecting credentials', () => {
const html = render(available)
match(html, /Для обновления deb потребуется подтверждение администратора в системном окне/)
match(html, /class="update-primary"><[^>]+.*Обновить<\/button>/)
doesNotMatch(html, /<input|type="password"|Перезапустить лаунчер/)
})
test('deb installation requests system confirmation and prevents duplicate install or check', () => {
const downloading = updaterReducer(available, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10, totalBytes: 10 } })
const html = render(installing)
match(html, /Подтвердите установку в системном окне и дождитесь завершения/)
match(html, /aria-label="Установка обновления лаунчера"/)
match(html, /class="update-primary" disabled=""/)
match(html, /class="update-check" disabled=""/)
doesNotMatch(html, /Для обновления deb потребуется|<input|type="password"/)
})
test('cancelled deb authorization remains visible and permits explicit retry without claiming success', () => {
const downloading = updaterReducer(available, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10 } })
const cancelled = updaterReducer(installing, { type: 'failed', error: 'Установка отменена в системном окне.' })
const html = render(cancelled)
match(html, /role="status">Установка отменена в системном окне/)
match(html, /class="update-primary">/)
match(html, /Повторить проверку/)
doesNotMatch(html, /disabled=""|Обновление установлено|Перезапустить лаунчер/)
const retry = render(updaterReducer(cancelled, { type: 'install' }))
match(retry, /Скачиваем обновление/)
doesNotMatch(retry, /Установка отменена/)
})
test('AppImage and older native status retain their installation copy without administrator hints', () => {
for (const installationKind of ['appimage', undefined] as const) {
const status = { ...available.status!, installationKind }
const downloading = updaterReducer({ ...available, status }, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10 } })
const html = render(installing)
match(html, /Проверяем подпись и устанавливаем/)
doesNotMatch(html, /администратора|системном окне/)
}
})
+1
View File
@@ -84,6 +84,7 @@ export interface GameExitedPayload {
export interface LauncherUpdateStatus {
currentVersion: string
supported: boolean
installationKind?: 'appimage' | 'deb' | 'other'
reason?: string | null
version?: string | null
notes?: string | null