Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7eee390ab | ||
|
|
5b741771b4 | ||
|
|
799fa692ef | ||
|
|
69946e62f1 | ||
|
|
fd8a6a87a1 | ||
|
|
b43fc610c4 | ||
|
|
effe683a40 | ||
|
|
260e4577d0 | ||
|
|
bb4b1f8051 | ||
|
|
bf43254c57 |
@@ -23,10 +23,10 @@ jobs:
|
||||
args: --bundles nsis,msi
|
||||
- name: macOS Apple Silicon
|
||||
os: macos-15
|
||||
args: --target aarch64-apple-darwin --bundles dmg
|
||||
args: --target aarch64-apple-darwin --bundles app,dmg
|
||||
- name: macOS Intel
|
||||
os: macos-15-intel
|
||||
args: --target x86_64-apple-darwin --bundles dmg
|
||||
args: --target x86_64-apple-darwin --bundles app,dmg
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -45,7 +45,26 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||
- run: npm run tauri:build -- ${{ matrix.args }}
|
||||
# These are build/test artifacts. Release signing happens separately with
|
||||
# the operator-held key; CI never receives that key or publishes stable.json.
|
||||
- run: npm run tauri:build -- --config scripts/tauri-unsigned.json ${{ matrix.args }}
|
||||
- name: Prepare unsigned macOS updater archive
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
apps = list(Path('src-tauri/target').glob('*/release/bundle/macos/*.app'))
|
||||
if len(apps) != 1:
|
||||
raise SystemExit('Expected exactly one macOS application bundle')
|
||||
app = apps[0]
|
||||
version = json.loads(Path('src-tauri/tauri.conf.json').read_text())['version']
|
||||
architecture = app.parts[2].split('-')[0]
|
||||
archive = app.parent / f'ShaCraft.Launcher_{version}_{architecture}.app.tar.gz'
|
||||
with tarfile.open(archive, 'w:gz') as output:
|
||||
output.add(app, arcname=app.name)
|
||||
PY
|
||||
- name: Upload Windows installers
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -70,4 +89,6 @@ jobs:
|
||||
with:
|
||||
name: shacraft-launcher-${{ matrix.name == 'macOS Apple Silicon' && 'macos-arm64' || 'macos-x64' }}
|
||||
if-no-files-found: error
|
||||
path: src-tauri/target/*/release/bundle/dmg/*.dmg
|
||||
path: |
|
||||
src-tauri/target/*/release/bundle/dmg/*.dmg
|
||||
src-tauri/target/*/release/bundle/macos/*.app.tar.gz
|
||||
|
||||
@@ -13,6 +13,30 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publisher:
|
||||
# Ubuntu 22.04 has no minisign package. Keep the desktop compatibility
|
||||
# check there, but exercise real signature verification on Ubuntu 24.04.
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: sudo apt-get update && sudo apt-get install -y minisign
|
||||
- run: python3 -m unittest discover -s scripts -p 'test_*.py'
|
||||
admission-client:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- name: Check the Minigames admission companion
|
||||
working-directory: admission-client
|
||||
run: ./gradlew test build --no-daemon
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: shacraft-admission-client
|
||||
if-no-files-found: error
|
||||
path: admission-client/build/libs/shacraft-admission-client-0.1.0.jar
|
||||
check:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
|
||||
@@ -10,3 +10,7 @@ src-tauri/gen/
|
||||
*.p12
|
||||
*.pfx
|
||||
*.sig
|
||||
|
||||
admission-client/.gradle/
|
||||
admission-client/build/
|
||||
graphify-out/
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
Cross-platform desktop launcher for the ShaCraft Minecraft network. It is a
|
||||
Tauri 2 application: React/Vite is the UI and Rust owns all filesystem,
|
||||
network and process-adjacent work. The current production profile is
|
||||
**Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21).
|
||||
network and process-adjacent work. Profiles are **Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21)
|
||||
and **Minigames** (Minecraft 26.2, Fabric 0.19.5, Java 25). See the Minigames
|
||||
integration record below and `docs/release-0.1.6.md` for publication evidence.
|
||||
|
||||
This repository owns the launcher only. The server-side API and published
|
||||
payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
@@ -21,8 +22,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
|
||||
## Trust model
|
||||
|
||||
- The only supported remote profile manifest endpoint is
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`.
|
||||
- The only supported remote profile manifest endpoints are the fixed
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest` and
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest`.
|
||||
The read-only Aeronautics player-count endpoint
|
||||
`https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it
|
||||
is display-only and is never allowed to influence downloads or launching.
|
||||
@@ -41,11 +43,62 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
control a URL in any of those domains.
|
||||
- ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the
|
||||
hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The
|
||||
revocable session token is stored locally with mode 600 on Unix. At launch,
|
||||
the nickname is fetched from the verified `aoc` account link; the legacy
|
||||
nickname in `settings.json` is ignored as an identity source. Server-side
|
||||
whitelist enforcement and LoginSystem remain the final access-control
|
||||
boundary, including for old launcher versions.
|
||||
revocable session token is stored locally with mode 600 on Unix and never
|
||||
passed to Java. The new admission flow uses fixed POST endpoints
|
||||
`/api/launcher/v2/admission/nickname` and
|
||||
`/api/launcher/v2/admission/tickets`; redirects are rejected. Neither the
|
||||
manifest nor the webview can select their origin or URL.
|
||||
- Free nicknames are claimed directly for the authenticated account. Existing
|
||||
player names are reserved server-side and assigned by an administrator;
|
||||
deleting a website account must not make an existing player's name free.
|
||||
- After installation, `admission.rs` generates an ephemeral Ed25519 key with
|
||||
the OS CSPRNG. The backend binds its public key to a one-use ticket, current
|
||||
account session, canonical `aoc` nickname and server access. Only that
|
||||
returned nickname selects launch identity; `settings.json` is never a fallback.
|
||||
Ticket and PKCS#8 private key go only in the final Java child's environment
|
||||
(`SHACRAFT_ADMISSION_TICKET`, `SHACRAFT_ADMISSION_PRIVATE_KEY`). Never put
|
||||
them in global environment, argv/argfiles, settings, logs or IPC. A fresh
|
||||
launch obtains a fresh ticket; there is no shared launcher secret.
|
||||
- Once admission is enforced on Aeronautics, its server mod verifies the
|
||||
challenge/proof before world entry, and server-side whitelist enforcement
|
||||
remains a final access boundary. Replace LoginSystem only as part of the
|
||||
validated `aoc` rollout; other servers are unaffected. Old launchers without
|
||||
admission proof will be rejected after enforcement. This authenticates an
|
||||
account's permission, not the integrity of an unmodified launcher binary.
|
||||
- Application updates are a separate trust domain from Minecraft profiles.
|
||||
The only channel is `https://shacraft.ru/launcher/updates/stable.json`.
|
||||
Both release metadata and the installer need a valid Minisign signature
|
||||
under the separate updater public key embedded in `tauri.conf.json`.
|
||||
Never reuse the profile signing key, accept unsigned metadata, or allow IPC
|
||||
to select an update URL, key, version, installer argument or destination.
|
||||
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 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.
|
||||
Installation holds game/install/account permits until restart. The game
|
||||
permit lasts until the tracked Java child exits. These are process-local
|
||||
guards; another launcher process is not a cross-process lock.
|
||||
- The updater signing private key stays outside Git on the operator's local
|
||||
machine; CI receives no production key. Publish only verified packages,
|
||||
public signatures and signed feed. Updater signatures are separate from
|
||||
Windows Authenticode and macOS code signing/notarization.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -60,7 +113,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was
|
||||
removed; only verified remote manifests may drive profile mutations.
|
||||
- `operations.rs` — process-local install/account permits owned by workers.
|
||||
Launch must use the authenticated ShaCraft nickname; no settings fallback.
|
||||
The account permit covers ticket issuance through Java spawn, preventing
|
||||
local logout/account switching from racing that handoff.
|
||||
- `storage.rs` — unique same-directory atomic writes, owner-only Unix files.
|
||||
- `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider.
|
||||
- `download.rs` — shared verified-download helper (temp file, hash,
|
||||
@@ -76,8 +130,15 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
placeholder pending ShaCraft's own Azure AD app registration and
|
||||
Minecraft-API approval.
|
||||
- `shacraft_account.rs` — local ShaCraft login/registration, session and
|
||||
verified nickname-link API.
|
||||
- `launch.rs` — builds and spawns the actual `java` process.
|
||||
nickname claim/admission API; legacy link commands remain for compatibility.
|
||||
- `admission.rs` — ephemeral key generation, strict ticket response validation,
|
||||
canonical identity and child-only admission environment.
|
||||
- `launch.rs` — builds and spawns the actual `java` process; admission secrets
|
||||
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
|
||||
@@ -85,8 +146,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
- `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium
|
||||
trust domains used to install and run the game itself.
|
||||
- `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests.
|
||||
- `.github/workflows/build.yml` — main-push/manual cross-platform builds with artifacts;
|
||||
not a signed release or updater publication.
|
||||
- `.github/workflows/build.yml` — main-push/manual cross-platform CI artifacts;
|
||||
updater signing is explicitly disabled there. Local release signing and
|
||||
atomic feed publication are documented in `docs/launcher-updates.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -105,6 +167,14 @@ not prove Tauri commands work.
|
||||
|
||||
See `PLAN.md` for known gaps. Never label browser preview or unit tests as
|
||||
a successful cold game install / Microsoft OAuth / Windows/macOS beta test.
|
||||
The admission implementation has local unit/UI checks and unsigned Linux
|
||||
0.1.2 AppImage/deb packages built on Ubuntu 26.04. Older Ubuntu compatibility
|
||||
has not been tested. The backend/mod rollout is active on Aeronautics as of
|
||||
2026-09-10. Real isolated NeoForge admission tests and a production rejection
|
||||
without the mod passed; these do not certify a full production modpack join.
|
||||
The server has a required early duplicate-login guard so unauthenticated
|
||||
connections cannot evict an already-online UUID. The client mod pins the actual
|
||||
socket to `135.106.154.86:25567`. See architecture and server rollout records.
|
||||
|
||||
## Working conventions
|
||||
|
||||
@@ -114,3 +184,74 @@ a successful cold game install / Microsoft OAuth / Windows/macOS beta test.
|
||||
them. Inspect `git status` before staging.
|
||||
- Update this file and `docs/launcher-architecture.md` whenever the trust
|
||||
model, endpoint contract, storage layout, or release workflow changes.
|
||||
|
||||
|
||||
## Cross-platform release 0.1.5 (2026-09-10)
|
||||
|
||||
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||
An initial non-Linux borrow/move compilation error in updater target selection
|
||||
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||
version/CPU type, Linux package identity and every artifact signature. Public
|
||||
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||
need a manual installation of the current release. Previous releases immutable.
|
||||
|
||||
|
||||
## Admission client menu 0.1.1 (2026-09-11)
|
||||
|
||||
The signed Aeronautics payload now contains admission mod 0.1.1 at the existing
|
||||
managed path `mods/shacraft-admission-0.1.0.jar` to prevent duplicate mod IDs on
|
||||
upgrade. SHA-256: `faa9ae13cb0f2d93c03dae26ab36ae20d3fb6b66c89c09254b16808d6b183f89`.
|
||||
From the title screen (and vanilla safety acknowledgement), Multiplayer connects
|
||||
to fixed `135.106.154.86:25567`. Cancel/errors return to TitleScreen; transitions
|
||||
from other screens do not auto-connect. Client-only registration, protocol 1 and
|
||||
one-use admission remain unchanged. Running game server was not restarted.
|
||||
Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
|
||||
signed-manifest retrieval and download/repair/restoration of the admission jar
|
||||
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
|
||||
pending exact error/log; this is not a verified macOS fix or desktop UI test.
|
||||
|
||||
## Minigames integration (2026-09-13, published in 0.1.6)
|
||||
|
||||
- Native profile mapping is fixed: aeronautics → aoc; minigames → minigames.
|
||||
Both display/claim the canonical existing aoc nickname. The backend enforces
|
||||
the current shared aoc subscription and whitelist for Minigames as well.
|
||||
Ticket requests and responses remain bound to the selected server; never
|
||||
accept an aoc ticket as a Minigames ticket.
|
||||
- `fabric.rs` adds independent exact HTTPS domains `meta.fabricmc.net` and
|
||||
`maven.fabricmc.net`. It verifies profile identity/parent/main class, bounded
|
||||
metadata, portable Maven coordinates, hashes and sizes before the existing
|
||||
atomic library installer. Unknown loaders now fail manifest validation.
|
||||
- Minigames launches with a native-owned Quick Play endpoint
|
||||
`135.106.154.86:25568`. The manifest cannot choose a game destination.
|
||||
- `admission-client/` owns the small client-only Fabric 26.2 companion. Its
|
||||
configuration-phase proof uses `minigames` in the existing Ed25519 transcript,
|
||||
verifies the actual socket, canonical nickname and nonce, and signs once per
|
||||
process. Only a fresh launch can retry a consumed ticket. Java receives only
|
||||
the ephemeral ticket and private key in its child environment, never the
|
||||
website session/password. Keep server verification on Paper before world
|
||||
entry with an early duplicate UUID guard.
|
||||
- The standalone Paper admission adapter and backend remain server-project
|
||||
responsibilities. Do not put map/SMASH source into this launcher repository.
|
||||
- Production updater publication requires a separately built, monotonically
|
||||
newer launcher release and existing operator signatures. Source tests or a
|
||||
client jar alone do not update installed 0.1.5 launchers.
|
||||
|
||||
Launcher 0.1.6 was built from `799fa692` on `codex/launcher-updater`; divergent
|
||||
`main` remains unchanged. All four native CI builds, UI/native checks and 18
|
||||
publisher signature tests pass. The unchanged Fabric companion completed a
|
||||
real Minecraft 26.2 → Paper configuration handshake with a synthetic account;
|
||||
its CI artifact is byte-identical. All eight public packages and the stable
|
||||
feed were signed with the existing local updater key and verified through
|
||||
public HTTPS downloads. See `docs/release-0.1.6.md` and its committed receipt.
|
||||
This records successful release/admission verification, not a Windows/macOS
|
||||
cold install or OS signing/notarization certification.
|
||||
|
||||
@@ -17,16 +17,113 @@
|
||||
approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity.
|
||||
- [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS.
|
||||
Unit tests и web preview не заменяют эти прогоны.
|
||||
- [ ] Подписанные installer-релизы и подписанное автообновление лаунчера.
|
||||
- [ ] Windows Authenticode / macOS signing-notarization и проверка установщиков.
|
||||
- [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX.
|
||||
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
|
||||
- [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости.
|
||||
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
|
||||
загружается через фиксированный display-only API. Не имитировать данные.
|
||||
|
||||
## Вход по одноразовому разрешению: внедрение 2026-09-10
|
||||
|
||||
- [x] Новый native claim закрепляет свободный ник за аккаунтом без входа в игру.
|
||||
Старые имена резервируются backend и переносятся администратором.
|
||||
- [x] После установки Rust создаёт Ed25519 ключ через OS CSPRNG и запрашивает
|
||||
ticket на фиксированном ShaCraft API. Ник для запуска берётся из ticket;
|
||||
локальные настройки не могут его заменить.
|
||||
- [x] Ticket и PKCS#8 private key передаются только окружением дочерней Java;
|
||||
session сайта остаётся в native. В argv, argfile, settings, логах и IPC
|
||||
секретов нет. Account permit держится до spawn.
|
||||
- [x] Ошибки и результаты привязки показаны в диалоге; двойной клик не создаёт
|
||||
параллельных запросов, старого polling нет.
|
||||
- [x] Локально пройдены 64 Rust-теста (5 live-тестов пропущены), 22 UI-теста,
|
||||
TypeScript/Vite и сборка Linux x86-64 `tauri:build -- --no-bundle`.
|
||||
Шесть браузерных сценариев используют только замоканный Tauri IPC.
|
||||
- [x] Собраны неподписанные AppImage и deb версии 0.1.2 на Ubuntu 26.04;
|
||||
проверены извлечение AppImage и metadata deb. Работа на старых Ubuntu и
|
||||
установка пакетов на чистой машине этим не подтверждены.
|
||||
- [x] Выложены backend, подписанный клиентский мод и серверный мод Aeronautics;
|
||||
сервер перезапущен и healthy. Whitelist сохранён; LoginSystem заменён только
|
||||
на `aoc`. Пакеты Linux 0.1.2 опубликованы на сайте, AppImage запущен локально.
|
||||
- [x] Настоящий изолированный NeoForge: новый ticket допускает в мир, отсутствие
|
||||
мода/билета и повтор билета отклоняются. Старый LoginSystem на клиенте не мешает.
|
||||
Подключение дубликата без авторизации не выбивает уже играющего владельца.
|
||||
- [x] На публичном сервере подключение без мода отклонено до входа в мир;
|
||||
HTTPS verifier из контейнера, подпись manifest и SHA-256 мода проверены.
|
||||
Backend Docker: 404 tests + 48 subtests, включая истечение, отзыв session,
|
||||
чужую identity, whitelist, резервирование имён и атомарное погашение.
|
||||
- [x] Полный вход в production Aeronautics через установленный лаунчер
|
||||
подтверждён пользователем 2026-09-10: «Присоединился!». Это пользовательское
|
||||
подтверждение, а не автоматизированный cold-install тест.
|
||||
- [ ] Проверить cold install и этот протокол на Windows/macOS, выпустить
|
||||
подписанные пакеты. Локальная Linux-сборка не подтверждает эти платформы.
|
||||
- [ ] Удобное переподключение: сейчас использованный или истёкший ticket
|
||||
требует нового запуска игры из лаунчера; автоматического обновления нет.
|
||||
|
||||
## Самообновление лаунчера 0.1.3: реализация 2026-09-10
|
||||
|
||||
- [x] Отдельный канал обновления приложения на фиксированном HTTPS endpoint.
|
||||
Выделенный публичный ключ проверяет подпись metadata и пакета; версия,
|
||||
заметки и URL связаны подписью. Redirect, downgrade и произвольные пути
|
||||
из webview запрещены; размеры metadata и загрузки ограничены.
|
||||
- [x] Vendored Tauri updater 2.11.0 принимает уже проверенный JSON через
|
||||
`check_metadata` без второго HTTP-запроса. На Linux AppImage заменяется
|
||||
атомарно через временный файл в том же каталоге с проверкой подписи и fsync.
|
||||
- [x] Проверка при старте без автоматической установки; доступны уведомление
|
||||
о новой версии, ручная проверка, заметки, прогресс, ошибки, повторная попытка
|
||||
и явный перезапуск. Сбой проверки не блокирует установленный лаунчер.
|
||||
- [x] Native guards исключают обновление во время игры и конфликтующих
|
||||
операций. На время установки и до перезапуска заблокированы запуск игры,
|
||||
ремонт сборки и изменения аккаунта. Для deb и development binary показано
|
||||
сообщение об установке вручную; версия 0.1.2 требует первого ручного обновления.
|
||||
- [x] Пройдены 76 Rust-тестов, 28 UI-тестов и 11 тестов publisher;
|
||||
TypeScript/Vite успешно собраны. Девять браузерных сценариев с mock Tauri IPC
|
||||
проверяют обновление, ошибки, повтор, блокировки и восстановление состояния;
|
||||
внешняя сеть и реальные аккаунты в этих сценариях не используются.
|
||||
- [x] Живой native-прогон скачал 0.1.3 с production HTTPS: повреждение
|
||||
отклонено без изменения старого файла, подлинный пакет атомарно заменил
|
||||
временную копию 0.1.2. Исходный AppImage сохранён, хеши проверены.
|
||||
- [x] Опубликованы Linux 0.1.3 и подписанный stable feed; HTTPS 200,
|
||||
`Cache-Control: no-store`, подписи и хеши проверены. Ссылка на сайте обновлена.
|
||||
AppImage установлен в `~/Applications`, добавлен ярлык и проверен запуск.
|
||||
- [ ] Полный GUI-цикл «Обновить → Перезапустить» проверить на следующем
|
||||
релизе; текущий прогон проверяет native-установку и запуск пакета отдельно.
|
||||
- [ ] Проверить установку и самообновление 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`. Важные следующие шаги:
|
||||
одноразовое подтверждение ника внутри игры (NoGravity не связывает игрока
|
||||
с веб-запросом), enforcement реферальных правил и очередь повторов whitelist.
|
||||
Не менять этот протокол незаметно в клиентском рефакторинге.
|
||||
Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны
|
||||
атомарная одноразовая проверка ticket, привязка к текущим session/аккаунту/нику,
|
||||
проверка до входа в мир и сохранение whitelist как серверного ограничения.
|
||||
Старые игровые имена нельзя отдавать первому зарегистрировавшемуся; их
|
||||
резервирование и назначение аккаунтам — отдельный этап миграции. Старый
|
||||
NoGravity challenge-поток должен быть закрыт при включённом admission, чтобы
|
||||
он не обходил резервирование имени. Остальные серверные задачи включают
|
||||
enforcement реферальных правил и очередь повторов whitelist. Не менять
|
||||
протокол незаметно в клиентском рефакторинге и не считать ticket доказательством
|
||||
неизменённости клиентского бинарника.
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
|
||||
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
|
||||
|
||||
Реализованы подписанная синхронизация Aeronautics, проверка/восстановление
|
||||
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
|
||||
NeoForge, настройки памяти и ника, обработка установки/запуска/выхода.
|
||||
Реализованы отдельные профили Aeronautics (Minecraft 1.21.1, NeoForge, Java 21)
|
||||
и Minigames (Minecraft 26.2, Fabric, Java 25), подписанная синхронизация файлов,
|
||||
проверка/восстановление модов и конфигурации, установка игры и Java, настройки
|
||||
памяти, обработка установки/запуска/выхода. Minigames опубликован в 0.1.6;
|
||||
проверки и результаты публикации — в [записи релиза](docs/release-0.1.6.md).
|
||||
|
||||
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
|
||||
берётся только из подтверждённой привязки Aeronautics, а не из редактируемых
|
||||
локальных настроек. Пароли не сохраняются; сессию можно отозвать.
|
||||
общий для обоих профилей и берётся только из подтверждённой привязки `aoc`.
|
||||
Тикет входа привязан к выбранному серверу; локальные настройки не выбирают личность. Пароли не сохраняются; сессию можно отозвать.
|
||||
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
|
||||
сценарием запуска; для его активации потребуются client ID и API approval.
|
||||
|
||||
@@ -37,14 +39,30 @@ cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на
|
||||
push/PR; workflow на main-push/ручном запуске собирает Windows x64, Linux x64, macOS Intel
|
||||
и Apple Silicon и сохраняет артефакты. Подпись релиза/автообновления ещё впереди.
|
||||
и Apple Silicon и сохраняет неподписанные артефакты. Релизный оператор
|
||||
подписывает проверенные пакеты и metadata локальным ключом; CI его не получает.
|
||||
Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
||||
|
||||
## Обновление лаунчера
|
||||
|
||||
С версии 0.1.3 настройки содержат проверку обновлений, установку с прогрессом
|
||||
и перезапуск. Лаунчер также проверяет новые версии при старте, но устанавливает
|
||||
их только по кнопке. Подписи пакета и сведений о версии обязательны.
|
||||
Закройте запущенный Minecraft перед установкой обновления.
|
||||
|
||||
В Linux обновляются AppImage и установленный deb (с версии 0.1.4).
|
||||
Для deb система запрашивает права администратора; пароль не передаётся лаунчеру.
|
||||
Deb 0.1.3 и ниже нужно один раз обновить вручную до 0.1.4. Dev-бинарник
|
||||
обновляется вручную. С версии 0.1.2 нужен ручной переход на новый AppImage.
|
||||
Пакеты Windows/macOS 0.1.5 опубликованы. Проверка реальной установки на каждой
|
||||
платформе остаётся отдельной от сборки и автоматических тестов.
|
||||
|
||||
## Навигация
|
||||
|
||||
- [Архитектура](docs/launcher-architecture.md) — компоненты, данные, IPC.
|
||||
- [Trust boundaries](docs/game-trust-boundary.md) — доверенные источники игры.
|
||||
- [Manifest](docs/manifest-v1.md) — подписанный контракт модпака.
|
||||
- [Обновления](docs/launcher-updates.md) — подпись, публикация и восстановление.
|
||||
- [PLAN.md](PLAN.md) — ограничения и следующие шаги.
|
||||
- [AGENTS.md](AGENTS.md) — инструкции для следующего разработчика/агента.
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ShaCraft Minigames admission client
|
||||
|
||||
Client-only Fabric companion for Minecraft 26.2, Java 25, Fabric Loader 0.19.5
|
||||
and Fabric API 0.160.0+26.2. This is account admission, not an anti-cheat or a
|
||||
proof that the original launcher binary is running.
|
||||
|
||||
Build with Java 25: `./gradlew test build --no-daemon`.
|
||||
Output: `build/libs/shacraft-admission-client-0.1.0.jar`.
|
||||
Publish that jar and the pinned Fabric API jar in the signed Minigames profile.
|
||||
|
||||
The native launcher supplies `SHACRAFT_ADMISSION_TICKET` and
|
||||
`SHACRAFT_ADMISSION_PRIVATE_KEY` only to the final Java child environment.
|
||||
The client accepts a CONFIGURATION payload on `shacraft_admission:challenge`
|
||||
with three Minecraft UTF strings: server ID (16), nickname (16), nonce (43).
|
||||
It verifies server `minigames`, the exact current game nickname and actual
|
||||
socket `135.106.154.86:25568`, then signs once with the ephemeral Ed25519 key.
|
||||
The response on `shacraft_admission:proof` contains ticket (43) and standard
|
||||
Base64 signature (88). The transcript has no final newline:
|
||||
|
||||
```
|
||||
shacraft-admission-v1
|
||||
{ticket_id}
|
||||
minigames
|
||||
{mc_username}
|
||||
{nonce}
|
||||
```
|
||||
|
||||
The private key and website session never go onto the Minecraft wire. Errors
|
||||
are redacted. A fresh game launch is needed for another connection after a
|
||||
proof has been sent. `SHACRAFT_ADMISSION_ALLOW_LOOPBACK=1` additionally permits
|
||||
literal loopback sockets for isolated tests; normal releases do not set it.
|
||||
Paper must fail closed before world entry and reject unauthenticated duplicate
|
||||
UUIDs before the vanilla duplicate-player eviction. The backend checks the
|
||||
current shared aoc account access and atomically redeems the server-bound ticket.
|
||||
|
||||
Three unit tests cover exact signature binding, invalid/cross-server fields
|
||||
and socket allowlisting. The unchanged production companion also passed actual Minecraft 26.2
|
||||
configuration negotiation and entered a local Paper lobby with a synthetic
|
||||
backend ticket; see [receipt](../docs/verification/minigames-fabric-2026-09-13.json).
|
||||
The public server and other platforms still require their own rollout checks.
|
||||
|
||||
The client explicitly advertises its single challenge receiver with vanilla
|
||||
`minecraft:register` at the start of configuration. Fabric normally waits for
|
||||
the server's registration first, while Paper gates plugin sends on that client
|
||||
advertisement. This bootstrap uses the pinned Fabric API's RegistrationPayload;
|
||||
update it and rerun live negotiation checks when upgrading Fabric API. It is
|
||||
queued after INIT so vanilla has switched outbound protocol to CONFIGURATION.
|
||||
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
id 'net.fabricmc.fabric-loom' version "${loom_version}"
|
||||
}
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
loom {
|
||||
splitEnvironmentSourceSets()
|
||||
mods {
|
||||
'shacraft_admission' {
|
||||
sourceSet sourceSets.main
|
||||
sourceSet sourceSets.client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
minecraft "com.mojang:minecraft:${project.minecraft_version}"
|
||||
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
|
||||
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
|
||||
testImplementation platform('org.junit:junit-bom:5.12.2')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
processResources {
|
||||
inputs.property 'version', project.version
|
||||
filesMatching('fabric.mod.json') { expand version: project.version }
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach { options.release = 25 }
|
||||
java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() }
|
||||
test { useJUnitPlatform() }
|
||||
|
||||
// Pure HTTP/validation tests use the same client implementation without launching Minecraft.
|
||||
sourceSets.test.compileClasspath += sourceSets.client.output
|
||||
sourceSets.test.runtimeClasspath += sourceSets.client.output
|
||||
@@ -0,0 +1,9 @@
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configuration-cache=false
|
||||
minecraft_version=26.2
|
||||
loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.2
|
||||
version=0.1.0
|
||||
group=ru.shacraft
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
|
||||
networkTimeout=30000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1,8 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url = 'https://maven.fabricmc.net/' }
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
rootProject.name = 'shacraft-admission-client'
|
||||
@@ -0,0 +1,57 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationConnectionEvents;
|
||||
import net.fabricmc.fabric.impl.networking.RegistrationPayload;
|
||||
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
|
||||
import java.util.List;
|
||||
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** The account session remains in the native launcher, never in Minecraft. */
|
||||
public final class ClientAdmission implements ClientModInitializer {
|
||||
private static final AtomicBoolean USED = new AtomicBoolean();
|
||||
|
||||
@Override public void onInitializeClient() {
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(AdmissionPayloads.Challenge.TYPE, AdmissionPayloads.Challenge.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(AdmissionPayloads.Proof.TYPE, AdmissionPayloads.Proof.CODEC);
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AdmissionPayloads.Challenge.TYPE, ClientAdmission::challenge);
|
||||
// Paper waits for vanilla channel advertisement, while Fabric normally waits
|
||||
// for the server's registration first. Bootstrap our one fixed receiver.
|
||||
// INIT runs in the listener constructor; schedule() queues until after vanilla
|
||||
// has switched the outbound protocol from LOGIN to CONFIGURATION.
|
||||
ClientConfigurationConnectionEvents.INIT.register((listener, client) -> client.schedule(() ->
|
||||
listener.send(new ServerboundCustomPayloadPacket(new RegistrationPayload(
|
||||
RegistrationPayload.REGISTER, List.of(AdmissionPayloads.Challenge.TYPE.id()))))));
|
||||
}
|
||||
|
||||
private static void challenge(AdmissionPayloads.Challenge challenge, ClientConfigurationNetworking.Context context) {
|
||||
var connection = context.packetContext().orElseThrow(net.fabricmc.fabric.api.networking.v1.context.PacketContext.CONNECTION);
|
||||
boolean loopback = "1".equals(System.getenv("SHACRAFT_ADMISSION_ALLOW_LOOPBACK"));
|
||||
if (!(connection.getRemoteAddress() instanceof InetSocketAddress remote)
|
||||
|| remote.getAddress() == null
|
||||
|| !AdmissionProof.allowedTarget(remote.getAddress().getHostAddress(), remote.getPort(), loopback)
|
||||
|| !AdmissionProof.SERVER_ID.equals(challenge.serverId())
|
||||
|| !context.client().getUser().getName().equals(challenge.nickname())
|
||||
|| !AdmissionProof.validOpaque(challenge.nonce())) {
|
||||
deny(context); return;
|
||||
}
|
||||
String ticket = System.getenv("SHACRAFT_ADMISSION_TICKET");
|
||||
String privateKey = System.getenv("SHACRAFT_ADMISSION_PRIVATE_KEY");
|
||||
if (!AdmissionProof.validOpaque(ticket) || !USED.compareAndSet(false, true)) {
|
||||
deny(context); return;
|
||||
}
|
||||
try {
|
||||
String signature = AdmissionProof.sign(privateKey, ticket, challenge.serverId(), challenge.nickname(), challenge.nonce());
|
||||
context.responseSender().sendPacket(new AdmissionPayloads.Proof(ticket, signature));
|
||||
} catch (Exception invalidKey) { deny(context); }
|
||||
}
|
||||
|
||||
private static void deny(ClientConfigurationNetworking.Context context) {
|
||||
context.responseSender().disconnect(Component.literal(
|
||||
"Не удалось подтвердить вход ShaCraft. Закройте игру и запустите её заново через ShaCraft Launcher."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
public final class AdmissionPayloads {
|
||||
private AdmissionPayloads() {}
|
||||
|
||||
public record Challenge(String serverId, String nickname, String nonce) implements CustomPacketPayload {
|
||||
public static final Type<Challenge> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "challenge"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Challenge> CODEC = StreamCodec.of(
|
||||
(buffer, value) -> { buffer.writeUtf(value.serverId, 16); buffer.writeUtf(value.nickname, 16); buffer.writeUtf(value.nonce, 43); },
|
||||
buffer -> new Challenge(buffer.readUtf(16), buffer.readUtf(16), buffer.readUtf(43)));
|
||||
@Override public Type<Challenge> type() { return TYPE; }
|
||||
@Override public String toString() { return "AdmissionChallenge[redacted]"; }
|
||||
}
|
||||
|
||||
public record Proof(String ticket, String signature) implements CustomPacketPayload {
|
||||
public static final Type<Proof> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "proof"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Proof> CODEC = StreamCodec.of(
|
||||
(buffer, value) -> { buffer.writeUtf(value.ticket, 43); buffer.writeUtf(value.signature, 88); },
|
||||
buffer -> new Proof(buffer.readUtf(43), buffer.readUtf(88)));
|
||||
@Override public Type<Proof> type() { return TYPE; }
|
||||
@Override public String toString() { return "AdmissionProof[redacted]"; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** The only client credential is an ephemeral private key supplied by the launcher. */
|
||||
public final class AdmissionProof {
|
||||
public static final String SERVER_ID = "minigames";
|
||||
private static final Pattern OPAQUE = Pattern.compile("[A-Za-z0-9_-]{43}");
|
||||
private static final Pattern NICKNAME = Pattern.compile("[A-Za-z0-9_]{3,16}");
|
||||
|
||||
private AdmissionProof() {}
|
||||
|
||||
public static boolean validOpaque(String value) {
|
||||
return value != null && OPAQUE.matcher(value).matches();
|
||||
}
|
||||
|
||||
public static byte[] transcript(String ticket, String serverId, String nickname, String nonce) {
|
||||
if (!validOpaque(ticket) || !validOpaque(nonce) || !SERVER_ID.equals(serverId)
|
||||
|| nickname == null || !NICKNAME.matcher(nickname).matches()) {
|
||||
throw new IllegalArgumentException("Invalid admission challenge");
|
||||
}
|
||||
return ("shacraft-admission-v1\n" + ticket + "\n" + serverId + "\n"
|
||||
+ nickname + "\n" + nonce).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static String sign(String encodedPrivateKey, String ticket, String serverId,
|
||||
String nickname, String nonce) throws Exception {
|
||||
if (encodedPrivateKey == null || encodedPrivateKey.length() > 256) {
|
||||
throw new IllegalArgumentException("Missing admission key");
|
||||
}
|
||||
byte[] encoded = Base64.getDecoder().decode(encodedPrivateKey);
|
||||
try {
|
||||
PrivateKey key = KeyFactory.getInstance("Ed25519")
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(encoded));
|
||||
Signature signer = Signature.getInstance("Ed25519");
|
||||
signer.initSign(key);
|
||||
signer.update(transcript(ticket, serverId, nickname, nonce));
|
||||
return Base64.getEncoder().encodeToString(signer.sign());
|
||||
} finally {
|
||||
java.util.Arrays.fill(encoded, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean validSignature(String value) {
|
||||
if (value == null || value.length() != 88) return false;
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(value);
|
||||
return decoded.length == 64 && Base64.getEncoder().encodeToString(decoded).equals(value);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean allowedTarget(String host, int port, boolean allowLoopback) {
|
||||
if (host == null) return false;
|
||||
if (allowLoopback && (host.equals("127.0.0.1") || host.equals("::1") || host.equals("[::1]") || host.equals("0:0:0:0:0:0:0:1"))) {
|
||||
return port > 0 && port <= 65535;
|
||||
}
|
||||
return port == 25568 && (host.equalsIgnoreCase("shacraft.ru") || host.equals("135.106.154.86"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "shacraft_admission",
|
||||
"version": "${version}",
|
||||
"name": "ShaCraft Minigames Admission",
|
||||
"description": "Account-bound admission to ShaCraft Minigames.",
|
||||
"environment": "client",
|
||||
"entrypoints": { "client": ["ru.shacraft.admission.ClientAdmission"] },
|
||||
"depends": { "fabricloader": ">=0.19.5", "minecraft": "26.2", "java": ">=25", "fabric-networking-api-v1": "*" }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.Signature;
|
||||
import java.util.Base64;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdmissionProofTest {
|
||||
private static final String TICKET = "A".repeat(43), NONCE = "B".repeat(43);
|
||||
@Test void signsExactServerBoundTranscript() throws Exception {
|
||||
var pair=KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||
String signed=AdmissionProof.sign(Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded()), TICKET,"minigames","Pilot_1",NONCE);
|
||||
var verifier=Signature.getInstance("Ed25519"); verifier.initVerify(pair.getPublic());
|
||||
verifier.update(("shacraft-admission-v1\n"+TICKET+"\nminigames\nPilot_1\n"+NONCE).getBytes(StandardCharsets.UTF_8));
|
||||
assertTrue(verifier.verify(Base64.getDecoder().decode(signed)));
|
||||
verifier.update(AdmissionProof.transcript(TICKET,"minigames","Other",NONCE));
|
||||
assertFalse(verifier.verify(Base64.getDecoder().decode(signed)));
|
||||
}
|
||||
@Test void rejectsCrossServerAndMalformedFields() {
|
||||
for (String[] v:new String[][]{{TICKET,"aoc","Pilot",NONCE},{TICKET,"minigames","Bad\nName",NONCE},{"bad","minigames","Pilot",NONCE},{TICKET,"minigames","Pilot","bad"}})
|
||||
assertThrows(IllegalArgumentException.class,()->AdmissionProof.transcript(v[0],v[1],v[2],v[3]));
|
||||
}
|
||||
@Test void trustsOnlyMinigamesSocketAndExplicitLocalTests() {
|
||||
assertTrue(AdmissionProof.allowedTarget("135.106.154.86",25568,false));
|
||||
assertFalse(AdmissionProof.allowedTarget("135.106.154.86",25567,false));
|
||||
assertFalse(AdmissionProof.allowedTarget("127.0.0.1",25568,false));
|
||||
assertTrue(AdmissionProof.allowedTarget("127.0.0.1",25570,true));
|
||||
assertFalse(AdmissionProof.allowedTarget("attacker.invalid",25568,true));
|
||||
}
|
||||
}
|
||||
@@ -90,3 +90,21 @@ URL — cannot redirect a download to an attacker-controlled host in any of
|
||||
these domains. When adding a new game-related download, verify its host is
|
||||
one of the ones above (or add a new hardcoded constant following the same
|
||||
pattern) rather than accepting a URL from anywhere else.
|
||||
|
||||
## 5. Fabric (`fabric.rs`, Minigames)
|
||||
|
||||
Metadata comes only from `https://meta.fabricmc.net/v2/versions/loader/` for
|
||||
manifest-selected, validated version identifiers. Profile ID, parent and
|
||||
KnotClient main class must match. At most 512 KiB metadata and 32 libraries
|
||||
are accepted. Library URLs are constructed only below
|
||||
`https://maven.fabricmc.net/` from portable three-part Maven coordinates;
|
||||
other metadata origins are rejected. Libraries require a 40-hex SHA-1 and a
|
||||
positive size up to 64 MiB. When Fabric metadata omits either for its loader
|
||||
jar, the fixed Maven's `.sha1` sidecar and HEAD provide them. Existing verified
|
||||
downloads perform the hash/size check and atomic rename. The ShaCraft signed
|
||||
manifest cannot select Fabric metadata URLs, repositories or launch targets.
|
||||
|
||||
Minecraft 26.2's official version metadata requires Java 25. `runtime.rs`
|
||||
already provisions a separate Adoptium Java 25 runtime without changing the
|
||||
Aeronautics Java 21 runtime. Client companion mods and Fabric API are separately
|
||||
approved ShaCraft managed files in the signed Minigames profile.
|
||||
|
||||
+271
-17
@@ -2,24 +2,37 @@
|
||||
|
||||
## Current capability
|
||||
|
||||
The launcher persists local settings, synchronises Aeronautics mod/config
|
||||
The launcher persists local settings, synchronises profile mod/config
|
||||
files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
|
||||
NeoForge version the manifest specifies, and launches the game. A player
|
||||
signs in with the same local ShaCraft account used on the website. The game
|
||||
identity is derived only from that account's verified Aeronautics nickname;
|
||||
the legacy editable nickname setting is not trusted at launch.
|
||||
signs in with the same local ShaCraft account used on the website. The current
|
||||
admission implementation claims a free nickname for that account and requests
|
||||
a one-use permission immediately before launching Java. The game identity
|
||||
comes only from the canonical Aeronautics nickname in that permission; the
|
||||
legacy editable nickname setting is not trusted at launch.
|
||||
|
||||
The backend and admission mod were deployed to Aeronautics on 2026-09-10;
|
||||
the server is healthy with whitelist enforcement retained. Real isolated
|
||||
NeoForge connections verified successful admission, absent/replayed proof
|
||||
rejection and protection of an online player from duplicate login. A public
|
||||
production connection without the mod was rejected before world entry.
|
||||
This does not certify a full cold installation or Windows/macOS operation.
|
||||
|
||||
The interface also shows a live Aeronautics player count from the fixed,
|
||||
read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
|
||||
the result never controls files, versions, URLs, or the launch command.
|
||||
|
||||
Not yet implemented: a user-selectable profile directory, a "reset managed
|
||||
files only" recovery action, and signed cross-platform release builds of the
|
||||
launcher itself. Do not represent these as completed in UI or release notes.
|
||||
Version 0.1.3 adds signed application updates, separate from modpack sync.
|
||||
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 0.1.5 packages are published; actual desktop installation tests remain pending.
|
||||
Not yet implemented: a user-selectable profile directory and a "reset managed
|
||||
files only" recovery action. OS code signing/notarization is separate from the
|
||||
updater signatures and is not certified by this implementation.
|
||||
|
||||
## Data flow
|
||||
|
||||
Two independent pipelines feed one launch:
|
||||
Managed files, game installation and account admission meet at Java spawn:
|
||||
|
||||
```text
|
||||
ShaCraft manifest (mods/config + which MC/loader/Java version to use)
|
||||
@@ -33,9 +46,14 @@ Game itself (never controlled by the manifest above)
|
||||
-> NeoForge's own installer, run headlessly (neoforge.rs)
|
||||
-> generic inheritsFrom merge of the two version JSONs (mojang.rs)
|
||||
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
|
||||
-> verified ShaCraft account link (shacraft_account.rs)
|
||||
-> deterministic offline UUID for the linked nickname (session.rs)
|
||||
-> java process spawned with the merged classpath/args (launch.rs)
|
||||
|
||||
Admission (fixed ShaCraft account API; never controlled by a manifest)
|
||||
native website session -> direct free-nickname claim or admin migration
|
||||
-> after installation: OS CSPRNG -> ephemeral Ed25519 key (admission.rs)
|
||||
-> public key + bearer session -> one-use ticket for canonical aoc nickname
|
||||
-> deterministic offline UUID for that nickname (session.rs)
|
||||
-> Java with merged classpath/args + child-only ticket/private-key environment
|
||||
-> client mod signs server challenge; server validates before world entry
|
||||
```
|
||||
|
||||
Profiles (ShaCraft-managed mods/config, and the player's own worlds/
|
||||
@@ -45,7 +63,8 @@ install (versions/libraries/assets/runtime, reused across profiles that
|
||||
target the same Minecraft version) lives at `app_data_dir()/game`. Settings
|
||||
live at `app_data_dir()/settings.json`, and the revocable ShaCraft session at
|
||||
`app_data_dir()/shacraft-session` (mode 600 on Unix). Passwords are never
|
||||
written to disk. None of these should be assumed to
|
||||
written to disk. The admission private key and ticket are ephemeral native
|
||||
values and are not persisted. None of these directories should be assumed to
|
||||
be the system `.minecraft` directory.
|
||||
|
||||
## Aeronautics contract
|
||||
@@ -60,18 +79,81 @@ be the system `.minecraft` directory.
|
||||
- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
|
||||
`cdn.shacraft.ru`.
|
||||
- Account API origin: fixed `https://shacraft.ru`; redirects are rejected.
|
||||
- Launch identity: the most recently verified `aoc` nickname returned by the
|
||||
authenticated account API. Local nickname edits cannot select an identity.
|
||||
- Launch identity: the canonical `aoc` nickname returned with the admission
|
||||
ticket. Local nickname edits cannot select an identity.
|
||||
|
||||
## Admission contract (implementation: 2026-09-10)
|
||||
|
||||
Both endpoints use the fixed account API origin, HTTPS without redirects and
|
||||
the native website session as bearer authentication. The session is never
|
||||
passed to the client mod or Java process.
|
||||
|
||||
`POST /api/launcher/v2/admission/nickname` accepts
|
||||
`{server_id: "aoc", mc_username: "Chosen_Name"}` and returns the existing
|
||||
account shape `{username, links}`. The backend atomically assigns a free name
|
||||
to that account. Existing player names remain reserved and require explicit
|
||||
administrator migration. The launcher no longer asks the player to enter the
|
||||
game to complete this claim. Legacy challenge IPC remains available to older
|
||||
flows, but an admission-enabled backend must block those legacy endpoints
|
||||
from bypassing reserved-name ownership.
|
||||
|
||||
After installation completes, the native launcher generates a fresh Ed25519
|
||||
key using the OS CSPRNG and calls
|
||||
`POST /api/launcher/v2/admission/tickets` with
|
||||
`{server_id: "aoc", public_key: "<standard base64 raw 32-byte public key>"}`.
|
||||
The response is `{ticket_id, mc_username, server_id, expires_in_seconds}`.
|
||||
The native boundary requires a canonical 43-character base64url ticket ID,
|
||||
an ASCII Minecraft nickname of 3–16 letters/digits/underscores, server `aoc`
|
||||
and a positive lifetime of at most 600 seconds. The backend checks the current
|
||||
account session, bound nickname and server access before issuing it.
|
||||
|
||||
`admission.rs` has no secret-bearing `Debug` or `Serialize` implementation.
|
||||
Only the final Java child's environment receives:
|
||||
|
||||
- `SHACRAFT_ADMISSION_TICKET`: the one-use ticket ID.
|
||||
- `SHACRAFT_ADMISSION_PRIVATE_KEY`: standard base64 of the Ed25519 PKCS#8
|
||||
private-key-only DER representation accepted by Java's `KeyFactory`.
|
||||
|
||||
No global environment mutation, webview/IPC payload, argument substitution,
|
||||
JVM argfile, settings file or log stores these values. The account operation
|
||||
permit remains held from issuance through spawn so local account switching
|
||||
or logout cannot race the handoff. Missing, disabled or invalid admission
|
||||
responses fail the launch with a visible error; there is no legacy-name or
|
||||
unsigned fallback. In this first version, a consumed or expired ticket needs
|
||||
a fresh game launch from the launcher; transparent in-game reconnect is not
|
||||
implemented.
|
||||
|
||||
The client mod proves possession of the ephemeral private key by signing the
|
||||
server's challenge. The server mod gates world entry on successful backend
|
||||
verification, including one-use consumption and current account/link/access
|
||||
checks. Whitelist enforcement is retained. The 2026-09-10 Aeronautics rollout
|
||||
replaced its separate LoginSystem `/register` and `/login` flow; other servers
|
||||
keep their existing authentication. Old launchers without admission proof
|
||||
cannot join Aeronautics. A required server-only Mixin rejects a duplicate
|
||||
online UUID before vanilla can disconnect the existing player. The client pins
|
||||
the actual game socket to `135.106.154.86:25567`; changing that address requires
|
||||
an explicit mod update. Server source and rollout records live in
|
||||
`/root/shacraft/services/admission-mod` and `docs/admission-2026-09-10.md` on
|
||||
the ShaCraft host.
|
||||
|
||||
This protocol prevents entry without the account's current permission. It
|
||||
does not attest that an original launcher or game binary is unmodified:
|
||||
software running as the same user can read its own process environment, and
|
||||
a compatible client can implement the protocol. Never replace account-bound
|
||||
proof with a shared key embedded in distributed binaries.
|
||||
|
||||
## Planned but not implemented
|
||||
|
||||
1. User-selectable profile directory and structured launcher logs.
|
||||
2. "Reset managed files only" recovery action that doesn't touch player
|
||||
worlds/screenshots/resourcepacks.
|
||||
3. Signed, cross-platform release builds of the launcher itself.
|
||||
3. Windows Authenticode/macOS signing-notarization and cross-platform release
|
||||
installation testing.
|
||||
4. Cancellation, structured logs and a full cold-install/recovery beta on
|
||||
every target OS. Install progress reports bytes or installer work counts
|
||||
depending on the stage; these units are not interchangeable.
|
||||
5. Transparent reconnect after the admission ticket has been consumed or
|
||||
expired. The current implementation requires a new launch.
|
||||
|
||||
Do not represent these as completed features in UI or release notes.
|
||||
|
||||
@@ -96,6 +178,54 @@ URLs and redirects. Manifest profile identity, size, signature, portable
|
||||
paths and existing symlinks are checked before managed file writes.
|
||||
Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox.
|
||||
|
||||
## Signed application updates (0.1.3)
|
||||
|
||||
`updater.rs` accepts only the fixed HTTPS feed
|
||||
`https://shacraft.ru/launcher/updates/stable.json`. A dedicated embedded Tauri
|
||||
public key authenticates both the metadata payload and the selected package.
|
||||
The signed metadata binds the plain stable version, release notes, date and
|
||||
platform URLs. Artifact URLs are confined to the matching version directory
|
||||
under `https://shacraft.ru/downloads/shacraft-launcher/`. The IPC never accepts
|
||||
a URL, key, destination path or replacement executable from the webview.
|
||||
|
||||
Metadata is downloaded once, with a 192 KiB envelope/64 KiB payload bound;
|
||||
packages are capped at 256 MiB. Redirects and version downgrades are rejected.
|
||||
The small vendored Tauri 2.11.0 `check_metadata` patch constructs its update
|
||||
object without another HTTP request. Linux AppImage installation uses a
|
||||
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.
|
||||
Startup checks never silently install; settings expose check, install, progress,
|
||||
errors and restart. A failed check does not prevent using the installed version.
|
||||
|
||||
The private updater key stays on the operator's computer. Normal CI builds are
|
||||
explicitly unsigned; reviewed release artifacts and metadata are signed locally
|
||||
and published only after signature/hash verification. The Caddy feed route uses
|
||||
`Cache-Control: no-store`. See [launcher-updates.md](launcher-updates.md) for
|
||||
the envelope contract, publisher commands and recovery constraints.
|
||||
|
||||
## Verification and distribution
|
||||
|
||||
`npm test` covers asynchronous helpers and state transitions;
|
||||
@@ -103,5 +233,129 @@ Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox.
|
||||
covers native policy and storage. Push/PR CI repeats checks on Linux.
|
||||
The package workflow runs on main pushes or manually and builds Windows
|
||||
x64, Linux x64 and both macOS architectures with named artifacts.
|
||||
Packages are not yet signed release artifacts. Native cold-install and
|
||||
launch tests are required before calling a platform release-ready.
|
||||
CI packages are unsigned build artifacts. Signed updater publication is a
|
||||
separate local operator step. Native cold-install and launch tests are required
|
||||
before calling a platform release-ready.
|
||||
|
||||
The local admission checkpoint passed 64 Rust tests (5 live tests ignored),
|
||||
22 UI unit tests, TypeScript/Vite build and a Linux x86-64 release build with
|
||||
`npm run tauri:build -- --no-bundle`. Six browser scenarios with mocked Tauri
|
||||
IPC covered invalid nicknames, deleted sessions, reserved-name errors,
|
||||
successful claims, duplicate clicks and a session revoked during a claim.
|
||||
They also checked modal feedback, Escape preserving the settings drawer and
|
||||
the absence of legacy link polling. These checks used no production account
|
||||
or real Minecraft connection. The Linux output is a dynamically linked
|
||||
binary, not evidence of Windows/macOS support testing.
|
||||
|
||||
Version 0.1.2 also produced unsigned Linux amd64 AppImage and deb packages with
|
||||
`npm run tauri:build -- --bundles appimage,deb`. AppImage extraction and the
|
||||
deb's version/architecture metadata were checked without running the app or
|
||||
installing the package. The build host was Ubuntu 26.04; do not claim support
|
||||
for older Ubuntu releases from this build. For local AppImage packaging,
|
||||
linuxdeploy's GTK plugin needs `librsvg-2.0.pc` from the matching `librsvg2-dev`
|
||||
package. Extracting that package into a temporary build directory and setting
|
||||
`PKG_CONFIG_PATH` supplied the missing metadata without changing host packages.
|
||||
|
||||
The 0.1.3 updater checkpoint passed 76 native tests (6 live tests ignored),
|
||||
28 UI tests, 11 publisher tests with real minisign and TypeScript/Vite build.
|
||||
Nine browser scenarios used mocked IPC. The signed Linux AppImage/deb were
|
||||
published on 2026-09-10 with a signed stable feed; feed bytes, signatures and
|
||||
public HTTPS responses were verified. A separately invoked live native test
|
||||
downloaded the production release, rejected corrupted bytes without changing
|
||||
the old file, atomically updated a temporary copy of 0.1.2 and compared hashes.
|
||||
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.
|
||||
|
||||
|
||||
## Cross-platform release 0.1.5 (2026-09-10)
|
||||
|
||||
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||
An initial non-Linux borrow/move compilation error in updater target selection
|
||||
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||
version/CPU type, Linux package identity and every artifact signature. Public
|
||||
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||
need a manual installation of the current release. Previous releases immutable.
|
||||
|
||||
The live native updater test passed against the published 0.1.5 feed: verified download, corrupted-byte rejection and replacement of only a temporary AppImage copy. The user-installed launcher was not modified.
|
||||
|
||||
|
||||
## Admission client menu 0.1.1 (2026-09-11)
|
||||
|
||||
The signed Aeronautics payload now contains admission mod 0.1.1 at the existing
|
||||
managed path `mods/shacraft-admission-0.1.0.jar` to prevent duplicate mod IDs on
|
||||
upgrade. SHA-256: `faa9ae13cb0f2d93c03dae26ab36ae20d3fb6b66c89c09254b16808d6b183f89`.
|
||||
From the title screen (and vanilla safety acknowledgement), Multiplayer connects
|
||||
to fixed `135.106.154.86:25567`. Cancel/errors return to TitleScreen; transitions
|
||||
from other screens do not auto-connect. Client-only registration, protocol 1 and
|
||||
one-use admission remain unchanged. Running game server was not restarted.
|
||||
Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
|
||||
signed-manifest retrieval and download/repair/restoration of the admission jar
|
||||
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
|
||||
pending exact error/log; this is not a verified macOS fix or desktop UI test.
|
||||
|
||||
## Minigames alongside Aeronautics (2026-09-13)
|
||||
|
||||
The new native allowlist adds profile `minigames` at
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest` and
|
||||
its display-only count at `https://shacraft.ru/api/online/minigames`.
|
||||
Aeronautics remains a separate profile and keeps its previous managed files.
|
||||
Minigames uses Minecraft 26.2, Fabric Loader 0.19.5, Java 25 and its own
|
||||
`profiles/minigames` game directory. The signed payload supplies Fabric API
|
||||
0.160.0+26.2 and `mods/shacraft-admission-client-0.1.0.jar`; it never supplies
|
||||
Paper, the server plugins, maps, credentials or game-download URLs.
|
||||
|
||||
`fabric.rs` obtains an exact parent/loader profile from fixed Fabric metadata,
|
||||
checks its identity and KnotClient entry point, and converts bounded Maven
|
||||
library entries into the existing verified library contract. Each artifact
|
||||
URL is constructed from a validated coordinate below fixed Fabric Maven;
|
||||
metadata-supplied alternative origins are rejected. SHA-1 and size come from
|
||||
Fabric metadata, or the same Maven's hash sidecar and HEAD for the loader jar.
|
||||
Java provisioning already accepts exactly Java 25. Automatic installation on
|
||||
a platform still requires a real cold-install check on that platform.
|
||||
|
||||
Both profiles claim/display the canonical `aoc` nickname. Only the native
|
||||
profile mapping determines ticket `server_id` (`aoc` or `minigames`), and the
|
||||
response must match that exact server. The backend is responsible for shared
|
||||
access checks at both issuance and redemption. Existing account sessions and
|
||||
settings need no migration. No copied subscription/whitelist grant is trusted.
|
||||
|
||||
Minigames adds native `--quickPlayMultiplayer 135.106.154.86:25568` at launch.
|
||||
The client-only Fabric companion validates the actual socket target and signs
|
||||
the existing configuration challenge with `minigames` in the transcript.
|
||||
Paper performs verification before entry; its early duplicate UUID gate must
|
||||
run before vanilla would evict the existing player. There is no proxy and no
|
||||
client-side shared secret. One ticket is used for one game connection; a fresh
|
||||
launcher start is required after expiry, consumption or a failed proof attempt.
|
||||
|
||||
The integration is staged until server authentication, signed profile payload,
|
||||
and a newer signed launcher release are deployed and checked together. The
|
||||
existing public 0.1.5 binary cannot select the new profile by a website-only
|
||||
catalog change. Preserve both catalog entries when publishing either profile.
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
# Signed launcher updates
|
||||
|
||||
The application updater is separate from the signed Aeronautics modpack
|
||||
manifest. Its only metadata endpoint is
|
||||
`https://shacraft.ru/launcher/updates/stable.json`. Its artifact URLs are confined
|
||||
to `https://shacraft.ru/downloads/shacraft-launcher/<version>/<filename>`.
|
||||
The webview cannot choose a URL, signing key or executable path. The native
|
||||
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. 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.
|
||||
|
||||
## Release-line compatibility
|
||||
|
||||
The archived 2026-09-09 review bundle at
|
||||
`/home/emil/Desktop/shacraft-updater-review/README.md` describes a different,
|
||||
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 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
|
||||
substitute the unreleased 0.2.0 prototype for an admission-compatible release.
|
||||
Do not merge its updater key, endpoint or account flow blindly: that can break
|
||||
both update continuity and server login. Future reconciliation requires an
|
||||
explicit compatibility review retaining admission support and the key/feed
|
||||
contract already distributed to players, or a separately designed migration.
|
||||
|
||||
## Authentication contract
|
||||
|
||||
The public key embedded in the application is a **dedicated Tauri updater
|
||||
key**, separate from the existing modpack manifest key. Tauri's minisign format
|
||||
wraps the entire minisign public-key/signature text in standard base64. The
|
||||
contents of a `.sig` file belong in metadata, not its filename or URL.
|
||||
|
||||
The stable feed contains the normal Tauri fields `version`, `notes`, `pub_date`
|
||||
and `platforms`, and two additional fields:
|
||||
|
||||
- `signedPayload`: standard base64 of the exact UTF-8 JSON bytes containing
|
||||
only the four normal fields. The publisher produces these bytes with sorted
|
||||
keys, compact separators, literal UTF-8 and no trailing newline.
|
||||
- `metadataSignature`: the Tauri `.sig` contents for those exact payload bytes,
|
||||
signed with the same updater key that signs the application packages.
|
||||
|
||||
The launcher authenticates the payload, requires it to equal the visible
|
||||
fields and then selects the signed platform artifact. This also authenticates
|
||||
the version and artifact URL: an old signed installer cannot be relabelled as
|
||||
a newer release by modifying unsigned metadata. Every artifact is separately
|
||||
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` (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
|
||||
`/home/emil/.local/share/shacraft-updater/production.key`, with owner-only
|
||||
permissions. Its public companion is `production.key.pub`. Never transfer the
|
||||
private key to the web server, GitHub, CI, logs, chat, a package or a public
|
||||
artifact. Signing commands below pass the local path, not the key contents.
|
||||
Keep a protected operator-controlled backup: replacing or losing the key will
|
||||
break continuity for installations trusting the existing public key. There is
|
||||
no automatic key rotation mechanism in this release.
|
||||
|
||||
Normal `build.yml` jobs explicitly merge `scripts/tauri-unsigned.json` to disable
|
||||
updater signing. They upload ordinary packages and unsigned macOS `.app.tar.gz`
|
||||
archives. CI does not receive the production key and does not publish the
|
||||
stable feed. A release operator reviews/tests these build artifacts, then signs
|
||||
the chosen packages locally. For a signed local Tauri bundle build, set
|
||||
`TAURI_SIGNING_PRIVATE_KEY` to the protected key path; never disable verification
|
||||
in the application to make a build pass.
|
||||
|
||||
Updater signatures authenticate ShaCraft's update channel. They are separate
|
||||
from Windows Authenticode, Apple signing/notarization, and Linux distribution
|
||||
package signatures; passing updater checks does not establish those assurances.
|
||||
|
||||
## Local preparation and signing
|
||||
|
||||
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 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.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. 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.4 \
|
||||
--downloads-root /tmp/shacraft-release/downloads \
|
||||
--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
|
||||
```
|
||||
|
||||
3. Inspect the payload and sign its exact bytes locally:
|
||||
|
||||
```bash
|
||||
npm run tauri -- signer sign \
|
||||
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||
/tmp/shacraft-release/release.payload.json
|
||||
```
|
||||
|
||||
Editing notes, timestamps, versions, signatures or URLs after this step
|
||||
invalidates the metadata signature. Prepare and sign again after any change.
|
||||
|
||||
## Publication
|
||||
|
||||
Upload **only** the packages, their `.sig` files, `release.payload.json`, its
|
||||
`.sig`, the public key and the publisher script. Stage and hash-check artifacts
|
||||
before publishing metadata. Production paths are:
|
||||
|
||||
- Downloads root: `/root/shacraft/caddy/www/downloads/shacraft-launcher`.
|
||||
- Stable feed: `/root/shacraft/data/launcher/updates/stable.json`.
|
||||
- Public feed: `https://shacraft.ru/launcher/updates/stable.json`.
|
||||
|
||||
Keep previous version directories immutable and save the current feed before
|
||||
replacing it. Run the publisher on the host with a public-key file and minisign
|
||||
available there. Neither operation needs a private key:
|
||||
|
||||
```bash
|
||||
python3 publish_launcher_update.py publish \
|
||||
--downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \
|
||||
--public-key /path/to/production.key.pub \
|
||||
--payload /path/to/release.payload.json \
|
||||
--signature /path/to/release.payload.json.sig \
|
||||
--output /root/shacraft/data/launcher/updates/stable.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
After that succeeds, repeat without `--dry-run`. The publisher verifies metadata
|
||||
and all artifacts under the public key, authenticates the previous feed before
|
||||
comparing versions, and refuses same-version replacement or downgrade. It holds
|
||||
an exclusive publication lock and writes/fsyncs a temporary sibling before
|
||||
atomically replacing `stable.json`. Dry-run validates everything but does not
|
||||
replace the feed. Do not change staged artifacts concurrently with publication.
|
||||
Do not overwrite a released version to add another platform: publish a higher
|
||||
version containing the complete intended platform set.
|
||||
|
||||
Alternatively, run the same verification locally against byte-for-byte copies
|
||||
of the current feed and staged downloads, then deploy the resulting feed only
|
||||
after checking uploaded package and metadata hashes against those validated
|
||||
files. An initial publication has no previous feed; subsequent publications
|
||||
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. 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.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
The artifact formats and signature encoding follow the
|
||||
[official Tauri updater documentation](https://v2.tauri.app/plugin/updater/).
|
||||
@@ -0,0 +1,20 @@
|
||||
## Cross-platform release 0.1.5 (2026-09-10)
|
||||
|
||||
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||
An initial non-Linux borrow/move compilation error in updater target selection
|
||||
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||
version/CPU type, Linux package identity and every artifact signature. Public
|
||||
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||
need a manual installation of the current release. Previous releases immutable.
|
||||
|
||||
The live native updater test passed against the published 0.1.5 feed: verified download, corrupted-byte rejection and replacement of only a temporary AppImage copy. The user-installed launcher was not modified.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Launcher 0.1.6 release
|
||||
|
||||
Adds a separate Minigames profile (Minecraft 26.2 / Fabric 0.19.5 / Java 25)
|
||||
while keeping Aeronautics and its installed profile intact. Both use the
|
||||
existing canonical aoc nickname and shared access. Tickets remain bound to
|
||||
the selected server. Minigames connects to 135.106.154.86:25568 and proves
|
||||
account admission during configuration before world entry.
|
||||
|
||||
Local checks: 87 native tests, 33 UI tests, TypeScript/Vite, three Java client
|
||||
proof tests, an official Fabric metadata resolution check and the Linux native
|
||||
release build. The [real Fabric/Paper smoke](verification/minigames-fabric-2026-09-13.json)
|
||||
passed with a synthetic account: the unchanged production companion entered
|
||||
the lobby and the backend consumed its Minigames ticket. Published on 2026-09-13:
|
||||
all four cross-platform jobs succeeded in [build run 34778218510](https://github.com/emil28092005/shacraft-launcher/actions/runs/34778218510)
|
||||
at runtime source `799fa692ef5e775f0044fe300f65fd4564771d47`.
|
||||
[Checks run 34778334417](https://github.com/emil28092005/shacraft-launcher/actions/runs/34778334417)
|
||||
passed at `5b741771b4668a834a2c5b757379340f11b8ae3a`; the only difference is the
|
||||
CI job that runs all 18 publisher signature tests on Ubuntu 24.04. The CI Fabric
|
||||
jar exactly matches the one used by the real client smoke.
|
||||
|
||||
All eight packages were signed locally, verified again on the server, and
|
||||
published after a successful dry-run. Full public HTTPS downloads match the
|
||||
recorded SHA-256 values; package signatures and the public feed signature pass.
|
||||
The feed payload equals the locally signed canonical bytes. See the
|
||||
[publication receipt](verification/launcher-release-0.1.6.json). The previous
|
||||
0.1.5 feed was backed up; its packages remain unchanged. The private key stayed
|
||||
on the operator workstation.
|
||||
|
||||
## Build contract
|
||||
|
||||
Git remote: `git@github.com:emil28092005/shacraft-launcher.git`.
|
||||
The production admission line is branch `codex/launcher-updater`; `main` is a
|
||||
divergent unreleased prototype and must remain unchanged for this release.
|
||||
Push the reviewed commit to the production branch, then dispatch
|
||||
`gh workflow run build.yml --ref codex/launcher-updater --repo emil28092005/shacraft-launcher`.
|
||||
The check workflow runs on branch pushes. Verify each run's head SHA equals the
|
||||
reviewed commit before downloading artifacts. A main push also triggers the
|
||||
build matrix, but was not the publication path for this release.
|
||||
CI publishes unsigned artifacts named:
|
||||
|
||||
- `shacraft-launcher-linux-x64`: AppImage and deb.
|
||||
- `shacraft-launcher-windows-x64`: NSIS exe and MSI.
|
||||
- `shacraft-launcher-macos-arm64`: aarch64 app.tar.gz and DMG.
|
||||
- `shacraft-launcher-macos-x64`: x86_64 app.tar.gz and DMG.
|
||||
|
||||
`.github/workflows/check.yml` additionally tests the Java 25 admission client
|
||||
and uploads `shacraft-admission-client`. It has no production credentials.
|
||||
Download the artifacts from the checked run at the exact reviewed commit with
|
||||
`gh run download RUN_ID --repo emil28092005/shacraft-launcher --dir STAGING`.
|
||||
|
||||
## Signing and publication
|
||||
|
||||
Stage renamed ASCII filenames below a local downloads root, for example
|
||||
`/tmp/shacraft-release-0.1.6/downloads/0.1.6/`. Preserve already published
|
||||
0.1.5 bytes. Expected updater filenames:
|
||||
|
||||
- `ShaCraft.Launcher_0.1.6_amd64.AppImage`
|
||||
- `ShaCraft.Launcher_0.1.6_amd64.deb`
|
||||
- `ShaCraft.Launcher_0.1.6_x64-setup.exe`
|
||||
- `ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz`
|
||||
- `ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz`
|
||||
|
||||
MSI and DMG are additional manual downloads; the updater uses EXE and app.tar.gz.
|
||||
Inspect package versions, architecture and contents before signing. Sign each
|
||||
chosen file with the existing local operator key:
|
||||
|
||||
```bash
|
||||
npm run tauri -- signer sign --private-key-path /home/emil/.local/share/shacraft-updater/production.key ARTIFACT
|
||||
```
|
||||
|
||||
The key file is mode 0600 and remains local. Never read its contents into logs,
|
||||
copy it to CI/server or substitute a different signing identity. Existing
|
||||
`production.key.pub` is sufficient for every later verification/publication.
|
||||
|
||||
After creating a UTF-8 release notes file, prepare the payload:
|
||||
|
||||
```bash
|
||||
python3 scripts/publish_launcher_update.py prepare \
|
||||
--version 0.1.6 \
|
||||
--downloads-root /tmp/shacraft-release-0.1.6/downloads \
|
||||
--artifact linux-x86_64=ShaCraft.Launcher_0.1.6_amd64.AppImage \
|
||||
--artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.6_amd64.AppImage \
|
||||
--artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.6_amd64.deb \
|
||||
--artifact windows-x86_64=ShaCraft.Launcher_0.1.6_x64-setup.exe \
|
||||
--artifact darwin-aarch64=ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz \
|
||||
--artifact darwin-x86_64=ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz \
|
||||
--notes-file /tmp/shacraft-release-0.1.6/notes.txt \
|
||||
--public-key /home/emil/.local/share/shacraft-updater/production.key.pub \
|
||||
--payload /tmp/shacraft-release-0.1.6/release.payload.json
|
||||
npm run tauri -- signer sign \
|
||||
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||
/tmp/shacraft-release-0.1.6/release.payload.json
|
||||
```
|
||||
|
||||
Upload only public packages, signatures, payload, public key and publisher.
|
||||
Server downloads root is `/root/shacraft/caddy/www/downloads/shacraft-launcher`;
|
||||
public artifact URLs are `https://shacraft.ru/downloads/shacraft-launcher/0.1.6/`
|
||||
followed by the checked filename. Preserve the old feed before publication.
|
||||
With the exact staged server paths, run the existing publisher first with
|
||||
`--dry-run`, then without it:
|
||||
|
||||
```bash
|
||||
python3 publish_launcher_update.py publish \
|
||||
--downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \
|
||||
--public-key PUBLIC_KEY_FILE \
|
||||
--payload SIGNED_PAYLOAD_FILE \
|
||||
--signature PAYLOAD_SIGNATURE_FILE \
|
||||
--output /root/shacraft/data/launcher/updates/stable.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Publication depends on the signed Minigames profile containing the final client
|
||||
and Fabric API jars, the healthy Paper admission gate, and the shared-access
|
||||
backend endpoints being available. Verify public HTTPS package hashes, feed
|
||||
signatures and an actual client admission before updating the website buttons.
|
||||
OS Authenticode/Apple notarization and cold installations on other platforms
|
||||
remain distinct from successful native CI/builds.
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"recorded_at": "2026-09-13T20:03:46.913196+00:00",
|
||||
"version": "0.1.6",
|
||||
"source_sha": "799fa692ef5e775f0044fe300f65fd4564771d47",
|
||||
"branch": "codex/launcher-updater",
|
||||
"build_run": 34778218510,
|
||||
"checks_run": 34778334417,
|
||||
"stable_feed": "https://shacraft.ru/launcher/updates/stable.json",
|
||||
"feed_sha256": "342876aee8760a984421889cec1453dd642ebf988ab2bad27f23ea4f4f203f8d",
|
||||
"feed_signature_verified": true,
|
||||
"feed_payload_equals_locally_signed_payload": true,
|
||||
"platforms": [
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64",
|
||||
"linux-x86_64",
|
||||
"linux-x86_64-appimage",
|
||||
"linux-x86_64-deb",
|
||||
"windows-x86_64"
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_amd64.AppImage",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_amd64.AppImage",
|
||||
"size": 83274232,
|
||||
"sha256": "fa01905baddebd08b59ab7855009f0b29df02fcd3813941a0190f5135ff58f7c",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_amd64.deb",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_amd64.deb",
|
||||
"size": 5997818,
|
||||
"sha256": "01621fde16f6fc1ca453acffb1d22d664c8d8b60909d22a8aa2af10ac80d8737",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x64-setup.exe",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64-setup.exe",
|
||||
"size": 3793645,
|
||||
"sha256": "0bb2a577d88d4387f23efcd28b19bf83e373b1b10c5cbbb5d6e0f69428d9f9ab",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x64_en-US.msi",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64_en-US.msi",
|
||||
"size": 5554176,
|
||||
"sha256": "8a53a9d8d6dbdd98238b294df604fe5050375e64709dfdff942bf60fcd61519b",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz",
|
||||
"size": 5876796,
|
||||
"sha256": "c113afc0794eaa6788cb994e7d84f821d22573d559f3cddfb0bcd5c2cad557cf",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_aarch64.dmg",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_aarch64.dmg",
|
||||
"size": 6420101,
|
||||
"sha256": "0391a1d4afa6cb21663f1f8de367ecdd68a51656db48e86ad0bec617270c6036",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz",
|
||||
"size": 6032564,
|
||||
"sha256": "ee025cd13af1887334d0af5a7e04259eddd6339fe8509b108e0b3ac0e7268daf",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x64.dmg",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64.dmg",
|
||||
"size": 6591340,
|
||||
"sha256": "cd54c8f975bb7a1ec587bbadde35caf4a3eb9a1f86182150310590e41dec4023",
|
||||
"public_signature_verified": true
|
||||
}
|
||||
],
|
||||
"previous_feed_version": "0.1.5",
|
||||
"previous_feed_sha256": "d81ab2b007f7f9a385f2ec28d17f204e75c0c2e92cf40931064dd66d3bd2898f",
|
||||
"previous_feed_backup": "/root/shacraft/.release-staging/launcher-0.1.6/stable.before-0.1.6.json",
|
||||
"private_key_location": "remains on operator workstation; not uploaded",
|
||||
"remote_publication": "all8 local signatures verified, remote hashes/signatures verified, dry-run passed, atomic publish passed",
|
||||
"limitations": "Cross-platform CI and package inspection passed. Windows/macOS cold installation and OS signing/notarization were not exercised."
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"recorded_at": "2026-09-13T19:32:59.436884+00:00",
|
||||
"result": "joined_world",
|
||||
"client": "Minecraft 26.2 / Fabric Loader 0.19.5 / Fabric API 0.160.0+26.2",
|
||||
"java": "25.0.2",
|
||||
"client_jar_sha256": "3335626b7c8fdd233e8531ad382594398c7df48842b1b919934d1154dd4ba8f1",
|
||||
"client_jar_size": 10667,
|
||||
"profile": "minigames",
|
||||
"synthetic_player": "AdmissionPilot",
|
||||
"endpoint": "127.0.0.1:25608",
|
||||
"environment": "isolated Xvfb :95; separate temporary game directory; synthetic account/ticket; explicit local socket test flag",
|
||||
"server_evidence": [
|
||||
"[22:32:02 INFO]: AdmissionPilot joined the game",
|
||||
"[22:32:02 INFO]: AdmissionPilot[/127.0.0.1:54602] logged in with entity id 77 at ([minecraft:shacraft_lobby_v2]0.5, 96.0, 43.5)"
|
||||
],
|
||||
"verified": [
|
||||
"production companion jar bytes unchanged",
|
||||
"queued minecraft:register during configuration",
|
||||
"server-bound Ed25519 response accepted",
|
||||
"actual vanilla client entered lobby world"
|
||||
],
|
||||
"not_tested": [
|
||||
"production public endpoint login",
|
||||
"Windows/macOS cold installation",
|
||||
"full GUI launcher install/account flow"
|
||||
],
|
||||
"notes": "Offline client emits Microsoft profile-certificate HTTP401; admission and world entry succeeded. Real account/session/password not used.",
|
||||
"backend_evidence": {
|
||||
"source": "backend agent read-only synthetic SQLite query",
|
||||
"issued_minigames_tickets_for_player": 1,
|
||||
"consumed_minigames_tickets_for_player": 1
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.6",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"lucide-react": "1.41.0",
|
||||
|
||||
+2
-2
@@ -2,13 +2,13 @@
|
||||
"name": "shacraft-launcher-ui",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.6",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare and atomically publish a signed ShaCraft stable updater feed.
|
||||
|
||||
Requires Python 3.10+ and the minisign CLI. Only public keys are inputs.
|
||||
Signing is deliberately a separate, operator-controlled action.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import contextlib
|
||||
from datetime import datetime, timezone
|
||||
import fcntl
|
||||
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",),
|
||||
}
|
||||
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):
|
||||
pass
|
||||
|
||||
|
||||
def version_tuple(version):
|
||||
if not isinstance(version, str) or not re.fullmatch(
|
||||
r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version
|
||||
):
|
||||
raise InvalidRelease("stable version must be plain MAJOR.MINOR.PATCH")
|
||||
parts = tuple(map(int, version.split(".")))
|
||||
if any(part > 2**64 - 1 for part in parts):
|
||||
raise InvalidRelease("version component exceeds SemVer range")
|
||||
return parts
|
||||
|
||||
|
||||
def artifact_name(platform, filename):
|
||||
if platform not in PLATFORMS:
|
||||
raise InvalidRelease("unsupported updater platform")
|
||||
if not isinstance(filename, str) or not re.fullmatch(
|
||||
r"[A-Za-z0-9][A-Za-z0-9._-]{0,199}", filename
|
||||
):
|
||||
raise InvalidRelease("artifact filename must be a plain ASCII filename")
|
||||
if not filename.endswith(PLATFORMS[platform]):
|
||||
raise InvalidRelease("artifact suffix does not match updater platform")
|
||||
return filename
|
||||
|
||||
|
||||
def regular_file(path, limit):
|
||||
info = path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size == 0 or info.st_size > limit:
|
||||
raise InvalidRelease("input must be a nonempty regular file within size limit")
|
||||
return info
|
||||
|
||||
|
||||
def read_file(path, limit=MAX_METADATA_BYTES):
|
||||
regular_file(path, limit)
|
||||
with path.open("rb") as stream:
|
||||
value = stream.read(limit + 1)
|
||||
if len(value) > limit:
|
||||
raise InvalidRelease("input exceeds size limit")
|
||||
return value
|
||||
|
||||
|
||||
def decode_tauri(value):
|
||||
if not isinstance(value, str) or not value or len(value) > MAX_METADATA_BYTES:
|
||||
raise InvalidRelease("invalid Tauri base64 value")
|
||||
try:
|
||||
decoded = base64.b64decode(value, validate=True)
|
||||
decoded.decode("utf-8")
|
||||
except (binascii.Error, UnicodeDecodeError) as exc:
|
||||
raise InvalidRelease("invalid Tauri base64 encoding") from exc
|
||||
if base64.b64encode(decoded).decode("ascii") != value:
|
||||
raise InvalidRelease("noncanonical Tauri base64 encoding")
|
||||
return decoded
|
||||
|
||||
|
||||
def verify_signature(artifact, signature, public_key, minisign):
|
||||
# Tauri wraps the entire standard minisign text file in base64.
|
||||
signature_bytes = decode_tauri(signature)
|
||||
key_bytes = decode_tauri(public_key)
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-update-verify-") as temporary:
|
||||
root = Path(temporary)
|
||||
signature_path = root / "signature.minisig"
|
||||
key_path = root / "public.minisign.pub"
|
||||
signature_path.write_bytes(signature_bytes)
|
||||
key_path.write_bytes(key_bytes)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[minisign, "-V", "-q", "-m", str(artifact), "-x", str(signature_path),
|
||||
"-p", str(key_path)],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=120, check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise InvalidRelease("minisign verification could not run") from exc
|
||||
if result.returncode != 0:
|
||||
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 = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise InvalidRelease("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
try:
|
||||
return json.loads(data, object_pairs_hook=unique)
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise InvalidRelease("invalid release JSON") from exc
|
||||
|
||||
|
||||
def canonical(payload):
|
||||
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def verify_payload_bytes(payload_bytes, signature, public_key, minisign):
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-update-payload-") as temporary:
|
||||
immutable_payload = Path(temporary) / "payload.json"
|
||||
immutable_payload.write_bytes(payload_bytes)
|
||||
verify_signature(immutable_payload, signature, public_key, minisign)
|
||||
|
||||
|
||||
def verified_previous(data, public_key, minisign):
|
||||
envelope = strict_json(data)
|
||||
if not isinstance(envelope, dict) or set(envelope) != FIELDS | {"signedPayload", "metadataSignature"}:
|
||||
raise InvalidRelease("existing feed must have authenticated metadata")
|
||||
payload_bytes = decode_tauri(envelope["signedPayload"])
|
||||
verify_payload_bytes(payload_bytes, envelope["metadataSignature"], public_key, minisign)
|
||||
payload = strict_json(payload_bytes)
|
||||
if payload != {key: envelope[key] for key in FIELDS}:
|
||||
raise InvalidRelease("existing feed fields differ from signed metadata")
|
||||
return payload
|
||||
|
||||
|
||||
def validate_payload(payload, downloads_root, public_key, minisign):
|
||||
if not isinstance(payload, dict) or set(payload) != FIELDS:
|
||||
raise InvalidRelease("payload must contain exactly the four Tauri release fields")
|
||||
version_tuple(payload["version"])
|
||||
if not isinstance(payload["notes"], str) or len(payload["notes"]) > 8000:
|
||||
raise InvalidRelease("release notes must contain at most 8000 characters")
|
||||
if not isinstance(payload["pub_date"], str):
|
||||
raise InvalidRelease("release date must be RFC3339 UTC")
|
||||
try:
|
||||
datetime.strptime(payload["pub_date"], "%Y-%m-%dT%H:%M:%SZ")
|
||||
except ValueError as exc:
|
||||
raise InvalidRelease("release date must be RFC3339 UTC") from exc
|
||||
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")
|
||||
prefix = ORIGIN + payload["version"] + "/"
|
||||
for platform, artifact in platforms.items():
|
||||
if not isinstance(artifact, dict) or set(artifact) != {"url", "signature"}:
|
||||
raise InvalidRelease("artifact requires exactly url and signature")
|
||||
url = artifact["url"]
|
||||
if not isinstance(url, str) or not url.startswith(prefix):
|
||||
raise InvalidRelease("artifact must use the fixed ShaCraft release URL")
|
||||
filename = artifact_name(platform, url[len(prefix):])
|
||||
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
|
||||
):
|
||||
raise InvalidRelease("artifact changed during verification")
|
||||
|
||||
|
||||
def atomic_write(destination, data):
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary = tempfile.mkstemp(prefix="." + destination.name + ".", dir=destination.parent)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
os.fchmod(stream.fileno(), 0o644)
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, destination)
|
||||
directory = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def prepare(args, public_key):
|
||||
version_tuple(args.version)
|
||||
artifacts = {}
|
||||
for item in args.artifact:
|
||||
platform, separator, filename = item.partition("=")
|
||||
if not separator or platform in artifacts:
|
||||
raise InvalidRelease("use each --artifact PLATFORM=FILENAME exactly once")
|
||||
artifact_name(platform, filename)
|
||||
signature = read_file(args.downloads_root / args.version / (filename + ".sig"), 16384).decode("ascii").strip()
|
||||
artifacts[platform] = {
|
||||
"url": ORIGIN + args.version + "/" + filename,
|
||||
"signature": signature,
|
||||
}
|
||||
payload = {
|
||||
"version": args.version,
|
||||
"notes": read_file(args.notes_file).decode("utf-8").strip(),
|
||||
"pub_date": args.pub_date or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"platforms": artifacts,
|
||||
}
|
||||
validate_payload(payload, args.downloads_root, public_key, args.minisign)
|
||||
atomic_write(args.payload, canonical(payload))
|
||||
return payload
|
||||
|
||||
|
||||
def publish(args, public_key):
|
||||
payload_bytes = read_file(args.payload)
|
||||
payload = strict_json(payload_bytes)
|
||||
if not isinstance(payload, dict) or set(payload) != FIELDS:
|
||||
raise InvalidRelease("payload must contain exactly the four Tauri release fields")
|
||||
signature = read_file(args.signature, 16384).decode("ascii").strip()
|
||||
if canonical(payload) != payload_bytes:
|
||||
raise InvalidRelease("payload must be the exact canonical file from prepare")
|
||||
# Verify the captured bytes, so a changing operator input cannot replace
|
||||
# a verified file with different bytes in the feed.
|
||||
verify_payload_bytes(payload_bytes, signature, public_key, args.minisign)
|
||||
envelope = dict(payload)
|
||||
envelope["signedPayload"] = base64.b64encode(payload_bytes).decode("ascii")
|
||||
envelope["metadataSignature"] = signature
|
||||
data = json.dumps(envelope, ensure_ascii=False, indent=2).encode("utf-8") + b"\n"
|
||||
if len(data) > MAX_METADATA_BYTES:
|
||||
raise InvalidRelease("signed metadata exceeds size limit")
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock = args.output.with_name("." + args.output.name + ".lock")
|
||||
descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as lock_stream:
|
||||
fcntl.flock(lock_stream, fcntl.LOCK_EX)
|
||||
if args.output.exists() or args.output.is_symlink():
|
||||
previous = verified_previous(read_file(args.output), public_key, args.minisign)
|
||||
if version_tuple(payload["version"]) <= version_tuple(previous["version"]):
|
||||
raise InvalidRelease("stable publication must strictly increase version")
|
||||
validate_payload(payload, args.downloads_root, public_key, args.minisign)
|
||||
if not args.dry_run:
|
||||
atomic_write(args.output, data)
|
||||
return payload
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--downloads-root", type=Path, required=True)
|
||||
common.add_argument("--public-key", type=Path, required=True, help="Tauri outer-base64 .pub file")
|
||||
common.add_argument("--minisign", default="minisign")
|
||||
common.add_argument("--payload", type=Path, required=True)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
prepare_parser = commands.add_parser("prepare", parents=[common])
|
||||
prepare_parser.add_argument("--version", required=True)
|
||||
prepare_parser.add_argument("--artifact", action="append", required=True, metavar="PLATFORM=FILENAME")
|
||||
prepare_parser.add_argument("--notes-file", type=Path, required=True)
|
||||
prepare_parser.add_argument("--pub-date")
|
||||
publish_parser = commands.add_parser("publish", parents=[common])
|
||||
publish_parser.add_argument("--signature", type=Path, required=True)
|
||||
publish_parser.add_argument("--output", type=Path, required=True)
|
||||
publish_parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
public_key = read_file(args.public_key, 16384).decode("ascii").strip()
|
||||
payload = prepare(args, public_key) if args.command == "prepare" else publish(args, public_key)
|
||||
except (InvalidRelease, OSError, UnicodeError) as exc:
|
||||
parser.exit(1, f"Release rejected: {exc}\n")
|
||||
print(f"{args.command}: {payload['version']} ({', '.join(sorted(payload['platforms']))})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Publisher policy and real minisign verification; keys exist only in tempdirs."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
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"))
|
||||
for value in ("v0.1.3", "0.01.3", "0.1.3-beta", "0.1.3+build", "../0.1.3", 3):
|
||||
with self.subTest(value=value), self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.version_tuple(value)
|
||||
|
||||
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)
|
||||
|
||||
def test_duplicate_json_keys_are_rejected(self):
|
||||
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):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-update-test-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.key = self.root / "fixture.key"
|
||||
public = self.root / "fixture.pub"
|
||||
subprocess.run(
|
||||
[MINISIGN, "-G", "-W", "-p", str(public), "-s", str(self.key)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||
)
|
||||
self.public_key = base64.b64encode(public.read_bytes()).decode("ascii")
|
||||
self.downloads = self.root / "downloads"
|
||||
release = self.downloads / "0.1.3"
|
||||
release.mkdir(parents=True)
|
||||
self.artifact = release / "fixture.AppImage"
|
||||
self.artifact.write_bytes(appimage_fixture())
|
||||
self.payload = {
|
||||
"version": "0.1.3", "notes": "Проверка обновления", "pub_date": "2026-09-10T00:00:00Z",
|
||||
"platforms": {"linux-x86_64": {
|
||||
"url": publisher.ORIGIN + "0.1.3/fixture.AppImage",
|
||||
"signature": self.sign(self.artifact),
|
||||
}},
|
||||
}
|
||||
self.payload_path = self.root / "payload.json"
|
||||
self.output = self.root / "stable.json"
|
||||
|
||||
def sign(self, path):
|
||||
signature_path = path.with_name(path.name + ".minisig")
|
||||
subprocess.run(
|
||||
[MINISIGN, "-S", "-s", str(self.key), "-m", str(path), "-x", str(signature_path), "-q"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||
)
|
||||
signature = base64.b64encode(signature_path.read_bytes()).decode("ascii")
|
||||
path.with_name(path.name + ".sig").write_text(signature, encoding="ascii")
|
||||
return signature
|
||||
|
||||
def publish(self, payload=None, dry_run=False):
|
||||
self.payload_path.write_bytes(publisher.canonical(payload or self.payload))
|
||||
self.sign(self.payload_path)
|
||||
args = argparse.Namespace(
|
||||
payload=self.payload_path, signature=self.payload_path.with_name("payload.json.sig"),
|
||||
output=self.output, downloads_root=self.downloads, minisign=MINISIGN, dry_run=dry_run,
|
||||
)
|
||||
return publisher.publish(args, self.public_key)
|
||||
|
||||
def test_valid_signed_feed_binds_metadata_and_artifact(self):
|
||||
self.publish()
|
||||
envelope = publisher.strict_json(self.output.read_bytes())
|
||||
payload_bytes = publisher.decode_tauri(envelope["signedPayload"])
|
||||
self.assertEqual(publisher.strict_json(payload_bytes), self.payload)
|
||||
self.assertEqual({key: envelope[key] for key in publisher.FIELDS}, self.payload)
|
||||
self.assertIn("metadataSignature", envelope)
|
||||
self.assertEqual(self.output.stat().st_mode & 0o777, 0o644)
|
||||
|
||||
def test_tampered_artifact_is_rejected_before_publication(self):
|
||||
self.artifact.write_bytes(b"replaced executable")
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_tampered_metadata_signature_is_rejected(self):
|
||||
self.payload_path.write_bytes(publisher.canonical(self.payload))
|
||||
signature = self.sign(self.payload_path)
|
||||
self.payload["notes"] = "Changed after signing"
|
||||
self.payload_path.write_bytes(publisher.canonical(self.payload))
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
|
||||
publisher.verify_signature(self.payload_path, signature, self.public_key, MINISIGN)
|
||||
|
||||
def test_same_version_or_downgrade_keeps_original_feed(self):
|
||||
self.publish()
|
||||
original = self.output.read_bytes()
|
||||
for version in ("0.1.3", "0.1.2"):
|
||||
payload = copy.deepcopy(self.payload)
|
||||
payload["version"] = version
|
||||
with self.subTest(version=version), self.assertRaisesRegex(
|
||||
publisher.InvalidRelease, "strictly increase"
|
||||
):
|
||||
self.publish(payload)
|
||||
self.assertEqual(self.output.read_bytes(), original)
|
||||
|
||||
def test_foreign_url_cannot_be_signed_into_feed(self):
|
||||
self.payload["platforms"]["linux-x86_64"]["url"] = "https://example.com/test.AppImage"
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "fixed ShaCraft release URL"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_previous_version_must_also_be_authenticated(self):
|
||||
self.publish()
|
||||
envelope = publisher.strict_json(self.output.read_bytes())
|
||||
envelope["version"] = "99.0.0"
|
||||
self.output.write_bytes(publisher.canonical(envelope))
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "differ from signed metadata"):
|
||||
self.publish()
|
||||
|
||||
def test_missing_signature_or_symlink_is_rejected(self):
|
||||
original = self.artifact.read_bytes()
|
||||
target = self.root / "outside.AppImage"
|
||||
target.write_bytes(original)
|
||||
self.artifact.unlink()
|
||||
self.artifact.symlink_to(target)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "regular file"):
|
||||
self.publish()
|
||||
self.artifact.unlink()
|
||||
self.artifact.write_bytes(original)
|
||||
self.payload["platforms"]["linux-x86_64"].pop("signature")
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "exactly url and signature"):
|
||||
self.publish()
|
||||
|
||||
def test_dry_run_verifies_without_creating_feed(self):
|
||||
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()
|
||||
Generated
+332
-8
@@ -1770,6 +1770,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.20",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -1975,6 +2005,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2217,6 +2253,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2232,6 +2269,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2295,12 +2344,32 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2817,15 +2886,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2893,6 +2967,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -2903,6 +2989,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
@@ -2935,6 +3048,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -2992,6 +3114,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
@@ -3216,13 +3361,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.1"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
"md-5",
|
||||
"minisign-verify",
|
||||
"reqwest 0.12.28",
|
||||
"reqwest 0.13.4",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
@@ -3230,8 +3380,11 @@ dependencies = [
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"url",
|
||||
"zip",
|
||||
"zeroize",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3255,6 +3408,22 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3477,7 +3646,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -3544,7 +3713,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -3640,6 +3809,53 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -3650,7 +3866,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -3673,7 +3889,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -3740,6 +3956,19 @@ dependencies = [
|
||||
"toml 1.1.5+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.1"
|
||||
@@ -4417,6 +4646,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
@@ -4674,6 +4912,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -4707,13 +4954,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -4744,6 +5008,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4756,6 +5026,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4768,12 +5044,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4786,6 +5074,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4798,6 +5092,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -4810,6 +5110,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4822,6 +5128,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
@@ -4886,7 +5198,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -5043,6 +5355,18 @@ dependencies = [
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.14.2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
|
||||
+13
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.1"
|
||||
version = "0.1.6"
|
||||
description = "ShaCraft Minecraft launcher"
|
||||
authors = ["ShaCraft"]
|
||||
license = "MIT"
|
||||
@@ -20,10 +20,21 @@ sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
md-5 = "0.10"
|
||||
base64 = "0.22"
|
||||
ed25519-dalek = "2"
|
||||
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 }
|
||||
minisign-verify = "0.2"
|
||||
semver = "1"
|
||||
url = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Ephemeral proof of possession for one Aeronautics connection.
|
||||
//!
|
||||
//! Neither this private key nor its ticket crosses IPC, enters launch arguments,
|
||||
//! or is persisted. Only the new Java child's environment receives them. A new
|
||||
//! launch obtains a new key and ticket; account session credentials stay native.
|
||||
|
||||
use crate::session::PlayerIdentity;
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine,
|
||||
};
|
||||
use ed25519_dalek::{
|
||||
pkcs8::{EncodePrivateKey, KeypairBytes},
|
||||
SigningKey,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub(crate) const TICKET_ENV: &str = "SHACRAFT_ADMISSION_TICKET";
|
||||
pub(crate) const PRIVATE_KEY_ENV: &str = "SHACRAFT_ADMISSION_PRIVATE_KEY";
|
||||
#[cfg(test)]
|
||||
const SERVER_ID: &str = "aoc";
|
||||
|
||||
pub(crate) fn server_for_profile(profile: &str) -> Result<&'static str, &'static str> {
|
||||
match profile {
|
||||
"aeronautics" => Ok("aoc"),
|
||||
"minigames" => Ok("minigames"),
|
||||
_ => Err("Unknown ShaCraft profile"),
|
||||
}
|
||||
}
|
||||
const MAX_LIFETIME_SECONDS: u64 = 600;
|
||||
|
||||
// Deliberately no Debug, Clone or Serialize for secret-bearing values.
|
||||
pub(crate) struct AdmissionKey {
|
||||
server_id: &'static str,
|
||||
public_key: String,
|
||||
private_key: Zeroizing<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct TicketRequest<'a> {
|
||||
server_id: &'static str,
|
||||
public_key: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct TicketResponse {
|
||||
ticket_id: String,
|
||||
mc_username: String,
|
||||
server_id: String,
|
||||
expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct Admission {
|
||||
server_id: &'static str,
|
||||
ticket_id: Zeroizing<String>,
|
||||
private_key: Zeroizing<String>,
|
||||
identity: PlayerIdentity,
|
||||
}
|
||||
|
||||
impl AdmissionKey {
|
||||
pub(crate) fn generate(server_id: &'static str) -> Result<Self, &'static str> {
|
||||
if !matches!(server_id, "aoc" | "minigames") { return Err("Unknown ShaCraft server"); }
|
||||
let mut seed = Zeroizing::new([0_u8; 32]);
|
||||
getrandom::fill(seed.as_mut())
|
||||
.map_err(|_| "Не удалось создать защищённый ключ входа. Повторите запуск лаунчера.")?;
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let public_key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||
// RFC 8410 PKCS#8 v1 (PrivateKeyInfo) without the optional public key.
|
||||
// This is accepted by Java 21's Ed25519 KeyFactory/PKCS8EncodedKeySpec.
|
||||
let key_bytes = KeypairBytes {
|
||||
secret_key: signing_key.to_bytes(),
|
||||
public_key: None,
|
||||
};
|
||||
let encoded = key_bytes
|
||||
.to_pkcs8_der()
|
||||
.map_err(|_| "Не удалось подготовить защищённый ключ входа.")?;
|
||||
Ok(Self {
|
||||
server_id,
|
||||
public_key,
|
||||
private_key: Zeroizing::new(STANDARD.encode(encoded.as_bytes())),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn request(&self) -> TicketRequest<'_> {
|
||||
TicketRequest {
|
||||
server_id: self.server_id,
|
||||
public_key: &self.public_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bind(self, response: TicketResponse) -> Result<Admission, &'static str> {
|
||||
let ticket_id = Zeroizing::new(response.ticket_id);
|
||||
let valid_ticket = ticket_id.len() == 43
|
||||
&& URL_SAFE_NO_PAD
|
||||
.decode(ticket_id.as_bytes())
|
||||
.is_ok_and(|bytes| {
|
||||
bytes.len() == 32 && URL_SAFE_NO_PAD.encode(bytes) == *ticket_id
|
||||
});
|
||||
let valid_nickname = (3..=16).contains(&response.mc_username.len())
|
||||
&& response
|
||||
.mc_username
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
|
||||
if !valid_ticket
|
||||
|| !valid_nickname
|
||||
|| response.server_id != self.server_id
|
||||
|| !(1..=MAX_LIFETIME_SECONDS).contains(&response.expires_in_seconds)
|
||||
{
|
||||
return Err("Сервер вернул некорректное разрешение на вход. Повторите попытку позже.");
|
||||
}
|
||||
Ok(Admission {
|
||||
server_id: self.server_id,
|
||||
ticket_id,
|
||||
private_key: self.private_key,
|
||||
identity: PlayerIdentity::Offline {
|
||||
name: response.mc_username,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Admission {
|
||||
pub(crate) fn server_id(&self) -> &str { self.server_id }
|
||||
pub(crate) fn identity(&self) -> &PlayerIdentity {
|
||||
&self.identity
|
||||
}
|
||||
|
||||
pub(crate) fn configure_child(&self, command: &mut Command) {
|
||||
command.env(TICKET_ENV, self.ticket_id.as_str());
|
||||
command.env(PRIVATE_KEY_ENV, self.private_key.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{pkcs8::DecodePrivateKey, Signer, Verifier};
|
||||
|
||||
fn response() -> TicketResponse {
|
||||
TicketResponse {
|
||||
ticket_id: URL_SAFE_NO_PAD.encode([37_u8; 32]),
|
||||
mc_username: "Canonical_Name".into(),
|
||||
server_id: SERVER_ID.into(),
|
||||
expires_in_seconds: 600,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_tickets_are_server_bound_with_shared_canonical_identity() {
|
||||
assert_eq!(server_for_profile("aeronautics").unwrap(), "aoc");
|
||||
assert_eq!(server_for_profile("minigames").unwrap(), "minigames");
|
||||
assert!(server_for_profile("../../other").is_err());
|
||||
assert!(AdmissionKey::generate("other").is_err());
|
||||
assert!(AdmissionKey::generate("minigames").unwrap().bind(response()).is_err());
|
||||
let key=AdmissionKey::generate("minigames").unwrap();
|
||||
assert_eq!(serde_json::to_value(key.request()).unwrap()["server_id"], "minigames");
|
||||
let mut payload=response(); payload.server_id="minigames".into();
|
||||
let admitted=key.bind(payload).unwrap();
|
||||
assert_eq!(admitted.server_id(), "minigames");
|
||||
assert_eq!(admitted.identity().name(), "Canonical_Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_distinct_keys_and_only_sends_the_public_key() {
|
||||
let key = AdmissionKey::generate("aoc").unwrap();
|
||||
let other = AdmissionKey::generate("aoc").unwrap();
|
||||
assert_ne!(key.public_key, other.public_key);
|
||||
let payload = serde_json::to_value(key.request()).unwrap();
|
||||
assert_eq!(payload.as_object().unwrap().len(), 2);
|
||||
assert_eq!(payload["server_id"], SERVER_ID);
|
||||
assert_eq!(payload["public_key"], key.public_key);
|
||||
assert!(!payload.to_string().contains(key.private_key.as_str()));
|
||||
let der = Zeroizing::new(STANDARD.decode(key.private_key.as_bytes()).unwrap());
|
||||
let restored = SigningKey::from_pkcs8_der(&der).unwrap();
|
||||
assert_eq!(
|
||||
STANDARD.encode(restored.verifying_key().to_bytes()),
|
||||
key.public_key
|
||||
);
|
||||
let message = b"shacraft-admission-v1:challenge-fixture";
|
||||
restored
|
||||
.verifying_key()
|
||||
.verify(message, &restored.sign(message))
|
||||
.unwrap();
|
||||
// Java's standard Ed25519 encoding is the 48-byte private-key-only form.
|
||||
assert_eq!(der.len(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_untrusted_identity_ticket_server_and_expiry() {
|
||||
let mutations: Vec<Box<dyn Fn(&mut TicketResponse)>> = vec![
|
||||
Box::new(|r| r.ticket_id = "../unsafe".into()),
|
||||
Box::new(|r| r.ticket_id = "A".repeat(42) + "!"),
|
||||
Box::new(|r| r.ticket_id = "A".repeat(42) + "B"),
|
||||
Box::new(|r| r.mc_username = "../../outside".into()),
|
||||
Box::new(|r| r.mc_username = "ab".into()),
|
||||
Box::new(|r| r.mc_username = "a".repeat(17)),
|
||||
Box::new(|r| r.server_id = "other".into()),
|
||||
Box::new(|r| r.expires_in_seconds = 0),
|
||||
Box::new(|r| r.expires_in_seconds = 601),
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut payload = response();
|
||||
mutate(&mut payload);
|
||||
assert!(AdmissionKey::generate("aoc").unwrap().bind(payload).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_only_enter_the_child_environment_and_identity_comes_from_ticket() {
|
||||
let original_ticket = std::env::var_os(TICKET_ENV);
|
||||
let original_key = std::env::var_os(PRIVATE_KEY_ENV);
|
||||
let admission = AdmissionKey::generate("aoc").unwrap().bind(response()).unwrap();
|
||||
let mut command = Command::new("java");
|
||||
command
|
||||
.arg("-Xmx6144M")
|
||||
.arg("net.minecraft.client.main.Main");
|
||||
admission.configure_child(&mut command);
|
||||
assert_eq!(admission.identity().name(), "Canonical_Name");
|
||||
let env: std::collections::HashMap<_, _> = command.get_envs().collect();
|
||||
assert_eq!(
|
||||
env.get(std::ffi::OsStr::new(TICKET_ENV)).unwrap().unwrap(),
|
||||
admission.ticket_id.as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
env.get(std::ffi::OsStr::new(PRIVATE_KEY_ENV))
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
admission.private_key.as_str()
|
||||
);
|
||||
assert_eq!(env.len(), 2);
|
||||
for argument in command.get_args() {
|
||||
assert!(!argument
|
||||
.to_string_lossy()
|
||||
.contains(admission.ticket_id.as_str()));
|
||||
assert!(!argument
|
||||
.to_string_lossy()
|
||||
.contains(admission.private_key.as_str()));
|
||||
}
|
||||
assert_eq!(std::env::var_os(TICKET_ENV), original_ticket);
|
||||
assert_eq!(std::env::var_os(PRIVATE_KEY_ENV), original_key);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{data_dir, shacraft::resolve_identity};
|
||||
use super::data_dir;
|
||||
use crate::{
|
||||
java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
|
||||
settings,
|
||||
fabric, java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
|
||||
settings, shacraft_account,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
@@ -56,6 +56,9 @@ fn resolve_merged_version(
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
|
||||
} else if manifest.minecraft.loader.kind == "fabric" {
|
||||
let child = fabric::fetch_profile(&manifest.minecraft.version, &manifest.minecraft.loader.version)?;
|
||||
mojang::merge_versions(&vanilla, Some(&child)).map_err(|error| error.to_string())
|
||||
} else {
|
||||
mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -163,6 +166,7 @@ pub(crate) async fn launch_game(
|
||||
let game_dir = data_dir(&app)?.join("game");
|
||||
let data_dir = data_dir(&app)?;
|
||||
let permit = state.installation.acquire("Installation")?;
|
||||
let game_permit = state.game.acquire("Игра")?;
|
||||
let account_operation = state.shacraft_account.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
@@ -172,7 +176,6 @@ pub(crate) async fn launch_game(
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
let profile_dir = data_dir.join("profiles").join(&manifest.id);
|
||||
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
|
||||
let identity = resolve_identity(&data_dir, &account_operation)?;
|
||||
|
||||
// Everything here should already be installed by `ensure_game_installed`,
|
||||
// so these are expected to hit their fast paths; no progress to show.
|
||||
@@ -201,12 +204,17 @@ pub(crate) async fn launch_game(
|
||||
std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?;
|
||||
let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log"));
|
||||
|
||||
// Generate fresh proof only after installation. Keep the account gate
|
||||
// through spawn so local logout/account switching cannot race issuance.
|
||||
let _account_permit = account_operation.acquire("ShaCraft account operation")?;
|
||||
let admission =
|
||||
shacraft_account::issue_admission(&data_dir, crate::admission::server_for_profile(&profile_id)?).map_err(|error| error.to_string())?;
|
||||
let request = launch::LaunchRequest {
|
||||
java_executable: Path::new(&java_install.executable),
|
||||
game_dir: &game_dir,
|
||||
profile_dir: &profile_dir,
|
||||
merged: &merged,
|
||||
identity: &identity,
|
||||
admission: &admission,
|
||||
memory_mb: settings.memory_mb,
|
||||
log_path: &log_path,
|
||||
};
|
||||
@@ -215,7 +223,9 @@ pub(crate) async fn launch_game(
|
||||
let watch_app = app.clone();
|
||||
let watch_profile_id = profile_id.clone();
|
||||
std::thread::spawn(move || {
|
||||
let game_permit = game_permit;
|
||||
let exit_code = child.wait().ok().and_then(|status| status.code());
|
||||
drop(game_permit);
|
||||
let _ = watch_app.emit(
|
||||
"game-exited",
|
||||
GameExited {
|
||||
|
||||
@@ -5,6 +5,7 @@ pub(crate) mod host;
|
||||
pub(crate) mod preferences;
|
||||
pub(crate) mod profiles;
|
||||
pub(crate) mod shacraft;
|
||||
pub(crate) mod updater;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! ShaCraft sessions and verified account links are the launch identity source.
|
||||
use super::data_dir;
|
||||
use crate::{
|
||||
operations::{LauncherOperations, Operation},
|
||||
session, shacraft_account,
|
||||
};
|
||||
use crate::{operations::LauncherOperations, shacraft_account};
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
@@ -75,6 +72,18 @@ pub(crate) async fn shacraft_start_link(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn shacraft_claim_nickname(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
nickname: String,
|
||||
) -> Result<shacraft_account::Account, String> {
|
||||
account_task(&app, &state, move |directory| {
|
||||
shacraft_account::claim_nickname(directory, &nickname)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn shacraft_link_status(
|
||||
app: AppHandle,
|
||||
@@ -86,35 +95,3 @@ pub(crate) async fn shacraft_link_status(
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Always revalidate the server session and its aoc link. Legacy local settings
|
||||
/// and Microsoft tokens do not select the identity in the ShaCraft-only flow.
|
||||
pub(super) fn resolve_identity(
|
||||
directory: &Path,
|
||||
operation: &Operation,
|
||||
) -> Result<session::PlayerIdentity, String> {
|
||||
let _permit = operation.acquire("ShaCraft account operation")?;
|
||||
let name =
|
||||
shacraft_account::aeronautics_nickname(directory).map_err(|error| error.to_string())?;
|
||||
Ok(session::PlayerIdentity::Offline { name })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_shacraft_session_cannot_fall_back_to_legacy_nickname() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"shacraft-identity-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
|
||||
assert!(resolve_identity(&directory, &Operation::default()).is_err());
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
use crate::{
|
||||
operations::LauncherOperations,
|
||||
updater::{self, LauncherUpdater, Stage, UpdateProgress, UpdateStatus},
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_launcher_update_status(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
) -> Result<UpdateStatus, String> {
|
||||
updater.status(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn check_launcher_update(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
) -> Result<UpdateStatus, String> {
|
||||
let status = updater.status(&app)?;
|
||||
if !status.supported || status.stage == Stage::Ready {
|
||||
return Ok(status);
|
||||
}
|
||||
let permit = updater
|
||||
.operation
|
||||
.acquire("Проверка или установка обновления")
|
||||
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
|
||||
let updater = updater.inner().clone();
|
||||
updater.set_stage(Stage::Checking)?;
|
||||
// Detached task owns the permit: cancelling an IPC caller cannot release
|
||||
// the operation while its native HTTP request is still in flight.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _permit = permit;
|
||||
let result = async {
|
||||
let key = updater::public_key(&app)?;
|
||||
updater::check_candidate(
|
||||
updater::trusted_builder(&app)?,
|
||||
&key,
|
||||
&app.package_info().version.to_string(),
|
||||
updater::installation_kind(&app),
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
{
|
||||
let mut state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
match result {
|
||||
Ok(candidate) => {
|
||||
state.stage = if candidate.is_some() {
|
||||
Stage::Available
|
||||
} else {
|
||||
Stage::Idle
|
||||
};
|
||||
state.candidate = candidate;
|
||||
}
|
||||
Err(error) => {
|
||||
state.stage = if state.candidate.is_some() {
|
||||
Stage::Available
|
||||
} else {
|
||||
Stage::Idle
|
||||
};
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
updater.status(&app)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Проверка обновления завершилась с ошибкой.".to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_launcher_update(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
operations: State<'_, LauncherOperations>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(reason) = updater::unsupported_reason(&app) {
|
||||
return Err(reason);
|
||||
}
|
||||
let permit = updater
|
||||
.operation
|
||||
.acquire("Проверка или установка обновления")
|
||||
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
|
||||
let candidate = {
|
||||
let state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
if state.stage == Stage::Ready {
|
||||
return Err("Обновление уже установлено. Перезапустите лаунчер.".into());
|
||||
}
|
||||
state
|
||||
.candidate
|
||||
.clone()
|
||||
.ok_or("Сначала проверьте наличие обновлений.")?
|
||||
};
|
||||
let destination = updater::installation_path(&app)?;
|
||||
let mutation_permits = operations.acquire_update()
|
||||
.map_err(|_| "Закройте Minecraft и дождитесь завершения установки или входа в аккаунт перед обновлением.".to_string())?;
|
||||
let updater = updater.inner().clone();
|
||||
updater.set_stage(Stage::Downloading)?;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _permit = permit;
|
||||
let result = async {
|
||||
let key = updater::public_key(&app)?;
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Downloading,
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
},
|
||||
);
|
||||
let bytes =
|
||||
updater::download_verified(&candidate, &key, |downloaded_bytes, total_bytes| {
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Downloading,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
},
|
||||
);
|
||||
})
|
||||
.await?;
|
||||
let size = bytes.len() as u64;
|
||||
updater.set_stage(Stage::Installing)?;
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Installing,
|
||||
downloaded_bytes: size,
|
||||
total_bytes: Some(size),
|
||||
},
|
||||
);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
updater::install_verified(&candidate, &bytes, &key, destination.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())??;
|
||||
Ok::<_, String>(size)
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(size) => {
|
||||
let mut state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
state.stage = Stage::Ready;
|
||||
state.restart_permits = Some(mutation_permits);
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Ready,
|
||||
downloaded_bytes: size,
|
||||
total_bytes: Some(size),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
updater.set_stage(Stage::Available)?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn restart_launcher_after_update(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
) -> Result<(), String> {
|
||||
let _permit = updater
|
||||
.operation
|
||||
.acquire("Установка обновления")
|
||||
.map_err(|_| "Установка обновления ещё выполняется.".to_string())?;
|
||||
{
|
||||
let state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
if state.stage != Stage::Ready || state.restart_permits.is_none() {
|
||||
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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Fabric metadata and Maven are fixed, independent game trust domains.
|
||||
//! The signed ShaCraft manifest selects versions, never arbitrary loader URLs.
|
||||
use crate::mojang::{Artifact, LibraryDownloads, VersionJson};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use std::{io::Read, time::Duration};
|
||||
|
||||
pub(crate) const MAVEN_HOST: &str = "maven.fabricmc.net";
|
||||
const HOSTS: [&str; 2] = ["meta.fabricmc.net", MAVEN_HOST];
|
||||
const MAX_PROFILE: usize = 512 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FabricLibrary {
|
||||
name: String,
|
||||
url: String,
|
||||
sha1: Option<String>,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
fn coordinate_path(name: &str) -> Result<String, String> {
|
||||
let pieces: Vec<_> = name.split(':').collect();
|
||||
if pieces.len() != 3
|
||||
|| pieces.iter().any(|p| {
|
||||
!crate::manifest::is_portable_component(p)
|
||||
|| *p == "."
|
||||
|| *p == ".."
|
||||
|| !p
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
|
||||
})
|
||||
|| pieces[0].split('.').any(|p| !crate::manifest::is_portable_component(p))
|
||||
{
|
||||
return Err("Invalid Fabric library coordinate".into());
|
||||
}
|
||||
Ok(format!(
|
||||
"{}/{}/{}/{}-{}.jar",
|
||||
pieces[0].replace('.', "/"),
|
||||
pieces[1],
|
||||
pieces[2],
|
||||
pieces[1],
|
||||
pieces[2]
|
||||
))
|
||||
}
|
||||
|
||||
fn get(client: &Client, url: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.take((limit + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if bytes.len() > limit {
|
||||
return Err("Fabric metadata exceeds its size limit".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_profile(minecraft: &str, loader: &str) -> Result<VersionJson, String> {
|
||||
let client =
|
||||
crate::trusted_http::client(&HOSTS, Duration::from_secs(30)).map_err(|e| e.to_string())?;
|
||||
// Defence in depth: these values normally already passed manifest validation.
|
||||
if [minecraft, loader].iter().any(|v| {
|
||||
!crate::manifest::is_portable_component(v)
|
||||
|| v.contains('/')
|
||||
|| !v
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
|
||||
}) {
|
||||
return Err("Invalid Fabric version".into());
|
||||
}
|
||||
let bytes = get(
|
||||
&client,
|
||||
&format!("https://meta.fabricmc.net/v2/versions/loader/{minecraft}/{loader}/profile/json"),
|
||||
MAX_PROFILE,
|
||||
)?;
|
||||
normalize(&client, &bytes, minecraft, loader)
|
||||
}
|
||||
|
||||
fn normalize(
|
||||
client: &Client,
|
||||
bytes: &[u8],
|
||||
minecraft: &str,
|
||||
loader: &str,
|
||||
) -> Result<VersionJson, String> {
|
||||
// Flattening libraries would consume the same key twice; parse the two views explicitly.
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
|
||||
let parent = value.get("inheritsFrom").and_then(|v| v.as_str());
|
||||
let mut version: VersionJson =
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||
if parent != Some(minecraft)
|
||||
|| version.id != format!("fabric-loader-{loader}-{minecraft}")
|
||||
|| version.main_class != "net.fabricmc.loader.impl.launch.knot.KnotClient"
|
||||
{
|
||||
return Err("Fabric profile identity mismatch".into());
|
||||
}
|
||||
let artifacts: Vec<FabricLibrary> =
|
||||
serde_json::from_value(value["libraries"].clone()).map_err(|e| e.to_string())?;
|
||||
if artifacts.is_empty() || artifacts.len() > 32 {
|
||||
return Err("Invalid Fabric library count".into());
|
||||
}
|
||||
for (library, artifact) in version.libraries.iter_mut().zip(artifacts) {
|
||||
if artifact.url != "https://maven.fabricmc.net/" {
|
||||
return Err("Untrusted Fabric Maven URL".into());
|
||||
}
|
||||
let path = coordinate_path(&artifact.name)?;
|
||||
let url = format!("https://{MAVEN_HOST}/{path}");
|
||||
let sha1 = match artifact.sha1 {
|
||||
Some(hash) => hash,
|
||||
None => String::from_utf8(get(client, &format!("{url}.sha1"), 128)?)
|
||||
.map_err(|e| e.to_string())?
|
||||
.trim()
|
||||
.to_owned(),
|
||||
};
|
||||
let size = match artifact.size {
|
||||
Some(size) => size,
|
||||
None => client
|
||||
.head(&url)
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.ok_or("Fabric library has no size")?,
|
||||
};
|
||||
if sha1.len() != 40
|
||||
|| !sha1.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
|| size == 0
|
||||
|| size > 64 * 1024 * 1024
|
||||
{
|
||||
return Err("Invalid Fabric library hash or size".into());
|
||||
}
|
||||
library.downloads = Some(LibraryDownloads {
|
||||
artifact: Some(Artifact {
|
||||
path,
|
||||
url,
|
||||
sha1,
|
||||
size,
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maven_coordinates_cannot_escape_library_directory() {
|
||||
assert_eq!(
|
||||
coordinate_path("net.fabricmc:fabric-loader:0.19.5").unwrap(),
|
||||
"net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar"
|
||||
);
|
||||
for bad in [
|
||||
"x:y:../evil",
|
||||
"a..b:c:1",
|
||||
"x:/tmp:1",
|
||||
"x:y:1:extra",
|
||||
"x:y:\\evil",
|
||||
"x:y:..",
|
||||
"CON:y:1",
|
||||
"a:y.:1",
|
||||
"a:AUX:1",
|
||||
] {
|
||||
assert!(coordinate_path(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn loader_identity_and_maven_origin_are_checked_before_downloads() {
|
||||
let client = Client::new();
|
||||
let base = serde_json::json!({"id":"fabric-loader-0.19.5-26.2", "inheritsFrom":"26.2", "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient", "libraries":[{"name":"net.fabricmc:fabric-loader:0.19.5", "url":"https://maven.fabricmc.net/", "sha1":"a".repeat(40), "size":42}]});
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&base).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_ok());
|
||||
for (field, value) in [
|
||||
("inheritsFrom", "1.21.1"),
|
||||
("mainClass", "attacker.Main"),
|
||||
("id", "wrong"),
|
||||
] {
|
||||
let mut bad = base.clone();
|
||||
bad[field] = value.into();
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&bad).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
let mut bad = base;
|
||||
bad["libraries"][0]["url"] = "https://attacker.invalid/".into();
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&bad).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "downloads official Fabric profile metadata"]
|
||||
fn live_resolves_fabric_26_2() {
|
||||
let profile = fetch_profile("26.2", "0.19.5").unwrap();
|
||||
assert!(profile
|
||||
.libraries
|
||||
.iter()
|
||||
.all(|library| library.downloads.is_some()));
|
||||
}
|
||||
}
|
||||
+90
-15
@@ -1,11 +1,10 @@
|
||||
//! Builds and spawns the real `java` invocation for a merged launch
|
||||
//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`,
|
||||
//! which is either a real Microsoft-authenticated session (`msa::LoginResult`)
|
||||
//! or an explicit offline account (`PlayerIdentity::Offline`). Offline mode is
|
||||
//! never silently substituted for a Microsoft session.
|
||||
//! profile. The `${auth_*}` placeholders use only the identity bound to the
|
||||
//! server-issued admission ticket. Its one-use proof stays out of arguments
|
||||
//! and files; only the Java child's environment receives it.
|
||||
|
||||
use crate::admission::Admission;
|
||||
use crate::mojang::{self, MergedVersion};
|
||||
use crate::session::PlayerIdentity;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -44,7 +43,7 @@ pub struct LaunchRequest<'a> {
|
||||
/// the shared `game_dir`.
|
||||
pub profile_dir: &'a Path,
|
||||
pub merged: &'a MergedVersion,
|
||||
pub identity: &'a PlayerIdentity,
|
||||
pub admission: &'a Admission,
|
||||
pub memory_mb: u16,
|
||||
pub log_path: &'a Path,
|
||||
}
|
||||
@@ -183,6 +182,10 @@ fn write_jvm_argfile(path: &Path, arguments: &[String]) -> io::Result<()> {
|
||||
/// that (see `lib.rs`'s launch command, which watches it on a background
|
||||
/// thread and emits an event).
|
||||
pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
Ok(build_command(request)?.spawn()?)
|
||||
}
|
||||
|
||||
fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
|
||||
fs::create_dir_all(request.profile_dir)?;
|
||||
let natives_dir = mojang::natives_directory(request.game_dir, &request.merged.id);
|
||||
fs::create_dir_all(&natives_dir)?;
|
||||
@@ -193,7 +196,8 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
let classpath = build_classpath(request.game_dir, request.merged, &client_jar);
|
||||
|
||||
let mut vars: HashMap<&str, String> = HashMap::new();
|
||||
vars.insert("auth_player_name", request.identity.name().to_string());
|
||||
let identity = request.admission.identity();
|
||||
vars.insert("auth_player_name", identity.name().to_string());
|
||||
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
|
||||
// `-DignoreList`. The actual client jar belongs to the vanilla parent
|
||||
// (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this
|
||||
@@ -203,14 +207,11 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
vars.insert("game_directory", request.profile_dir.display().to_string());
|
||||
vars.insert("assets_root", assets_root.display().to_string());
|
||||
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
|
||||
vars.insert("auth_uuid", request.identity.uuid());
|
||||
vars.insert(
|
||||
"auth_access_token",
|
||||
request.identity.access_token().to_string(),
|
||||
);
|
||||
vars.insert("auth_uuid", identity.uuid());
|
||||
vars.insert("auth_access_token", identity.access_token().to_string());
|
||||
vars.insert("clientid", launcher_client_id(request.game_dir)?);
|
||||
vars.insert("auth_xuid", request.identity.xuid().to_string());
|
||||
vars.insert("user_type", request.identity.user_type().to_string());
|
||||
vars.insert("auth_xuid", identity.xuid().to_string());
|
||||
vars.insert("user_type", identity.user_type().to_string());
|
||||
vars.insert("version_type", "ShaCraft Launcher".to_string());
|
||||
vars.insert("natives_directory", natives_dir.display().to_string());
|
||||
vars.insert("launcher_name", "ShaCraft Launcher".to_string());
|
||||
@@ -227,6 +228,7 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
|
||||
|
||||
let mut command = Command::new(request.java_executable);
|
||||
request.admission.configure_child(&mut command);
|
||||
let memory_argument = format!("-Xmx{}M", request.memory_mb);
|
||||
if cfg!(windows) {
|
||||
let argfile = request.profile_dir.join(".shacraft-jvm.args");
|
||||
@@ -243,19 +245,92 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
for argument in game_args {
|
||||
command.arg(substitute(&argument, &vars));
|
||||
}
|
||||
// This endpoint is native-owned; a manifest cannot redirect game admission.
|
||||
if request.admission.server_id() == "minigames" {
|
||||
command.args(["--quickPlayMultiplayer", "135.106.154.86:25568"]);
|
||||
}
|
||||
command.current_dir(request.profile_dir);
|
||||
|
||||
let log_file = fs::File::create(request.log_path)?;
|
||||
command.stdout(Stdio::from(log_file.try_clone()?));
|
||||
command.stderr(Stdio::from(log_file));
|
||||
|
||||
Ok(command.spawn()?)
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_and_written_files_never_contain_admission_secrets() {
|
||||
use crate::admission::{AdmissionKey, PRIVATE_KEY_ENV, TICKET_ENV};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("shacraft-launch-proof-{}", random_uuid_v4()));
|
||||
let game_dir = directory.join("game");
|
||||
let profile_dir = directory.join("profile");
|
||||
let log_path = directory.join("game.log");
|
||||
for server_id in ["aoc", "minigames"] {
|
||||
let vanilla: mojang::VersionJson = serde_json::from_value(serde_json::json!({
|
||||
"id": "1.21.1",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"arguments": {
|
||||
"game": ["--username", "${auth_player_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}"],
|
||||
"jvm": ["-cp", "${classpath}", "-Dlauncher=${launcher_name}"]
|
||||
},
|
||||
"assetIndex": {"id": "17", "sha1": "0".repeat(40), "size": 1, "url": "https://piston-meta.mojang.com/assets"},
|
||||
"downloads": {"client": {"sha1": "0".repeat(40), "size": 1, "url": "https://piston-data.mojang.com/client.jar"}}
|
||||
})).unwrap();
|
||||
let merged = mojang::merge_versions(&vanilla, None).unwrap();
|
||||
let response = serde_json::from_value(serde_json::json!({
|
||||
"ticket_id": URL_SAFE_NO_PAD.encode([73_u8; 32]), "mc_username": "Ticket_Name",
|
||||
"server_id": server_id, "expires_in_seconds": 600
|
||||
}))
|
||||
.unwrap();
|
||||
let admission = AdmissionKey::generate(server_id).unwrap().bind(response).unwrap();
|
||||
let request = LaunchRequest {
|
||||
java_executable: Path::new("java"),
|
||||
game_dir: &game_dir,
|
||||
profile_dir: &profile_dir,
|
||||
merged: &merged,
|
||||
admission: &admission,
|
||||
memory_mb: 6144,
|
||||
log_path: &log_path,
|
||||
};
|
||||
let command = build_command(&request).unwrap();
|
||||
let env: HashMap<_, _> = command.get_envs().collect();
|
||||
let proof = [TICKET_ENV, PRIVATE_KEY_ENV]
|
||||
.map(|name| env[std::ffi::OsStr::new(name)].unwrap().to_str().unwrap());
|
||||
let arguments: Vec<_> = command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy())
|
||||
.collect();
|
||||
assert!(arguments
|
||||
.windows(2)
|
||||
.any(|args| args == ["--username", "Ticket_Name"]));
|
||||
assert!(arguments
|
||||
.windows(2)
|
||||
.any(|args| args == ["--accessToken", "0"]));
|
||||
assert_eq!(arguments.windows(2).any(|pair| pair == ["--quickPlayMultiplayer", "135.106.154.86:25568"]), server_id == "minigames");
|
||||
for secret in proof {
|
||||
assert!(arguments.iter().all(|argument| !argument.contains(secret)));
|
||||
for path in [
|
||||
log_path.clone(),
|
||||
game_dir.join(".shacraft-client-id"),
|
||||
profile_dir.join(".shacraft-jvm.args"),
|
||||
] {
|
||||
if path.exists() {
|
||||
assert!(!fs::read_to_string(path).unwrap().contains(secret));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(command);
|
||||
}
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_rfc4122_version_4_uuids() {
|
||||
let id = random_uuid_v4();
|
||||
|
||||
+33
-1
@@ -1,4 +1,8 @@
|
||||
mod admission;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod deb_updater;
|
||||
mod download;
|
||||
mod fabric;
|
||||
mod java;
|
||||
mod launch;
|
||||
mod manifest;
|
||||
@@ -13,13 +17,36 @@ mod settings;
|
||||
mod shacraft_account;
|
||||
mod storage;
|
||||
mod trusted_http;
|
||||
mod updater;
|
||||
|
||||
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())
|
||||
.manage(updater::LauncherUpdater::default())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
// Do not interrupt the short package replacement step. A
|
||||
// download can safely be abandoned before any file changes.
|
||||
if let Some(updater) = window.try_state::<updater::LauncherUpdater>() {
|
||||
if updater
|
||||
.state
|
||||
.lock()
|
||||
.is_ok_and(|state| state.stage == updater::Stage::Installing)
|
||||
{
|
||||
api.prevent_close();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::host::native_host,
|
||||
commands::host::detect_java,
|
||||
@@ -34,12 +61,17 @@ pub fn run() {
|
||||
commands::shacraft::get_shacraft_account,
|
||||
commands::shacraft::shacraft_logout,
|
||||
commands::shacraft::shacraft_start_link,
|
||||
commands::shacraft::shacraft_claim_nickname,
|
||||
commands::shacraft::shacraft_link_status,
|
||||
commands::account::start_microsoft_login,
|
||||
commands::account::get_account,
|
||||
commands::account::logout,
|
||||
commands::game::ensure_game_installed,
|
||||
commands::game::launch_game
|
||||
commands::game::launch_game,
|
||||
commands::updater::get_launcher_update_status,
|
||||
commands::updater::check_launcher_update,
|
||||
commands::updater::install_launcher_update,
|
||||
commands::updater::restart_launcher_after_update
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running ShaCraft Launcher");
|
||||
|
||||
@@ -91,7 +91,7 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
));
|
||||
}
|
||||
if !is_version(&manifest.minecraft.version)
|
||||
|| manifest.minecraft.loader.kind.trim().is_empty()
|
||||
|| !matches!(manifest.minecraft.loader.kind.as_str(), "neoforge" | "fabric" | "vanilla")
|
||||
|| !is_version(&manifest.minecraft.loader.version)
|
||||
{
|
||||
return Err(ManifestError::Invalid(
|
||||
|
||||
@@ -58,6 +58,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
|
||||
MOJANG_HOSTS[2],
|
||||
MOJANG_HOSTS[3],
|
||||
crate::neoforge::NEOFORGE_HOST,
|
||||
crate::fabric::MAVEN_HOST,
|
||||
],
|
||||
std::time::Duration::from_secs(10 * 60),
|
||||
)
|
||||
@@ -67,7 +68,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
|
||||
/// own fixed Maven. The profile itself comes from the SHA-256-verified
|
||||
/// NeoForge installer, never from the ShaCraft manifest.
|
||||
fn is_allowed_library_host(url: &str) -> bool {
|
||||
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
|
||||
is_allowed_host(url) || crate::neoforge::is_allowed_host(url) || crate::trusted_http::allows(url, &[crate::fabric::MAVEN_HOST])
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -7,11 +7,42 @@ use std::sync::{
|
||||
Arc,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct LauncherOperations {
|
||||
pub installation: Operation,
|
||||
pub account: Operation,
|
||||
pub shacraft_account: Operation,
|
||||
// Held from launch scheduling until Child::wait completes, not just spawn.
|
||||
pub game: Operation,
|
||||
}
|
||||
|
||||
impl LauncherOperations {
|
||||
/// Acquire every mutation gate without waiting. Partial acquisition is
|
||||
/// rolled back by RAII, so a failed update cannot strand an account gate.
|
||||
pub fn acquire_update(&self) -> Result<UpdatePermits, String> {
|
||||
let installation = self.installation.acquire("Установка игры")?;
|
||||
let account = self.account.acquire("Вход в аккаунт")?;
|
||||
let shacraft_account = self
|
||||
.shacraft_account
|
||||
.acquire("Операция с аккаунтом ShaCraft")?;
|
||||
let game = self
|
||||
.game
|
||||
.acquire("Игра")
|
||||
.map_err(|_| "Закройте Minecraft перед обновлением лаунчера.".to_string())?;
|
||||
Ok(UpdatePermits {
|
||||
_installation: installation,
|
||||
_account: account,
|
||||
_shacraft_account: shacraft_account,
|
||||
_game: game,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UpdatePermits {
|
||||
_installation: Permit,
|
||||
_account: Permit,
|
||||
_shacraft_account: Permit,
|
||||
_game: Permit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -36,7 +67,7 @@ impl Drop for Permit {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Operation;
|
||||
use super::{LauncherOperations, Operation};
|
||||
|
||||
#[test]
|
||||
fn rejects_overlap_and_releases_on_worker_error() {
|
||||
@@ -49,4 +80,43 @@ mod tests {
|
||||
assert!(worker().is_err());
|
||||
assert!(operation.acquire("Installation").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_game_blocks_update_and_partial_locks_are_released() {
|
||||
let operations = LauncherOperations::default();
|
||||
let game = operations.game.acquire("game").unwrap();
|
||||
assert!(operations.acquire_update().is_err());
|
||||
assert!(operations.installation.acquire("install").is_ok());
|
||||
assert!(operations.account.acquire("account").is_ok());
|
||||
assert!(operations
|
||||
.shacraft_account
|
||||
.acquire("ShaCraft account")
|
||||
.is_ok());
|
||||
drop(game);
|
||||
assert!(operations.acquire_update().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_excludes_game_and_accounts_until_permit_drop() {
|
||||
let operations = LauncherOperations::default();
|
||||
let permit = operations.acquire_update().unwrap();
|
||||
assert!(operations.installation.acquire("install").is_err());
|
||||
assert!(operations.account.acquire("account").is_err());
|
||||
assert!(operations
|
||||
.shacraft_account
|
||||
.acquire("ShaCraft account")
|
||||
.is_err());
|
||||
assert!(operations.game.acquire("game").is_err());
|
||||
assert!(operations.acquire_update().is_err());
|
||||
drop(permit);
|
||||
assert!(operations.acquire_update().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_operation_blocks_update_without_stranding_installation() {
|
||||
let operations = LauncherOperations::default();
|
||||
let _account = operations.shacraft_account.acquire("account").unwrap();
|
||||
assert!(operations.acquire_update().is_err());
|
||||
assert!(operations.installation.acquire("install").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,41 @@ mod tests {
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
#[test]
|
||||
#[ignore = "downloads the public signed admission mod into a temporary directory"]
|
||||
fn live_admission_mod_is_restored_when_missing_or_corrupt() {
|
||||
let mut manifest = crate::remote::fetch_manifest("aeronautics").unwrap();
|
||||
manifest.files.retain(|file| {
|
||||
file.path.starts_with("mods/shacraft-admission") && file.path.ends_with(".jar")
|
||||
});
|
||||
assert_eq!(
|
||||
manifest.files.len(),
|
||||
1,
|
||||
"exactly one admission mod is required"
|
||||
);
|
||||
assert!(matches!(manifest.files[0].policy, FilePolicy::Managed));
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-live-admission-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
assert_eq!(inspect(&root, &manifest).unwrap().missing_files, 1);
|
||||
super::sync(&root, &manifest).unwrap();
|
||||
assert!(inspect(&root, &manifest).unwrap().up_to_date);
|
||||
let target = root.join(&manifest.files[0].path);
|
||||
fs::write(&target, b"corrupt fixture").unwrap();
|
||||
assert!(!inspect(&root, &manifest).unwrap().up_to_date);
|
||||
super::sync(&root, &manifest).unwrap();
|
||||
assert!(inspect(&root, &manifest).unwrap().up_to_date);
|
||||
fs::remove_file(&target).unwrap();
|
||||
super::sync(&root, &manifest).unwrap();
|
||||
assert!(inspect(&root, &manifest).unwrap().up_to_date);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn manifest(hash: String, size: u64) -> Manifest {
|
||||
Manifest {
|
||||
schema_version: 1,
|
||||
|
||||
@@ -13,6 +13,8 @@ use std::{
|
||||
|
||||
const AERONAUTICS_MANIFEST: &str =
|
||||
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
||||
const MINIGAMES_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest";
|
||||
const MINIGAMES_ONLINE: &str = "https://shacraft.ru/api/online/minigames";
|
||||
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
|
||||
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
|
||||
const MANIFEST_KEY_ID: &str = "2026-09-06";
|
||||
@@ -71,6 +73,7 @@ impl fmt::Display for RemoteError {
|
||||
pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
||||
let url = match profile_id {
|
||||
"aeronautics" => AERONAUTICS_MANIFEST,
|
||||
"minigames" => MINIGAMES_MANIFEST,
|
||||
_ => return Err(RemoteError::UnknownProfile),
|
||||
};
|
||||
let client = Client::builder()
|
||||
@@ -125,6 +128,7 @@ fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteErr
|
||||
pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError> {
|
||||
let url = match profile_id {
|
||||
"aeronautics" => AERONAUTICS_ONLINE,
|
||||
"minigames" => MINIGAMES_ONLINE,
|
||||
_ => return Err(RemoteError::UnknownProfile),
|
||||
};
|
||||
let client = Client::builder()
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use reqwest::redirect::Policy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt, fs, io, path::Path, time::Duration};
|
||||
use std::{
|
||||
fmt, fs,
|
||||
io::{self, Read},
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const API_ORIGIN: &str = "https://shacraft.ru";
|
||||
const SESSION_FILE: &str = "shacraft-session";
|
||||
@@ -62,7 +67,6 @@ pub enum AccountError {
|
||||
Api(String),
|
||||
Io(io::Error),
|
||||
InvalidSession,
|
||||
NoLinkedNickname,
|
||||
}
|
||||
|
||||
impl fmt::Display for AccountError {
|
||||
@@ -72,9 +76,6 @@ impl fmt::Display for AccountError {
|
||||
Self::Api(message) => formatter.write_str(message),
|
||||
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
|
||||
Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"),
|
||||
Self::NoLinkedNickname => {
|
||||
formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,18 +222,96 @@ pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, Acc
|
||||
response.json::<LinkStatus>().map_err(AccountError::Network)
|
||||
}
|
||||
|
||||
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
|
||||
get_account(data_dir)?
|
||||
.links
|
||||
.into_iter()
|
||||
.find(|link| link.server_id == "aoc")
|
||||
.map(|link| link.mc_username)
|
||||
.ok_or(AccountError::NoLinkedNickname)
|
||||
const ADMISSION_ENDPOINT: &str = "/api/launcher/v2/admission/tickets";
|
||||
|
||||
/// Error responses at this boundary never echo arbitrary response bodies: a
|
||||
/// misconfigured proxy/service must not copy credentials into UI diagnostics.
|
||||
fn admission_error(status: reqwest::StatusCode) -> AccountError {
|
||||
use reqwest::StatusCode;
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => AccountError::InvalidSession,
|
||||
StatusCode::FORBIDDEN => AccountError::Api(
|
||||
"Нет разрешения на вход в Aeronautics. Проверьте привязку ника и доступ к серверу в аккаунте ShaCraft.".into()),
|
||||
StatusCode::CONFLICT => AccountError::Api(
|
||||
"Этот ник уже занят или зарезервирован. Если это ваш игровой ник, обратитесь в поддержку ShaCraft.".into()),
|
||||
StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE => AccountError::Api(
|
||||
"Вход через ShaCraft Launcher пока не настроен на сервере. Повторите попытку позже.".into()),
|
||||
StatusCode::TOO_MANY_REQUESTS => AccountError::Api(
|
||||
"Слишком много запросов входа. Подождите немного и повторите попытку.".into()),
|
||||
_ => AccountError::Api(format!("Не удалось получить разрешение ShaCraft: HTTP {status}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_admission_response(
|
||||
data_dir: &Path,
|
||||
response: Response,
|
||||
) -> Result<Response, AccountError> {
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let _ = fs::remove_file(session_path(data_dir));
|
||||
}
|
||||
Err(admission_error(response.status()))
|
||||
}
|
||||
|
||||
/// The server reserves a free nickname atomically for this account. Existing
|
||||
/// player names remain reserved for administrator-assisted migration.
|
||||
pub fn claim_nickname(data_dir: &Path, nickname: &str) -> Result<Account, AccountError> {
|
||||
let token = load_session(data_dir)?;
|
||||
let response = client()?
|
||||
.post(format!("{API_ORIGIN}/api/launcher/v2/admission/nickname"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({"server_id": "aoc", "mc_username": nickname}))
|
||||
.send()
|
||||
.map_err(AccountError::Network)?;
|
||||
checked_admission_response(data_dir, response)?
|
||||
.json()
|
||||
.map_err(AccountError::Network)
|
||||
}
|
||||
|
||||
/// Called only after installation, immediately before Java spawn. Nothing in
|
||||
/// this response is exposed to the webview or persisted with account settings.
|
||||
pub(crate) fn issue_admission(
|
||||
data_dir: &Path,
|
||||
server_id: &'static str,
|
||||
) -> Result<crate::admission::Admission, AccountError> {
|
||||
let token = load_session(data_dir)?;
|
||||
let key = crate::admission::AdmissionKey::generate(server_id)
|
||||
.map_err(|message| AccountError::Api(message.into()))?;
|
||||
let response = client()?
|
||||
.post(format!("{API_ORIGIN}{ADMISSION_ENDPOINT}"))
|
||||
.bearer_auth(token)
|
||||
.json(&key.request())
|
||||
.send()
|
||||
.map_err(AccountError::Network)?;
|
||||
let response = checked_admission_response(data_dir, response)?;
|
||||
// The expected object is under 256 bytes; bound the remote allocation and
|
||||
// use a fixed parse error without response values or secret-bearing bodies.
|
||||
let mut body = zeroize::Zeroizing::new(Vec::new());
|
||||
response.take(4097).read_to_end(&mut body).map_err(|_| {
|
||||
AccountError::Api(
|
||||
"Не удалось прочитать разрешение на вход. Повторите попытку позже.".into(),
|
||||
)
|
||||
})?;
|
||||
let invalid = || {
|
||||
AccountError::Api(
|
||||
"Сервер вернул некорректное разрешение на вход. Повторите попытку позже.".into(),
|
||||
)
|
||||
};
|
||||
if body.len() > 4096 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let payload = serde_json::from_slice(&body).map_err(|_| invalid())?;
|
||||
key.bind(payload)
|
||||
.map_err(|message| AccountError::Api(message.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{load_session, save_session, session_path};
|
||||
use super::{
|
||||
admission_error, issue_admission, load_session, save_session, session_path, AccountError,
|
||||
};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
@@ -275,4 +354,34 @@ mod tests {
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_without_session_never_falls_back_to_legacy_nickname() {
|
||||
let directory = temporary_directory();
|
||||
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
|
||||
assert!(matches!(
|
||||
issue_admission(&directory, "aoc"),
|
||||
Err(AccountError::InvalidSession)
|
||||
));
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_unavailable_and_revoked_session_have_actionable_errors() {
|
||||
for status in [
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
||||
] {
|
||||
assert!(admission_error(status)
|
||||
.to_string()
|
||||
.contains("пока не настроен"));
|
||||
}
|
||||
assert!(matches!(
|
||||
admission_error(reqwest::StatusCode::UNAUTHORIZED),
|
||||
AccountError::InvalidSession
|
||||
));
|
||||
assert!(admission_error(reqwest::StatusCode::CONFLICT)
|
||||
.to_string()
|
||||
.contains("зарезервирован"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
//! Launcher releases form a separate trust boundary from modpack manifests.
|
||||
//! Only this module chooses update URLs. The webview receives display data and
|
||||
//! progress, never an updater resource, destination, signature or public key.
|
||||
use crate::operations::{Operation, UpdatePermits};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use minisign_verify::{PublicKey, Signature};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_updater::{Update, UpdaterBuilder, UpdaterExt};
|
||||
use url::Url;
|
||||
|
||||
pub(crate) const UPDATE_ENDPOINT: &str = "https://shacraft.ru/launcher/updates/stable.json";
|
||||
pub(crate) const MAX_METADATA_BYTES: usize = 192 * 1024;
|
||||
const MAX_PAYLOAD_BYTES: usize = 64 * 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 {
|
||||
#[default]
|
||||
Idle,
|
||||
Checking,
|
||||
Available,
|
||||
Downloading,
|
||||
Installing,
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct UpdateState {
|
||||
pub stage: Stage,
|
||||
pub candidate: Option<Update>,
|
||||
// Hold all mutation gates until the user restarts into the installed app.
|
||||
pub restart_permits: Option<UpdatePermits>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct LauncherUpdater {
|
||||
pub operation: Operation,
|
||||
pub state: Arc<Mutex<UpdateState>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UpdateProgress {
|
||||
pub stage: Stage,
|
||||
pub downloaded_bytes: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl LauncherUpdater {
|
||||
pub fn status<R: Runtime>(&self, app: &AppHandle<R>) -> Result<UpdateStatus, String> {
|
||||
let reason = unsupported_reason(app);
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
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
|
||||
.candidate
|
||||
.as_ref()
|
||||
.map(|update| update.version.clone()),
|
||||
notes: state
|
||||
.candidate
|
||||
.as_ref()
|
||||
.and_then(|update| update.body.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_stage(&self, stage: Stage) -> Result<(), String> {
|
||||
self.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?
|
||||
.stage = stage;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn installation_kind<R: Runtime>(app: &AppHandle<R>) -> InstallationKind {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let env = app.env();
|
||||
if let (Some(image), Some(directory), Ok(executable)) = (
|
||||
env.appimage.as_ref(),
|
||||
env.appdir.as_ref(),
|
||||
std::env::current_exe(),
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_appimage_supported(
|
||||
image: &std::path::Path,
|
||||
directory: &std::path::Path,
|
||||
executable: &std::path::Path,
|
||||
) -> bool {
|
||||
use std::io::Read;
|
||||
if !image.is_absolute()
|
||||
|| !directory.is_absolute()
|
||||
|| !executable.starts_with(directory)
|
||||
|| !image.is_file()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut header = [0_u8; 11];
|
||||
std::fs::File::open(image)
|
||||
.and_then(|mut file| file.read_exact(&mut header))
|
||||
.is_ok()
|
||||
&& &header[..4] == b"\x7fELF"
|
||||
&& &header[8..11] == b"AI\x02"
|
||||
}
|
||||
|
||||
pub(crate) fn public_key<R: Runtime>(app: &AppHandle<R>) -> Result<String, String> {
|
||||
app.config()
|
||||
.plugins
|
||||
.0
|
||||
.get("updater")
|
||||
.and_then(|value| value.get("pubkey"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| !key.is_empty())
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| "В этой сборке отсутствует ключ проверки обновлений.".into())
|
||||
}
|
||||
|
||||
/// Resolve the startup AppImage path once. Release metadata and IPC never
|
||||
/// select an installation destination; symlink launch shortcuts remain usable.
|
||||
pub(crate) fn installation_path<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
) -> Result<Option<std::path::PathBuf>, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if installation_kind(app) == InstallationKind::Deb {
|
||||
return Ok(None);
|
||||
}
|
||||
let image = app
|
||||
.env()
|
||||
.appimage
|
||||
.ok_or("Запустите AppImage, чтобы обновить лаунчер.")?;
|
||||
return std::fs::canonicalize(image)
|
||||
.map(Some)
|
||||
.map_err(|_| "Файл AppImage перемещён или недоступен. Запустите его снова.".into());
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trusted_builder<R: Runtime>(app: &AppHandle<R>) -> Result<UpdaterBuilder, String> {
|
||||
app.updater_builder()
|
||||
.endpoints(vec![Url::parse(UPDATE_ENDPOINT).expect("fixed update URL")])
|
||||
.map_err(|_| BAD_METADATA.to_string())
|
||||
.map(|builder| {
|
||||
builder
|
||||
.timeout(Duration::from_secs(20))
|
||||
.configure_client(|client| {
|
||||
client
|
||||
.https_only(true)
|
||||
.redirect(reqwest_updater::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(false)
|
||||
.danger_accept_invalid_hostnames(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn http_client(timeout: Duration) -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.https_only(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(timeout)
|
||||
.user_agent(concat!("ShaCraft-Launcher/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|_| "Не удалось подключиться к серверу обновлений.".into())
|
||||
}
|
||||
|
||||
fn checked_length(current: usize, next: usize, maximum: usize) -> Result<usize, String> {
|
||||
current
|
||||
.checked_add(next)
|
||||
.filter(|size| *size <= maximum)
|
||||
.ok_or_else(|| "Размер ответа сервера обновлений превышает допустимый.".into())
|
||||
}
|
||||
|
||||
async fn bounded_response(
|
||||
mut response: reqwest::Response,
|
||||
maximum: usize,
|
||||
mut progress: impl FnMut(u64, Option<u64>),
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if !response.status().is_success() || response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Err("Сервер обновлений временно недоступен. Повторите попытку позже.".into());
|
||||
}
|
||||
let total = response.content_length();
|
||||
if total.is_some_and(|length| length > maximum as u64) {
|
||||
return Err("Размер ответа сервера обновлений превышает допустимый.".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|_| {
|
||||
"Загрузка обновления прервалась. Проверьте соединение и повторите попытку.".to_string()
|
||||
})? {
|
||||
checked_length(bytes.len(), chunk.len(), maximum)?;
|
||||
bytes.extend_from_slice(&chunk);
|
||||
progress(bytes.len() as u64, total);
|
||||
}
|
||||
if total.is_some_and(|length| length != bytes.len() as u64) {
|
||||
return Err("Обновление загружено не полностью. Повторите попытку.".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn verify_signature(
|
||||
bytes: &[u8],
|
||||
encoded_signature: &str,
|
||||
encoded_key: &str,
|
||||
) -> Result<(), String> {
|
||||
if encoded_signature.len() > 4096 || encoded_key.len() > 4096 {
|
||||
return Err(BAD_SIGNATURE.into());
|
||||
}
|
||||
let key_bytes = STANDARD.decode(encoded_key).map_err(|_| BAD_SIGNATURE)?;
|
||||
let signature_bytes = STANDARD
|
||||
.decode(encoded_signature)
|
||||
.map_err(|_| BAD_SIGNATURE)?;
|
||||
let key = PublicKey::decode(std::str::from_utf8(&key_bytes).map_err(|_| BAD_SIGNATURE)?)
|
||||
.map_err(|_| BAD_SIGNATURE)?;
|
||||
let signature =
|
||||
Signature::decode(std::str::from_utf8(&signature_bytes).map_err(|_| BAD_SIGNATURE)?)
|
||||
.map_err(|_| BAD_SIGNATURE)?;
|
||||
key.verify(bytes, &signature, true)
|
||||
.map_err(|_| BAD_SIGNATURE.into())
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
let encoded = raw
|
||||
.get("signedPayload")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or(BAD_METADATA)?;
|
||||
if encoded.len() > MAX_PAYLOAD_BYTES * 4 / 3 + 4 {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let payload = STANDARD.decode(encoded).map_err(|_| BAD_METADATA)?;
|
||||
if payload.len() > MAX_PAYLOAD_BYTES {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let signature = raw
|
||||
.get("metadataSignature")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or(BAD_METADATA)?;
|
||||
verify_signature(&payload, signature, key).map_err(|_| BAD_METADATA)?;
|
||||
let parsed: Value = serde_json::from_slice(&payload).map_err(|_| BAD_METADATA)?;
|
||||
let signed = parsed.as_object().ok_or(BAD_METADATA)?;
|
||||
if signed.len() != 4 {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
for field in ["version", "notes", "pub_date", "platforms"] {
|
||||
if !signed.contains_key(field) || signed.get(field) != object.get(field) {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub(crate) fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
|
||||
let announced = metadata
|
||||
.get("version")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or(BAD_METADATA)?;
|
||||
let version = semver::Version::parse(announced).map_err(|_| BAD_METADATA)?;
|
||||
// Stable channel rejects prerelease/build aliases and noncanonical spellings.
|
||||
if !version.pre.is_empty() || !version.build.is_empty() || version.to_string() != announced {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let current = semver::Version::parse(current).map_err(|_| BAD_METADATA)?;
|
||||
Ok(version > current)
|
||||
}
|
||||
|
||||
fn require_platform(metadata: &Value, kind: InstallationKind) -> Result<String, String> {
|
||||
let os = if cfg!(target_os = "macos") {
|
||||
"darwin"
|
||||
} else {
|
||||
std::env::consts::OS
|
||||
};
|
||||
let target = format!("{os}-{}", std::env::consts::ARCH);
|
||||
let platforms = metadata
|
||||
.get("platforms")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or(BAD_METADATA)?;
|
||||
#[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()
|
||||
.map(|bundle| format!("{target}-{bundle}"))
|
||||
.chain(std::iter::once(target.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
targets
|
||||
.into_iter()
|
||||
.find(|target| platforms.contains_key(target))
|
||||
.ok_or_else(|| "Обновление для вашего формата установки пока не опубликовано.".into())
|
||||
}
|
||||
|
||||
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"
|
||||
|| url.host_str() != Some("shacraft.ru")
|
||||
|| url.port().is_some()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| filename.is_empty()
|
||||
|| !filename.as_bytes()[0].is_ascii_alphanumeric()
|
||||
|| !filename
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
|
||||
{
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let correct_extension = if cfg!(target_os = "linux") {
|
||||
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") {
|
||||
filename.ends_with(".exe") || filename.ends_with(".msi")
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !correct_extension {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
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.
|
||||
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)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
"Не удалось проверить обновления. Проверьте подключение к интернету.".to_string()
|
||||
})?;
|
||||
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)?;
|
||||
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)?
|
||||
.check_metadata(raw.clone())
|
||||
.map_err(updater_error)?
|
||||
.ok_or(BAD_METADATA)?;
|
||||
if update.raw_json != raw
|
||||
|| update.current_version != current
|
||||
|| Some(update.version.as_str()) != metadata.get("version").and_then(Value::as_str)
|
||||
{
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
// Retain the exact signed envelope with the native-only candidate.
|
||||
verified_metadata(&update.raw_json, key)?;
|
||||
validate_download_url(
|
||||
&update.download_url,
|
||||
&update.version,
|
||||
candidate_kind(&update),
|
||||
)?;
|
||||
Ok(Some(update))
|
||||
}
|
||||
|
||||
pub(crate) async fn download_verified(
|
||||
update: &Update,
|
||||
key: &str,
|
||||
progress: impl FnMut(u64, Option<u64>),
|
||||
) -> Result<Vec<u8>, String> {
|
||||
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())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
"Не удалось загрузить обновление. Проверьте подключение и повторите попытку."
|
||||
.to_string()
|
||||
})?;
|
||||
let bytes = bounded_response(response, MAX_ARTIFACT_BYTES, progress).await?;
|
||||
verify_signature(&bytes, &update.signature, key)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Keep verification adjacent to the only call that can replace the app. This
|
||||
/// also protects against an accidental mutation of bytes after downloading.
|
||||
pub(crate) fn install_verified(
|
||||
update: &Update,
|
||||
bytes: &[u8],
|
||||
key: &str,
|
||||
destination: Option<&std::path::Path>,
|
||||
) -> Result<(), String> {
|
||||
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. Проверьте свободное место и права на папку лаунчера."
|
||||
.into()
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = destination;
|
||||
update.install(bytes).map_err(updater_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri 2.11 moves the old AppImage away before writing the new one. Use our
|
||||
/// atomic-file primitive on Linux so interruption during writing leaves the
|
||||
/// old executable intact. Other OS installers stay with the official plugin.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn install_appimage_atomic(destination: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Write},
|
||||
os::unix::fs::PermissionsExt,
|
||||
};
|
||||
if bytes.len() < 11 || &bytes[..4] != b"\x7fELF" || &bytes[8..11] != b"AI\x02" {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"release is not a type-2 AppImage",
|
||||
));
|
||||
}
|
||||
let metadata = fs::symlink_metadata(destination)?;
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"AppImage must be a regular file",
|
||||
));
|
||||
}
|
||||
let parent =
|
||||
fs::File::open(destination.parent().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "AppImage has no parent")
|
||||
})?)?;
|
||||
let mut output = crate::storage::AtomicFile::new(destination)?;
|
||||
output.writer().write_all(bytes)?;
|
||||
output.writer().set_permissions(fs::Permissions::from_mode(
|
||||
metadata.permissions().mode() & 0o777,
|
||||
))?;
|
||||
output.commit()?;
|
||||
parent.sync_all()
|
||||
}
|
||||
|
||||
pub(crate) fn updater_error(error: tauri_plugin_updater::Error) -> String {
|
||||
match error {
|
||||
tauri_plugin_updater::Error::TargetNotFound(_) | tauri_plugin_updater::Error::TargetsNotFound(_) =>
|
||||
"Обновление для вашей платформы пока не опубликовано.".into(),
|
||||
tauri_plugin_updater::Error::Io(_) | tauri_plugin_updater::Error::TempDirNotOnSameMountPoint =>
|
||||
"Не удалось заменить файл лаунчера. Проверьте свободное место и права на папку приложения.".into(),
|
||||
tauri_plugin_updater::Error::Minisign(_) | tauri_plugin_updater::Error::Base64(_) |
|
||||
tauri_plugin_updater::Error::SignatureUtf8(_) => BAD_SIGNATURE.into(),
|
||||
_ => "Не удалось установить обновление. Повторите попытку или скачайте лаунчер с shacraft.ru.".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn artifact_url() -> &'static str {
|
||||
if cfg!(target_os = "linux") {
|
||||
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.AppImage"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.app.tar.gz"
|
||||
} else {
|
||||
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.exe"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_policy_pins_origin_version_plain_path_and_package_type() {
|
||||
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/"),
|
||||
artifact_url().replace("shacraft.ru/", "shacraft.ru:8443/"),
|
||||
artifact_url().replace("https://", "https://user@"),
|
||||
format!("{}?url=x", artifact_url()),
|
||||
format!("{}#x", artifact_url()),
|
||||
artifact_url().replace("/0.2.0/", "/0.1.0/"),
|
||||
artifact_url().replace("ShaCraft_", "%53haCraft_"),
|
||||
artifact_url().replace("ShaCraft_", "nested/ShaCraft_"),
|
||||
format!("{}.sh", artifact_url()),
|
||||
] {
|
||||
assert!(
|
||||
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());
|
||||
for version in ["0.1.2", "0.1.3"] {
|
||||
assert!(!newer_version(&serde_json::json!({"version":version}), "0.1.3").unwrap());
|
||||
}
|
||||
for version in ["v0.2.0", "0.2.0-test", "0.2.0+extra", "00.2.0", "../0.2.0"] {
|
||||
assert!(newer_version(&serde_json::json!({"version":version}), "0.1.3").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_size_limit_handles_missing_length_and_overflow() {
|
||||
assert_eq!(checked_length(3, 5, 8).unwrap(), 8);
|
||||
assert!(checked_length(3, 6, 8).is_err());
|
||||
assert!(checked_length(usize::MAX, 1, usize::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_oversized_metadata_proof_is_rejected() {
|
||||
assert!(verified_metadata(&serde_json::json!({"version":"0.2.0"}), "").is_err());
|
||||
let raw = serde_json::json!({"version":"0.2.0","notes":"","pub_date":"","platforms":{},
|
||||
"signedPayload":"A".repeat(MAX_PAYLOAD_BYTES * 2),"metadataSignature":""});
|
||||
assert!(verified_metadata(&raw, "").is_err());
|
||||
}
|
||||
|
||||
fn fixture() -> Value {
|
||||
// Public test key/signatures only. The ephemeral private key was
|
||||
// discarded by the publisher tests and never enters this repository.
|
||||
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_metadata_and_artifact_signatures_pass_and_tampering_fails() {
|
||||
let fixture = fixture();
|
||||
let key = fixture["publicKey"].as_str().unwrap();
|
||||
let raw = &fixture["metadata"];
|
||||
assert!(verified_metadata(raw, key).is_ok());
|
||||
let signature = raw["platforms"]["linux-x86_64"]["signature"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
let bytes = fixture["artifactText"].as_str().unwrap().as_bytes();
|
||||
assert!(verify_signature(bytes, signature, key).is_ok());
|
||||
let mut corrupt = bytes.to_vec();
|
||||
corrupt[0] ^= 1;
|
||||
assert!(verify_signature(&corrupt, signature, key).is_err());
|
||||
assert!(
|
||||
verify_signature(bytes, &STANDARD.encode("invalid minisign signature"), key).is_err()
|
||||
);
|
||||
assert!(
|
||||
verify_signature(bytes, signature, &STANDARD.encode("invalid public key")).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relabelling_a_signed_old_artifact_or_changing_signed_fields_fails() {
|
||||
let fixture = fixture();
|
||||
let key = fixture["publicKey"].as_str().unwrap();
|
||||
for field in ["version", "notes", "pub_date", "platforms"] {
|
||||
let mut raw = fixture["metadata"].clone();
|
||||
raw[field] = Value::String("tampered".into());
|
||||
assert!(verified_metadata(&raw, key).is_err(), "{field}");
|
||||
}
|
||||
let mut raw = fixture["metadata"].clone();
|
||||
let mut payload: Value = serde_json::from_slice(
|
||||
&STANDARD
|
||||
.decode(raw["signedPayload"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
raw["version"] = Value::String("99.0.0".into());
|
||||
payload["version"] = Value::String("99.0.0".into());
|
||||
raw["signedPayload"] =
|
||||
Value::String(STANDARD.encode(serde_json::to_vec(&payload).unwrap()));
|
||||
assert!(verified_metadata(&raw, key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_platform_is_not_reported_as_latest() {
|
||||
assert!(require_platform(
|
||||
&serde_json::json!({"platforms":{}}),
|
||||
InstallationKind::Appimage
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_support_requires_actual_appimage_and_matching_appdir() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let image = directory.path().join("launcher.AppImage");
|
||||
let appdir = directory.path().join(".mount_test");
|
||||
let binary = appdir.join("usr/bin/shacraft-launcher");
|
||||
std::fs::write(&image, b"\x7fELF\x02\x01\x01\0AI\x02rest").unwrap();
|
||||
assert!(linux_appimage_supported(&image, &appdir, &binary));
|
||||
assert!(!linux_appimage_supported(
|
||||
&image,
|
||||
&appdir,
|
||||
&directory.path().join("raw-binary")
|
||||
));
|
||||
std::fs::write(&image, b"not a valid AppImage").unwrap();
|
||||
assert!(!linux_appimage_supported(&image, &appdir, &binary));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn appimage_replacement_preserves_old_on_error_and_retains_executable_mode() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let image = directory.path().join("launcher.AppImage");
|
||||
let old = b"\x7fELF\x02\x01\x01\0AI\x02old";
|
||||
let new = b"\x7fELF\x02\x01\x01\0AI\x02new";
|
||||
std::fs::write(&image, old).unwrap();
|
||||
std::fs::set_permissions(&image, std::fs::Permissions::from_mode(0o751)).unwrap();
|
||||
assert!(install_appimage_atomic(&image, b"wrong package").is_err());
|
||||
assert_eq!(std::fs::read(&image).unwrap(), old);
|
||||
assert!(install_appimage_atomic(&directory.path().join("missing"), new).is_err());
|
||||
assert_eq!(std::fs::read(&image).unwrap(), old);
|
||||
install_appimage_atomic(&image, new).unwrap();
|
||||
assert_eq!(std::fs::read(&image).unwrap(), new);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&image).unwrap().permissions().mode() & 0o777,
|
||||
0o751
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
/// Read-only production HTTPS requests; replacement occurs ONLY in a new
|
||||
/// temporary copy of the path supplied by the operator. No QA switches or
|
||||
/// alternative endpoints are compiled into a distributed launcher.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires published signed update and SHACRAFT_UPDATER_TEST_IMAGE pointing to an old AppImage"]
|
||||
fn live_signed_update_replaces_only_temporary_copy() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let source = std::path::PathBuf::from(
|
||||
std::env::var_os("SHACRAFT_UPDATER_TEST_IMAGE").expect("old AppImage path"),
|
||||
);
|
||||
assert!(source.is_file());
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let destination = directory.path().join("isolated-old.AppImage");
|
||||
std::fs::copy(&source, &destination).unwrap();
|
||||
let old_hash = Sha256::digest(std::fs::read(&destination).unwrap());
|
||||
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
let mut context = tauri::test::mock_context(tauri::test::noop_assets());
|
||||
context
|
||||
.config_mut()
|
||||
.plugins
|
||||
.0
|
||||
.insert("updater".into(), config["plugins"]["updater"].clone());
|
||||
context.package_info_mut().version = "0.1.2".parse().unwrap();
|
||||
let app = tauri::test::mock_builder()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.build(context)
|
||||
.unwrap();
|
||||
let key = public_key(app.handle()).unwrap();
|
||||
let builder = trusted_builder(app.handle())
|
||||
.unwrap()
|
||||
.executable_path(&destination);
|
||||
tauri::async_runtime::block_on(async {
|
||||
let update = check_candidate(builder, &key, "0.1.2", InstallationKind::Appimage)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("newer published version");
|
||||
let mut bytes = download_verified(&update, &key, |_, _| {}).await.unwrap();
|
||||
let new_hash = Sha256::digest(&bytes);
|
||||
assert_ne!(old_hash, new_hash);
|
||||
bytes[0] ^= 1;
|
||||
assert!(install_verified(&update, &bytes, &key, Some(&destination)).is_err());
|
||||
assert_eq!(
|
||||
old_hash,
|
||||
Sha256::digest(std::fs::read(&destination).unwrap())
|
||||
);
|
||||
bytes[0] ^= 1;
|
||||
install_verified(&update, &bytes, &key, Some(&destination)).unwrap();
|
||||
assert_eq!(
|
||||
new_hash,
|
||||
Sha256::digest(std::fs::read(&destination).unwrap())
|
||||
);
|
||||
assert_eq!(old_hash, Sha256::digest(std::fs::read(&source).unwrap()));
|
||||
println!(
|
||||
"Verified signed update {} and tamper rejection; replaced only isolated copy",
|
||||
update.version
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ShaCraft Launcher",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.6",
|
||||
"identifier": "ru.shacraft.launcher",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
@@ -35,6 +36,26 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": [
|
||||
"libwebkit2gtk-4.1-0",
|
||||
"libgtk-3-0",
|
||||
"pkexec"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDk0N0MzODEwMjg1OUVCNDEKUldSQjYxa29FRGg4bEdKSkFWUzZUNDZhRFN4cGIwL0FvVnl0blhrOWtSMWhSOWxHMkU1aGs5L2oK",
|
||||
"endpoints": [
|
||||
"https://shacraft.ru/launcher/updates/stable.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgM0FGNTgxMjY4QTBERUU5NgpSV1NXN2cyS0pvSDFPczhocEIzTmp1bjM1TnRRWWE1QnIyck00bDRmZW1sQlphQXY2MTZvTWcwZgo=",
|
||||
"metadata": {
|
||||
"notes": "Проверка обновления",
|
||||
"platforms": {
|
||||
"linux-x86_64": {
|
||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVzdnMktKb0gxT2dZZmwrT0V0b0pYbDdYU3dSang1TXJNZXdtTDZvUVNvU1FMU2ZsajJkL2d4bXlOVDdQNmt4eEExeUtGcG1zckNWcEhPVXd4TnBJOU9ublZWbHk0NFFjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTk1ODU1CWZpbGU6Zml4dHVyZS5BcHBJbWFnZQloYXNoZWQKdWxLSklQR3pabTcxdkNaUmM3d3FHbTRRSDI5dzY4UEU0QXY3NE9MazVWdGRPallpOTQ0a2RSK1AyRDBaeEZ5eGdERFgvbmVtZEluUXc4UFdUOWV6Qnc9PQo=",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.3/fixture.AppImage"
|
||||
}
|
||||
},
|
||||
"pub_date": "2026-09-10T00:00:00Z",
|
||||
"version": "0.1.3",
|
||||
"signedPayload": "eyJub3RlcyI6ItCf0YDQvtCy0LXRgNC60LAg0L7QsdC90L7QstC70LXQvdC40Y8iLCJwbGF0Zm9ybXMiOnsibGludXgteDg2XzY0Ijp7InNpZ25hdHVyZSI6ImRXNTBjblZ6ZEdWa0lHTnZiVzFsYm5RNklITnBaMjVoZEhWeVpTQm1jbTl0SUcxcGJtbHphV2R1SUhObFkzSmxkQ0JyWlhrS1VsVlRWemRuTWt0S2IwZ3hUMmRaWm13clQwVjBiMHBZYkRkWVUzZFNhbmcxVFhKTlpYZHRURFp2VVZOdlUxRk1VMlpzYWpKa0wyZDRiWGxPVkRkUU5tdDRlRUV4ZVV0R2NHMXpja05XY0VoUFZYZDRUbkJKT1U5dWJsWldiSGswTkZGalBRcDBjblZ6ZEdWa0lHTnZiVzFsYm5RNklIUnBiV1Z6ZEdGdGNEb3hOemc0T1RrMU9EVTFDV1pwYkdVNlptbDRkSFZ5WlM1QmNIQkpiV0ZuWlFsb1lYTm9aV1FLZFd4TFNrbFFSM3BhYlRjeGRrTmFVbU0zZDNGSGJUUlJTREk1ZHpZNFVFVTBRWFkzTkU5TWF6VldkR1JQYWxscE9UUTBhMlJTSzFBeVJEQmFlRVo1ZUdkRVJGZ3ZibVZ0WkVsdVVYYzRVRmRVT1dWNlFuYzlQUW89IiwidXJsIjoiaHR0cHM6Ly9zaGFjcmFmdC5ydS9kb3dubG9hZHMvc2hhY3JhZnQtbGF1bmNoZXIvMC4xLjMvZml4dHVyZS5BcHBJbWFnZSJ9fSwicHViX2RhdGUiOiIyMDI2LTA5LTEwVDAwOjAwOjAwWiIsInZlcnNpb24iOiIwLjEuMyJ9",
|
||||
"metadataSignature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVzdnMktKb0gxT3YrcXkzN0haeGZoeHBML0pMdXJYSXRjSE1vQ2VPZkg3bFpHZjRHbWVzOG1wdlJLcWRxUlJIaW11NElydkcxMk5jWStEMGtZSEI5UXAwak1TLzdjRXdnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTk1ODU1CWZpbGU6cGF5bG9hZC5qc29uCWhhc2hlZApEMGVQWFJCQW9RVTFaRnNDbG8xMnZOekpHcy9Pb0xEN0hHaDFiMkxJQ294WDBkeEszY0s2aGl3QWMzWEtFdmtIakRxOEx4VlF5UHJoZnpYSDJ1ZEdBQT09Cg=="
|
||||
},
|
||||
"artifactText": "isolated ShaCraft updater fixture; not an executable"
|
||||
}
|
||||
+23
-6
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { FeedbackDialog, type Feedback } from './components/FeedbackDialog'
|
||||
import { Library } from './components/Library'
|
||||
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
|
||||
import { PlayDock } from './components/PlayDock'
|
||||
@@ -8,6 +9,7 @@ import { Titlebar } from './components/Titlebar'
|
||||
import { servers } from './data/servers'
|
||||
import { useAccount } from './hooks/useAccount'
|
||||
import { useLauncher } from './hooks/useLauncher'
|
||||
import { useLauncherUpdate } from './hooks/useLauncherUpdate'
|
||||
import { useServerStatus } from './hooks/useServerStatus'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { isNative } from './services/native'
|
||||
@@ -18,6 +20,7 @@ export function App() {
|
||||
const [selected, setSelected] = useState(servers[0])
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [windowError, setWindowError] = useState<string | null>(null)
|
||||
const [errorFeedback, setErrorFeedback] = useState<Feedback | null>(null)
|
||||
const preferences = useSettings()
|
||||
const session = useAccount()
|
||||
const launcher = useLauncher()
|
||||
@@ -31,13 +34,19 @@ export function App() {
|
||||
const access = launchAccess(session.account)
|
||||
const checking = desktop && (!profile || profile.status === 'checking')
|
||||
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error
|
||||
const disabled = !desktop || busy || session.busy || access === 'loading' ||
|
||||
const updater = useLauncherUpdate(busy || session.busy || preferences.saving || session.recoveryCodes.length > 0)
|
||||
const updateLocked = updater.locksOperations
|
||||
const disabled = !desktop || busy || updateLocked || session.busy || access === 'loading' ||
|
||||
(access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady))
|
||||
const repairDisabled = !desktop || busy || checking
|
||||
const repairDisabled = !desktop || busy || updateLocked || checking
|
||||
const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
|
||||
useEffect(() => {
|
||||
if (error) setErrorFeedback({ kind: 'error', title: 'Ошибка лаунчера', message: error })
|
||||
}, [error])
|
||||
|
||||
let label = 'Играть'
|
||||
if (!desktop) label = 'В приложении'
|
||||
else if (updateLocked) label = updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Перезапустите лаунчер' : 'Обновляем лаунчер…'
|
||||
else if (operation.phase === 'running') label = 'Игра запущена'
|
||||
else if (operation.phase === 'launching') label = 'Запускаем…'
|
||||
else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…'
|
||||
@@ -61,7 +70,7 @@ export function App() {
|
||||
<Titlebar host={launcher.host} onError={setWindowError} />
|
||||
<div className="workspace" inert={session.recoveryCodes.length > 0}>
|
||||
<Library selected={selected} profiles={launcher.profiles} account={session.account}
|
||||
native={desktop} locked={busy || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
|
||||
native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
|
||||
<ServerStage server={selected} status={serverStatus}>
|
||||
<PlayDock server={selected} operation={operation} profile={profile}
|
||||
memoryGb={preferences.settings.memoryMb / 1024} native={desktop}
|
||||
@@ -70,9 +79,17 @@ export function App() {
|
||||
onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
|
||||
</ServerStage>
|
||||
</div>
|
||||
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
|
||||
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
|
||||
{desktop && (updater.state.status?.version || updateLocked) && !settingsOpen && !session.recoveryCodes.length &&
|
||||
<button className="update-banner" onClick={() => setSettingsOpen(true)}>
|
||||
<span className="update-banner-dot" />
|
||||
{updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Обновление установлено · перезапустить'
|
||||
: updateLocked ? 'Обновляем ShaCraft Launcher…' : `ShaCraft Launcher ${updater.state.status?.version} · обновить`}
|
||||
</button>}
|
||||
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy || updateLocked} preferences={preferences}
|
||||
session={session} updater={updater} host={launcher.host} java={launcher.java} onClose={closeSettings} />
|
||||
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
|
||||
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
|
||||
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,15 +41,15 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
|
||||
</form>
|
||||
)}
|
||||
{account && !linkedNickname && (
|
||||
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
|
||||
<span><strong>Игровой ник</strong><small>Общий ник ShaCraft</small></span>
|
||||
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
|
||||
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
|
||||
<small>Подтвердите владение ником на сервере Aeronautics.</small>
|
||||
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
|
||||
</label>
|
||||
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник'}</span>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</span>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export interface Feedback {
|
||||
kind: 'info' | 'success' | 'error'
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function FeedbackDialog({ feedback, onDismiss }: { feedback: Feedback | null; onDismiss: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const visible = feedback !== null
|
||||
useEffect(() => {
|
||||
const element = dialog.current
|
||||
if (!visible || !element) return
|
||||
const previous = document.activeElement
|
||||
element.showModal()
|
||||
return () => {
|
||||
element.close()
|
||||
if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
|
||||
}
|
||||
}, [visible])
|
||||
if (!feedback) return null
|
||||
return (
|
||||
<dialog ref={dialog} className={`feedback-dialog ${feedback.kind}`} aria-labelledby="feedback-title"
|
||||
aria-describedby="feedback-message" onCancel={(event) => { event.preventDefault(); onDismiss() }}
|
||||
onKeyDown={(event) => event.stopPropagation()}>
|
||||
<div aria-live={feedback.kind === 'error' ? 'assertive' : 'polite'} aria-atomic="true">
|
||||
<h2 id="feedback-title">{feedback.title}</h2>
|
||||
<p id="feedback-message">{feedback.message}</p>
|
||||
</div>
|
||||
<button type="button" autoFocus onClick={onDismiss}>Понятно</button>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ArrowDownToLine, RefreshCw } from 'lucide-react'
|
||||
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||
import { isNative } from '../services/native'
|
||||
import { updatePercent } from '../state/updater'
|
||||
|
||||
export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof useLauncherUpdate> }) {
|
||||
const { state, blocked, eventsReady, eventError } = updater
|
||||
const { phase, status, error } = state
|
||||
const percent = updatePercent(state.progress)
|
||||
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">
|
||||
<h3 id="launcher-update-title">ShaCraft Launcher</h3>
|
||||
{status?.currentVersion && <span>{status.currentVersion}</span>}
|
||||
</div>
|
||||
<div className="launcher-update-status" aria-live="polite">
|
||||
{!isNative() ? <p>Обновления доступны в приложении лаунчера.</p>
|
||||
: ready ? <p className="update-success">Обновление установлено. Перезапустите лаунчер.</p>
|
||||
: 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={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' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
|
||||
</button> : <>
|
||||
{status?.supported && status.version && <button className="update-primary"
|
||||
disabled={blocked || working || checking || !eventsReady} onClick={() => void updater.install()}>
|
||||
<ArrowDownToLine />{working ? 'Обновляем…' : 'Обновить'}
|
||||
</button>}
|
||||
{status?.supported !== false && <button className="update-check" disabled={checking || working} onClick={() => void updater.check()}>
|
||||
{checking ? 'Проверяем…' : error ? 'Повторить проверку' : 'Проверить обновления'}
|
||||
</button>}
|
||||
</>}
|
||||
</div>}
|
||||
{blocked && status?.supported && status.version && !working && <p className="update-hint">Завершите игру и текущие операции, чтобы обновить лаунчер.</p>}
|
||||
</section>
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
|
||||
<button className="rail-button active" aria-label="Настройки" onClick={onSettings}><Settings /></button>
|
||||
</nav>
|
||||
<aside className="library-panel">
|
||||
<div className="library-heading"><p>Сборки</p><span>{servers.length} доступна</span></div>
|
||||
<div className="library-heading"><p>Сборки</p><span>{servers.length} сборки</span></div>
|
||||
<div className="server-list">
|
||||
{servers.map((server) => {
|
||||
const profile = profiles[server.profileId]
|
||||
@@ -33,7 +33,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
|
||||
return (
|
||||
<button key={server.id} className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
|
||||
aria-pressed={selected.id === server.id} disabled={locked} onClick={() => onSelect(server)}>
|
||||
<span className={`server-glyph ${server.id}`} aria-hidden="true">A</span>
|
||||
<span className={`server-glyph ${server.id}`} aria-hidden="true">{server.name.slice(0, 1)}</span>
|
||||
<span className="server-copy"><strong>{server.name}</strong><small>{status}</small></span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function ServerStage({ server, status, children }: { server: Server; stat
|
||||
<p>{server.kicker}</p><h1>{server.name}</h1><h2>{server.subtitle}</h2>
|
||||
<dl className="hero-meta">
|
||||
<div><dt>Загрузчик</dt><dd>{server.loader}</dd></div>
|
||||
<div><dt>Java</dt><dd>Версия 21</dd></div>
|
||||
<div><dt>Java</dt><dd>Версия {server.id === 'minigames' ? 25 : 21}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
{children}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FolderOpen, Wrench, X } from 'lucide-react'
|
||||
import { AccountSettings } from './AccountSettings'
|
||||
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
|
||||
import type { useAccount } from '../hooks/useAccount'
|
||||
import type { useSettings } from '../hooks/useSettings'
|
||||
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||
import type { JavaInstallation, NativeHost } from '../types/launcher'
|
||||
|
||||
interface SettingsDrawerProps {
|
||||
@@ -12,10 +14,11 @@ interface SettingsDrawerProps {
|
||||
java: JavaInstallation | null | undefined
|
||||
preferences: ReturnType<typeof useSettings>
|
||||
session: ReturnType<typeof useAccount>
|
||||
updater: ReturnType<typeof useLauncherUpdate>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) {
|
||||
export function SettingsDrawer({ open, locked, host, java, preferences, session, updater, onClose }: SettingsDrawerProps) {
|
||||
const { settings, loaded, saving, error } = preferences
|
||||
const closeButton = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => {
|
||||
@@ -25,7 +28,7 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
if (event.key !== 'Tab') return
|
||||
const elements = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled)')
|
||||
const elements = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), summary')
|
||||
const first = elements?.[0]
|
||||
const last = elements?.[elements.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
|
||||
@@ -44,9 +47,10 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
|
||||
<aside className={`settings-drawer ${open ? 'open' : ''}`} inert={!open} aria-hidden={!open}
|
||||
role="dialog" aria-modal={open ? true : undefined} aria-labelledby="settings-title">
|
||||
<div className="drawer-title">
|
||||
<div><p>Настройки</p><h2 id="settings-title">Игра</h2></div>
|
||||
<div><p>Настройки</p><h2 id="settings-title">Лаунчер и игра</h2></div>
|
||||
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
|
||||
</div>
|
||||
<LauncherUpdateSettings updater={updater} />
|
||||
<label className="range-setting">
|
||||
<span><strong>Оперативная память</strong><b>{settings.memoryMb / 1024} ГБ</b></span>
|
||||
<input type="range" min="3" max="12" step="1" value={settings.memoryMb / 1024} disabled={!loaded || locked}
|
||||
|
||||
@@ -3,11 +3,15 @@ import { test } from 'node:test'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AccountSettings } from './AccountSettings'
|
||||
import { RecoveryCodesModal } from './RecoveryCodesModal'
|
||||
import { ServerStage } from './ServerStage'
|
||||
import { Library } from './Library'
|
||||
import { servers } from '../data/servers'
|
||||
import type { useAccount } from '../hooks/useAccount'
|
||||
|
||||
function session(): ReturnType<typeof useAccount> {
|
||||
return {
|
||||
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
|
||||
feedback: null, dismissFeedback: () => {},
|
||||
linking: false, linkedNickname: null, authenticate: async () => true,
|
||||
logout: async () => {}, startLink: async () => {}, clearError: () => {},
|
||||
acknowledgeRecoveryCodes: () => {},
|
||||
@@ -30,7 +34,7 @@ test('linked identity is displayed read-only while an unlinked account offers ve
|
||||
doesNotMatch(linked, /<input|Привязать ник/)
|
||||
const unlinked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: { username: 'website_login', links: [] } }} locked={false} />)
|
||||
match(unlinked, /Привязать ник/)
|
||||
match(unlinked, /Aeronautics/)
|
||||
match(unlinked, /Общий ник ShaCraft/)
|
||||
})
|
||||
|
||||
test('recovery codes render only until explicitly acknowledged', () => {
|
||||
@@ -42,3 +46,15 @@ test('recovery codes render only until explicitly acknowledged', () => {
|
||||
match(html, /Я сохранил коды/)
|
||||
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
|
||||
})
|
||||
|
||||
test('both profiles remain selectable and display their own runtime requirements', () => {
|
||||
const library = renderToStaticMarkup(<Library selected={servers[1]!} profiles={{}} account={null}
|
||||
locked={false} native={false} onSelect={() => {}} onSettings={() => {}} />)
|
||||
match(library, /Aeronautics/)
|
||||
match(library, /Minigames/)
|
||||
equal((library.match(/class="server-row/g) ?? []).length, 2)
|
||||
const stage = (index: number) => renderToStaticMarkup(<ServerStage server={servers[index]!} status={null}>{null}</ServerStage>)
|
||||
match(stage(0), /Версия 21/)
|
||||
match(stage(1), /Версия 25/)
|
||||
match(stage(1), /Fabric 0.19.5/)
|
||||
})
|
||||
|
||||
@@ -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, /администратора|системном окне/)
|
||||
}
|
||||
})
|
||||
@@ -12,4 +12,9 @@ export const servers: readonly [Server, ...Server[]] = [
|
||||
loader: 'NeoForge 21.1.248',
|
||||
profileId: 'aeronautics',
|
||||
},
|
||||
{
|
||||
id: 'minigames', kicker: 'Лобби и арены', name: 'Minigames',
|
||||
subtitle: 'Небесные острова. Сражения на аренах SMASH.',
|
||||
version: '26.2 · Fabric 0.19.5', loader: 'Fabric 0.19.5', profileId: 'minigames',
|
||||
},
|
||||
]
|
||||
|
||||
+35
-59
@@ -1,24 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Feedback } from '../components/FeedbackDialog'
|
||||
import { createRequestScope, errorMessage } from '../services/async'
|
||||
import { isNative, native } from '../services/native'
|
||||
import { linkedNickname, validCredentials } from '../state/account'
|
||||
import { isValidNickname } from '../state/settings'
|
||||
import type { ShaCraftAccount } from '../types/launcher'
|
||||
|
||||
interface PendingLink {
|
||||
challengeId: number
|
||||
expiresAt: number
|
||||
isCurrent: () => boolean
|
||||
}
|
||||
|
||||
export function useAccount() {
|
||||
// undefined = restoring saved account; null = signed out.
|
||||
const [account, setAccount] = useState<ShaCraftAccount | null | undefined>(isNative() ? undefined : null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||
const [challenge, setChallenge] = useState<PendingLink | null>(null)
|
||||
const [linkMessage, setLinkMessage] = useState<string | null>(null)
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null)
|
||||
const reportLink = useCallback((message: string, kind: Feedback['kind'] = 'info') => {
|
||||
setLinkMessage(message)
|
||||
setFeedback({ kind, title: kind === 'error' ? 'Не удалось привязать ник' : 'Привязка игрового ника', message })
|
||||
}, [])
|
||||
const pending = useRef(false)
|
||||
const requests = useRef(createRequestScope())
|
||||
|
||||
@@ -35,44 +34,6 @@ export function useAccount() {
|
||||
return () => { active = false; requests.current.invalidate() }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!challenge) return
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const current = () => active && challenge.isCurrent()
|
||||
const poll = async () => {
|
||||
if (!current()) return
|
||||
if (Date.now() >= challenge.expiresAt) {
|
||||
setChallenge(null)
|
||||
setLinkMessage('Срок проверки истёк. Начните привязку ещё раз.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await native.linkStatus(challenge.challengeId)
|
||||
if (!current()) return
|
||||
if (result.status === 'verified') {
|
||||
const refreshed = await native.getAccount()
|
||||
if (!current()) return
|
||||
setAccount(refreshed)
|
||||
setChallenge(null)
|
||||
setLinkMessage(linkedNickname(refreshed) ? 'Ник подтверждён.' : 'Не удалось подтвердить привязку. Войдите снова.')
|
||||
} else if (result.status === 'expired' || result.status === 'conflict') {
|
||||
setChallenge(null)
|
||||
setLinkMessage(result.detail || 'Проверка завершилась. Попробуйте ещё раз.')
|
||||
} else {
|
||||
// One request at a time; dispose and logout cancel future polling.
|
||||
timer = setTimeout(() => { void poll() }, 3_000)
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!current()) return
|
||||
setChallenge(null)
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось проверить ник'))
|
||||
}
|
||||
}
|
||||
timer = setTimeout(() => { void poll() }, 3_000)
|
||||
return () => { active = false; clearTimeout(timer) }
|
||||
}, [challenge])
|
||||
|
||||
const authenticate = async (username: string, password: string, register: boolean) => {
|
||||
if (pending.current || account === undefined) return false
|
||||
if (!validCredentials(username, password)) {
|
||||
@@ -92,7 +53,6 @@ export function useAccount() {
|
||||
const result = await native.authenticate(username, password, register)
|
||||
if (!currentRequest()) return false
|
||||
setAccount(result.account)
|
||||
setChallenge(null)
|
||||
setLinkMessage(null)
|
||||
setRecoveryCodes(result.recoveryCodes)
|
||||
return true
|
||||
@@ -109,7 +69,6 @@ export function useAccount() {
|
||||
if (!isNative() || pending.current) return
|
||||
pending.current = true
|
||||
requests.current.invalidate()
|
||||
setChallenge(null)
|
||||
setLinkMessage(null)
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
@@ -126,26 +85,42 @@ export function useAccount() {
|
||||
}
|
||||
|
||||
const startLink = async (nickname: string) => {
|
||||
if (!isNative() || pending.current || challenge || !account) return
|
||||
if (pending.current) return
|
||||
if (!isNative()) {
|
||||
reportLink('Привязка доступна в приложении лаунчера.', 'error')
|
||||
return
|
||||
}
|
||||
if (!account) {
|
||||
reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error')
|
||||
return
|
||||
}
|
||||
nickname = nickname.trim()
|
||||
if (!isValidNickname(nickname)) {
|
||||
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
|
||||
reportLink('Ник: 3–16 латинских букв, цифр или _', 'error')
|
||||
return
|
||||
}
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setLinkMessage('Создаём проверку…')
|
||||
setError(null)
|
||||
reportLink('Проверяем аккаунт и закрепляем ник…')
|
||||
requests.current.invalidate()
|
||||
const currentRequest = requests.current.capture()
|
||||
try {
|
||||
const started = await native.startLink(nickname)
|
||||
const refreshed = await native.getAccount()
|
||||
if (!currentRequest()) return
|
||||
setLinkMessage(started.registered_on_server
|
||||
? 'Зайдите на Aeronautics с этим ником и выполните /login.'
|
||||
: 'Зайдите на Aeronautics с этим ником и выполните /register.')
|
||||
setChallenge({ challengeId: started.challenge_id,
|
||||
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: currentRequest })
|
||||
setAccount(refreshed)
|
||||
if (!refreshed) {
|
||||
reportLink('Сессия завершена или аккаунт удалён. Войдите в ShaCraft снова; если аккаунт удалён, создайте новый.', 'error')
|
||||
return
|
||||
}
|
||||
const linkedAccount = await native.claimNickname(nickname)
|
||||
if (!currentRequest()) return
|
||||
setAccount(linkedAccount)
|
||||
const confirmed = linkedNickname(linkedAccount)
|
||||
reportLink(confirmed ? `Ник ${confirmed} закреплён за аккаунтом. Теперь можно запускать игру.`
|
||||
: 'Не удалось получить закреплённый ник. Войдите снова.', confirmed ? 'success' : 'error')
|
||||
} catch (reason) {
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
|
||||
if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error')
|
||||
} finally {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
@@ -153,7 +128,8 @@ export function useAccount() {
|
||||
}
|
||||
|
||||
return {
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: false,
|
||||
feedback, dismissFeedback: () => setFeedback(null),
|
||||
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
|
||||
clearError: () => setError(null),
|
||||
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react'
|
||||
import { errorMessage, singleFlight } from '../services/async'
|
||||
import { isNative, native, watchLauncherUpdate } from '../services/native'
|
||||
import { initialUpdaterState, updateBlocksOperations, updaterReducer } from '../state/updater'
|
||||
|
||||
// StrictMode re-runs effects; share the pending native check without installing
|
||||
// anything. Manual checks always make a fresh request.
|
||||
const startupCheck = singleFlight(async () => {
|
||||
const status = await native.updateStatus()
|
||||
if (!status.supported || (status.stage && ['downloading', 'installing', 'ready'].includes(status.stage))) return { status, checked: false, error: null }
|
||||
try {
|
||||
return { status: await native.checkUpdate(), checked: true, error: null }
|
||||
} catch (error) {
|
||||
return { status, checked: false, error: errorMessage(error, 'Не удалось проверить обновления. Повторите попытку.') }
|
||||
}
|
||||
})
|
||||
|
||||
export function useLauncherUpdate(blocked: boolean) {
|
||||
const [state, dispatch] = useReducer(updaterReducer, initialUpdaterState)
|
||||
const [eventsReady, setEventsReady] = useState(false)
|
||||
const [eventError, setEventError] = useState<string | null>(null)
|
||||
const pending = useRef(false)
|
||||
const mounted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
mounted.current = true
|
||||
const subscription = watchLauncherUpdate((progress) => {
|
||||
if (active) dispatch({ type: 'progress', progress })
|
||||
})
|
||||
void subscription.ready.then(() => {
|
||||
if (active) { setEventsReady(true); setEventError(null) }
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setEventError(errorMessage(reason, 'Не удалось подключить события обновления. Перезапустите лаунчер.'))
|
||||
})
|
||||
pending.current = true
|
||||
void startupCheck().then((result) => {
|
||||
if (active) dispatch({ type: 'loaded', ...result })
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
|
||||
}).finally(() => { if (active) pending.current = false })
|
||||
return () => { active = false; mounted.current = false; subscription.dispose() }
|
||||
}, [])
|
||||
|
||||
const check = async () => {
|
||||
if (!isNative() || pending.current || updateBlocksOperations(state)) return
|
||||
pending.current = true
|
||||
dispatch({ type: 'check' })
|
||||
try {
|
||||
const status = await native.checkUpdate()
|
||||
if (mounted.current) dispatch({ type: 'loaded', status, checked: true })
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
if (!isNative() || pending.current || blocked || !eventsReady || state.phase !== 'idle' ||
|
||||
!state.status?.supported || !state.status.version) return
|
||||
pending.current = true
|
||||
dispatch({ type: 'install' })
|
||||
try {
|
||||
await native.installUpdate()
|
||||
if (mounted.current) dispatch({ type: 'ready' })
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось установить обновление. Повторите попытку.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
const restart = async () => {
|
||||
if (!isNative() || pending.current || blocked || state.phase !== 'ready') return
|
||||
pending.current = true
|
||||
dispatch({ type: 'restart' })
|
||||
try {
|
||||
await native.restartAfterUpdate()
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось перезапустить лаунчер. Закройте его и откройте снова.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
return { state, eventsReady, eventError, blocked, locksOperations: updateBlocksOperations(state), check, install, restart }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
|
||||
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
|
||||
ShaCraftAccount, ShaCraftLoginResult, SyncResult,
|
||||
LauncherUpdateStatus, LauncherUpdateProgress,
|
||||
} from '../types/launcher'
|
||||
|
||||
export const isNative = () => typeof window !== 'undefined' && isTauri()
|
||||
@@ -26,10 +27,15 @@ export const native = {
|
||||
accountRequests.enqueue(() => invoke<ShaCraftLoginResult>('shacraft_authenticate', { username, password, register })),
|
||||
logout: () => accountRequests.enqueue(() => invoke<void>('shacraft_logout')),
|
||||
startLink: (nickname: string) => accountRequests.enqueue(() => invoke<LinkChallenge>('shacraft_start_link', { nickname })),
|
||||
claimNickname: (nickname: string) => accountRequests.enqueue(() => invoke<ShaCraftAccount>('shacraft_claim_nickname', { nickname })),
|
||||
linkStatus: (challengeId: number) => accountRequests.enqueue(() => invoke<LinkStatus>('shacraft_link_status', { challengeId })),
|
||||
serverStatus: (profileId: string) => invoke<ServerStatus>('get_server_status', { profileId }),
|
||||
installGame: (profileId: string) => invoke<void>('ensure_game_installed', { profileId }),
|
||||
launchGame: (profileId: string) => invoke<void>('launch_game', { profileId }),
|
||||
updateStatus: () => invoke<LauncherUpdateStatus>('get_launcher_update_status'),
|
||||
checkUpdate: () => invoke<LauncherUpdateStatus>('check_launcher_update'),
|
||||
installUpdate: () => invoke<void>('install_launcher_update'),
|
||||
restartAfterUpdate: () => invoke<void>('restart_launcher_after_update'),
|
||||
}
|
||||
|
||||
export const windowControls = {
|
||||
@@ -47,3 +53,9 @@ export function watchGame(handlers: {
|
||||
listen<GameExitedPayload>('game-exited', ({ payload }) => handlers.exited(payload)),
|
||||
])
|
||||
}
|
||||
|
||||
export function watchLauncherUpdate(progress: (payload: LauncherUpdateProgress) => void) {
|
||||
return createSubscription([
|
||||
listen<LauncherUpdateProgress>('launcher-update-progress', ({ payload }) => progress(payload)),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ShaCraftAccount } from '../types/launcher'
|
||||
import { isValidNickname } from './settings'
|
||||
|
||||
/** Only the authenticated account's verified Aeronautics link selects a name. */
|
||||
/** Only the authenticated account's verified aoc link selects the shared network nickname. */
|
||||
export function linkedNickname(account: ShaCraftAccount | null | undefined): string | null {
|
||||
const nickname = account?.links.find((link) => link.server_id === 'aoc')?.mc_username
|
||||
return nickname && isValidNickname(nickname) ? nickname : null
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { equal, match } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { initialUpdaterState, updateBlocksOperations, updatePercent, updaterReducer } from './updater.ts'
|
||||
|
||||
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
|
||||
status: { currentVersion: '0.1.3', supported: true, version: '0.1.4' } })
|
||||
const downloading = updaterReducer(available, { type: 'install' })
|
||||
|
||||
test('checking and available update allow play; installation locks operations until restart', () => {
|
||||
equal(updateBlocksOperations(available), false)
|
||||
equal(updateBlocksOperations(updaterReducer(available, { type: 'check' })), false)
|
||||
equal(updateBlocksOperations(downloading), true)
|
||||
const ready = updaterReducer(downloading, { type: 'ready' })
|
||||
equal(updateBlocksOperations(ready), true)
|
||||
equal(updaterReducer(ready, { type: 'check' }), ready)
|
||||
equal(updaterReducer(ready, { type: 'install' }), ready)
|
||||
const restarting = updaterReducer(ready, { type: 'restart' })
|
||||
equal(updateBlocksOperations(restarting), true)
|
||||
})
|
||||
|
||||
test('an installation failure permits playing and retrying; a restart failure retains the lock', () => {
|
||||
const failed = updaterReducer(downloading, { type: 'failed', error: 'Подпись не совпадает' })
|
||||
equal(updateBlocksOperations(failed), false)
|
||||
equal(failed.status?.version, '0.1.4')
|
||||
const retry = updaterReducer(failed, { type: 'install' })
|
||||
equal(retry.error, null)
|
||||
equal(retry.phase, 'downloading')
|
||||
const ready = updaterReducer(retry, { type: 'ready' })
|
||||
const restartError = updaterReducer(updaterReducer(ready, { type: 'restart' }), { type: 'failed', error: 'Перезапустите вручную' })
|
||||
equal(restartError.phase, 'ready')
|
||||
match(restartError.error ?? '', /вручную/)
|
||||
equal(updateBlocksOperations(restartError), true)
|
||||
})
|
||||
|
||||
test('late progress cannot undo completion or a failed installation', () => {
|
||||
const installing = updaterReducer(downloading, { type: 'progress', progress: { stage: 'installing', downloadedBytes: 20 } })
|
||||
equal(updaterReducer(installing, { type: 'progress', progress: { stage: 'downloading', downloadedBytes: 10 } }), installing)
|
||||
const ready = updaterReducer(installing, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } })
|
||||
equal(updaterReducer(ready, { type: 'progress', progress: { stage: 'installing', downloadedBytes: 20 } }), ready)
|
||||
const failed = updaterReducer(downloading, { type: 'failed', error: 'Сбой сети' })
|
||||
equal(updaterReducer(failed, { type: 'ready' }), failed)
|
||||
equal(updaterReducer(failed, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } }), failed)
|
||||
})
|
||||
|
||||
test('native status restores an update after a webview reload; early events preserve readiness', () => {
|
||||
for (const stage of ['downloading', 'installing', 'ready'] as const) {
|
||||
const restored = updaterReducer(initialUpdaterState, { type: 'loaded', checked: false,
|
||||
status: { ...available.status!, stage } })
|
||||
equal(restored.phase, stage)
|
||||
equal(updateBlocksOperations(restored), true)
|
||||
}
|
||||
const earlyReady = updaterReducer(initialUpdaterState, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } })
|
||||
const staleStatus = updaterReducer(earlyReady, { type: 'loaded', checked: false,
|
||||
status: { ...available.status!, stage: 'downloading' } })
|
||||
equal(staleStatus.phase, 'ready')
|
||||
equal(staleStatus.status?.currentVersion, '0.1.3')
|
||||
})
|
||||
|
||||
test('unsupported package and no available version never enter installation', () => {
|
||||
const unsupported = updaterReducer(initialUpdaterState, { type: 'loaded', checked: false,
|
||||
status: { currentVersion: '0.1.3', supported: false, reason: 'Используйте AppImage' } })
|
||||
equal(updaterReducer(unsupported, { type: 'install' }), unsupported)
|
||||
const current = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
|
||||
status: { currentVersion: '0.1.3', supported: true } })
|
||||
equal(updaterReducer(current, { type: 'install' }), current)
|
||||
})
|
||||
|
||||
test('unknown, invalid and excessive progress cannot produce misleading percentages', () => {
|
||||
equal(updatePercent(null), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 1 }), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: NaN, totalBytes: 20 }), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 1, totalBytes: Infinity }), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 10, totalBytes: 20 }), 50)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 30, totalBytes: 20 }), 100)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: -1, totalBytes: 20 }), 0)
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { LauncherUpdateProgress, LauncherUpdateStatus } from '../types/launcher'
|
||||
|
||||
export interface UpdaterState {
|
||||
phase: 'loading' | 'idle' | 'checking' | 'downloading' | 'installing' | 'ready' | 'restarting'
|
||||
status: LauncherUpdateStatus | null
|
||||
progress: LauncherUpdateProgress | null
|
||||
error: string | null
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
export const initialUpdaterState: UpdaterState = {
|
||||
phase: 'loading', status: null, progress: null, error: null, checked: false,
|
||||
}
|
||||
|
||||
type UpdaterAction =
|
||||
| { type: 'loaded'; status: LauncherUpdateStatus; checked: boolean; error?: string | null }
|
||||
| { type: 'check' }
|
||||
| { type: 'install' }
|
||||
| { type: 'progress'; progress: LauncherUpdateProgress }
|
||||
| { type: 'ready' }
|
||||
| { type: 'restart' }
|
||||
| { type: 'failed'; error: string }
|
||||
|
||||
export function updateBlocksOperations(state: UpdaterState): boolean {
|
||||
return ['downloading', 'installing', 'ready', 'restarting'].includes(state.phase)
|
||||
}
|
||||
|
||||
export function updaterReducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
|
||||
switch (action.type) {
|
||||
case 'loaded':
|
||||
if (updateBlocksOperations(state)) return { ...state, status: action.status }
|
||||
return { ...state, phase: action.status.stage === 'ready' || action.status.stage === 'downloading' || action.status.stage === 'installing'
|
||||
? action.status.stage : 'idle', status: action.status,
|
||||
checked: action.checked, error: action.error ?? null }
|
||||
case 'check':
|
||||
return updateBlocksOperations(state) ? state : { ...state, phase: 'checking', error: null }
|
||||
case 'install':
|
||||
return state.phase !== 'idle' || !state.status?.supported || !state.status.version ? state
|
||||
: { ...state, phase: 'downloading', progress: null, error: null }
|
||||
case 'progress':
|
||||
if (state.phase !== 'loading' && state.phase !== 'downloading' && state.phase !== 'installing') return state
|
||||
// A delayed download event cannot revert an installation to downloading.
|
||||
if (state.phase === 'installing' && action.progress.stage === 'downloading') return state
|
||||
return { ...state, phase: action.progress.stage, progress: action.progress }
|
||||
case 'ready':
|
||||
return state.phase === 'downloading' || state.phase === 'installing'
|
||||
? { ...state, phase: 'ready', error: null } : state
|
||||
case 'restart':
|
||||
return state.phase === 'ready' ? { ...state, phase: 'restarting', error: null } : state
|
||||
case 'failed':
|
||||
return { ...state, phase: state.phase === 'ready' || state.phase === 'restarting' ? 'ready' : 'idle', error: action.error }
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePercent(progress: LauncherUpdateProgress | null): number | null {
|
||||
if (!progress || !progress.totalBytes || progress.totalBytes <= 0 ||
|
||||
!Number.isFinite(progress.totalBytes) || !Number.isFinite(progress.downloadedBytes)) return null
|
||||
return Math.max(0, Math.min(100, Math.round(progress.downloadedBytes / progress.totalBytes * 100)))
|
||||
}
|
||||
+33
-1
@@ -130,7 +130,7 @@ button:disabled { cursor: default; }
|
||||
.settings-drawer.open { transform: translateX(0); }
|
||||
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; }
|
||||
.drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; }
|
||||
.drawer-title h2 { margin: 0; font-family: 'Unbounded'; font-size: 25px; }
|
||||
.drawer-title h2 { margin: 0; font-family: 'Unbounded', sans-serif; font-size: 21px; }
|
||||
.drawer-title button { border: 0; background: #222923; width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; cursor: pointer; }
|
||||
.drawer-title button svg { width: 18px; }
|
||||
.range-setting { display: grid; background: #191f1a; padding: 18px; border-radius: 13px; margin-bottom: 12px; }
|
||||
@@ -157,6 +157,30 @@ button:disabled { cursor: default; }
|
||||
.account-hint { font-size: 11px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.recovery-codes { white-space: pre-line; font-size: 15px; max-height: 45vh; overflow: auto; }
|
||||
.settings-feedback button { padding: 6px 10px; background: var(--panel-2); border: 1px solid var(--line); cursor: pointer; }
|
||||
|
||||
.update-banner { position: absolute; top: 70px; right: 28px; z-index: 10; display: flex; align-items: center; gap: 9px; max-width: 440px; padding: 10px 14px; border: 1px solid #325939; border-radius: 9px; background: #17291bdc; color: #b6e5b8; font-size: 11px; cursor: pointer; box-shadow: 0 6px 24px #0003; }
|
||||
.update-banner:hover { background: #203a25; }
|
||||
.update-banner-dot { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--green); }
|
||||
.launcher-update { border: 1px solid #2c3e30; border-radius: 12px; padding: 17px; margin-bottom: 24px; background: #17201a; }
|
||||
.launcher-update-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.launcher-update-heading h3 { margin: 0; font-size: 13px; font-weight: 750; }
|
||||
.launcher-update-heading > span { color: #a6bba8; font-size: 11px; }
|
||||
.launcher-update p { font-size: 11px; line-height: 1.65; margin: 10px 0 0; color: #97a89a; }
|
||||
.launcher-update p.update-success { color: #b0e2b1; }
|
||||
.launcher-update .update-error { color: #eaae89; }
|
||||
.launcher-update progress { width: 100%; height: 5px; accent-color: var(--green); margin-top: 12px; }
|
||||
.update-actions { display: flex; align-items: stretch; flex-direction: column; gap: 9px; margin-top: 15px; }
|
||||
.update-actions button { display: flex; justify-content: center; align-items: center; gap: 8px; border: 1px solid #35543b; border-radius: 7px; min-height: 35px; padding: 8px 10px; font-size: 11px; cursor: pointer; }
|
||||
.update-actions button:disabled { cursor: default; opacity: .48; }
|
||||
.update-actions button svg { width: 14px; height: 14px; }
|
||||
.update-primary { background: var(--green); color: #102313; font-weight: 800; }
|
||||
.update-primary:hover:not(:disabled) { background: #91df93; }
|
||||
.update-check { background: transparent; color: #b6c6b9; }
|
||||
.update-check:hover:not(:disabled) { background: #263b2c; }
|
||||
.update-notes { margin-top: 10px; font-size: 11px; color: #b6c6b9; }
|
||||
.update-notes summary { cursor: pointer; }
|
||||
.update-notes p { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow: auto; }
|
||||
.launcher-update .update-hint { color: #a8a58e; }
|
||||
.status-error, .build-state .status-error, .text-setting > .status-error { color: #eea18f; overflow-wrap: anywhere; }
|
||||
.build-state .status-error { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
@@ -166,6 +190,14 @@ button:disabled { cursor: default; }
|
||||
.login-code { font-family: 'Unbounded'; font-size: 28px; letter-spacing: .08em; color: var(--green); background: #191f1a; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||
.login-url { color: var(--ice) !important; font-size: 11px !important; word-break: break-all; }
|
||||
|
||||
.feedback-dialog { width: min(440px, calc(100vw - 40px)); max-height: calc(100vh - 80px); overflow-y: auto; padding: 28px; margin: auto; color: #e9eee9; background: #151d17; border: 1px solid #456348; border-radius: 16px; box-shadow: 0 24px 90px #0009; }
|
||||
.feedback-dialog::backdrop { background: #050a07b8; }
|
||||
.feedback-dialog.error { border-color: #a46c54; }
|
||||
.feedback-dialog h2 { margin: 0 0 14px; font-size: 19px; }
|
||||
.feedback-dialog p { margin: 0 0 24px; color: #c1cbc2; font-size: 14px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.feedback-dialog button { width: 100%; padding: 12px; border: 0; border-radius: 9px; background: var(--green); color: #102112; font: inherit; font-weight: 650; cursor: pointer; }
|
||||
.feedback-dialog button:focus-visible { outline: 2px solid #e5ffe8; outline-offset: 4px; }
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.workspace { grid-template-columns: 58px 224px 1fr; }
|
||||
.brand { width: 240px; }
|
||||
|
||||
@@ -80,3 +80,19 @@ export interface GameExitedPayload {
|
||||
profileId: string
|
||||
exitCode: number | null
|
||||
}
|
||||
|
||||
export interface LauncherUpdateStatus {
|
||||
currentVersion: string
|
||||
supported: boolean
|
||||
installationKind?: 'appimage' | 'deb' | 'other'
|
||||
reason?: string | null
|
||||
version?: string | null
|
||||
notes?: string | null
|
||||
stage?: 'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'ready'
|
||||
}
|
||||
|
||||
export interface LauncherUpdateProgress {
|
||||
stage: 'downloading' | 'installing' | 'ready'
|
||||
downloadedBytes: number
|
||||
totalBytes?: number | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "6aa2854f314481a459be1189b02c65a2450789ab"
|
||||
},
|
||||
"path_in_vcs": "plugins/updater"
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
authors = ["Tauri Programme within The Commons Conservancy"]
|
||||
build = "build.rs"
|
||||
links = "tauri-plugin-updater"
|
||||
exclude = [
|
||||
"/banner.png",
|
||||
"/guest-js",
|
||||
"/package.json",
|
||||
"/rollup.config.js",
|
||||
"/tests",
|
||||
"/tsconfig.json",
|
||||
]
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "In-app updates for Tauri applications."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/tauri-apps/plugins-workspace"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
no-default-features = true
|
||||
features = ["zip"]
|
||||
|
||||
[package.metadata.platforms.support.windows]
|
||||
level = "full"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.linux]
|
||||
level = "full"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.macos]
|
||||
level = "full"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.android]
|
||||
level = "none"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.ios]
|
||||
level = "none"
|
||||
notes = ""
|
||||
|
||||
[features]
|
||||
default = [
|
||||
"rustls-tls",
|
||||
"system-proxy",
|
||||
"zip",
|
||||
]
|
||||
native-tls = ["reqwest/native-tls"]
|
||||
native-tls-vendored = ["reqwest/native-tls-vendored"]
|
||||
rustls-tls = [
|
||||
"reqwest/rustls-no-provider",
|
||||
"dep:rustls",
|
||||
]
|
||||
system-proxy = ["reqwest/system-proxy"]
|
||||
zip = [
|
||||
"dep:zip",
|
||||
"dep:tar",
|
||||
"dep:flate2",
|
||||
]
|
||||
|
||||
[lib]
|
||||
name = "tauri_plugin_updater"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies.base64]
|
||||
version = "0.22"
|
||||
|
||||
[dependencies.futures-util]
|
||||
version = "0.3"
|
||||
|
||||
[dependencies.http]
|
||||
version = "1"
|
||||
|
||||
[dependencies.infer]
|
||||
version = "0.19"
|
||||
|
||||
[dependencies.log]
|
||||
version = "0.4.21"
|
||||
|
||||
[dependencies.minisign-verify]
|
||||
version = "0.2"
|
||||
|
||||
[dependencies.percent-encoding]
|
||||
version = "2.3"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.13"
|
||||
features = [
|
||||
"json",
|
||||
"stream",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[dependencies.rustls]
|
||||
version = "0.23"
|
||||
features = ["ring"]
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[dependencies.semver]
|
||||
version = "1"
|
||||
features = ["serde"]
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1"
|
||||
features = ["derive"]
|
||||
|
||||
[dependencies.serde_json]
|
||||
version = "1"
|
||||
|
||||
[dependencies.tauri]
|
||||
version = "2.10"
|
||||
default-features = false
|
||||
|
||||
[dependencies.tempfile]
|
||||
version = "3.20"
|
||||
|
||||
[dependencies.thiserror]
|
||||
version = "2"
|
||||
|
||||
[dependencies.time]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"parsing",
|
||||
"formatting",
|
||||
]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1"
|
||||
|
||||
[dependencies.url]
|
||||
version = "2"
|
||||
|
||||
[build-dependencies.tauri-plugin]
|
||||
version = "2.5"
|
||||
features = ["build"]
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.dirs]
|
||||
version = "6"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.flate2]
|
||||
version = "1"
|
||||
optional = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.tar]
|
||||
version = "0.4"
|
||||
optional = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.flate2]
|
||||
version = "1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.osakit]
|
||||
version = "0.3"
|
||||
features = ["full"]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.tar]
|
||||
version = "0.4"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies.windows-sys]
|
||||
version = "0.60.0"
|
||||
features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Shell",
|
||||
]
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies.zip]
|
||||
version = "4"
|
||||
optional = true
|
||||
default-features = false
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
[package]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
description = "In-app updates for Tauri applications."
|
||||
edition = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
license = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
links = "tauri-plugin-updater"
|
||||
exclude = [
|
||||
"/banner.png",
|
||||
"/guest-js",
|
||||
"/package.json",
|
||||
"/rollup.config.js",
|
||||
"/tests",
|
||||
"/tsconfig.json",
|
||||
]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
no-default-features = true
|
||||
features = ["zip"]
|
||||
|
||||
[package.metadata.platforms.support]
|
||||
windows = { level = "full", notes = "" }
|
||||
linux = { level = "full", notes = "" }
|
||||
macos = { level = "full", notes = "" }
|
||||
android = { level = "none", notes = "" }
|
||||
ios = { level = "none", notes = "" }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { workspace = true, features = ["build"] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
tokio = "1"
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
] }
|
||||
rustls = { version = "0.23", default-features = false, features = [
|
||||
"ring",
|
||||
], optional = true }
|
||||
url = { workspace = true }
|
||||
http = "1"
|
||||
minisign-verify = "0.2"
|
||||
time = { version = "0.3", features = ["parsing", "formatting"] }
|
||||
base64 = "0.22"
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
futures-util = "0.3"
|
||||
tempfile = "3.20"
|
||||
infer = "0.19"
|
||||
percent-encoding = "2.3"
|
||||
|
||||
[target."cfg(target_os = \"windows\")".dependencies]
|
||||
zip = { version = "4", default-features = false, optional = true }
|
||||
windows-sys = { version = "0.60.0", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
[target."cfg(target_os = \"linux\")".dependencies]
|
||||
dirs = "6"
|
||||
tar = { version = "0.4", optional = true }
|
||||
flate2 = { version = "1", optional = true }
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tar = "0.4"
|
||||
flate2 = "1"
|
||||
osakit = { version = "0.3", features = ["full"] }
|
||||
|
||||
[features]
|
||||
default = ["rustls-tls", "system-proxy", "zip"]
|
||||
zip = ["dep:zip", "dep:tar", "dep:flate2"]
|
||||
native-tls = ["reqwest/native-tls"]
|
||||
native-tls-vendored = ["reqwest/native-tls-vendored"]
|
||||
rustls-tls = ["reqwest/rustls-no-provider", "dep:rustls"]
|
||||
system-proxy = ["reqwest/system-proxy"]
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
SPDXVersion: SPDX-2.1
|
||||
DataLicense: CC0-1.0
|
||||
PackageName: tauri
|
||||
DataFormat: SPDXRef-1
|
||||
PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
|
||||
PackageHomePage: https://tauri.app
|
||||
PackageLicenseDeclared: Apache-2.0
|
||||
PackageLicenseDeclared: MIT
|
||||
PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy
|
||||
PackageSummary: <text>Tauri is a rust project that enables developers to make secure
|
||||
and small desktop applications using a web frontend.
|
||||
</text>
|
||||
PackageComment: <text>The package includes the following libraries; see
|
||||
Relationship information.
|
||||
</text>
|
||||
Created: 2019-05-20T09:00:00Z
|
||||
PackageDownloadLocation: git://github.com/tauri-apps/tauri
|
||||
PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git
|
||||
PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git
|
||||
Creator: Person: Daniel Thompson-Yvetot
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 - Present Tauri Apps Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
# ShaCraft metadata-only updater patch
|
||||
|
||||
This directory vendors the Rust/build portion of the official
|
||||
`tauri-plugin-updater` **2.11.0** crate. Files were copied from the local Cargo
|
||||
registry cache, and every copied file was checked byte-for-byte against the
|
||||
cached `.crate` archive before applying this patch. No replacement crate was
|
||||
downloaded for this vendoring step.
|
||||
|
||||
Upstream: <https://github.com/tauri-apps/plugins-workspace/tree/6aa2854f314481a459be1189b02c65a2450789ab/plugins/updater>
|
||||
|
||||
The published archive's SHA-256 is
|
||||
`b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606`.
|
||||
The original registry `.cargo_vcs_info.json`, normalized `Cargo.toml` and
|
||||
`Cargo.toml.orig` are retained for provenance. Unused upstream JS source, package
|
||||
lockfile, changelog and Cargo cache marker are omitted; `api-iife.js` is retained
|
||||
because the official build script references it.
|
||||
|
||||
## Local change
|
||||
|
||||
Only `src/updater.rs` changes upstream Rust behavior. The new public method
|
||||
`Updater::check_metadata(&self, raw_json: serde_json::Value) -> Result<Option<Update>>`
|
||||
is synchronous and performs **no HTTP requests**. It uses the existing release
|
||||
deserializer, current-version/comparator decision, platform URL/signature
|
||||
selection and `Update` construction. `Updater::check()` keeps its original
|
||||
endpoint iteration and validation, then calls this same method. Its accepted
|
||||
release is parsed again by the helper; endpoint selection and error semantics
|
||||
are preserved.
|
||||
|
||||
ShaCraft fetches a size-limited metadata response and verifies its signature in
|
||||
its own native trust boundary before passing that exact JSON value to this
|
||||
method. This avoids asking the upstream `check()` path to fetch and parse a
|
||||
second, potentially unbounded metadata response. This helper does not itself
|
||||
authenticate metadata; callers must enforce their own policy.
|
||||
|
||||
The upstream package download, Minisign verification and all platform installer
|
||||
implementations are unchanged by this patch. ShaCraft's own native integration
|
||||
may choose a separate bounded download and installation path.
|
||||
|
||||
## License and maintenance
|
||||
|
||||
Upstream is dual licensed **Apache-2.0 OR MIT**. Both full license files,
|
||||
`LICENSE.spdx`, copyright notices and `SECURITY.md` remain in this directory.
|
||||
Rebase this small patch when upgrading the dependency, retain upstream notices,
|
||||
and repeat native updater tests before release.
|
||||
+103
@@ -0,0 +1,103 @@
|
||||

|
||||
|
||||
In-app updates for Tauri applications.
|
||||
|
||||
| Platform | Supported |
|
||||
| -------- | --------- |
|
||||
| Linux | ✓ |
|
||||
| Windows | ✓ |
|
||||
| macOS | ✓ |
|
||||
| Android | x |
|
||||
| iOS | x |
|
||||
|
||||
## Install
|
||||
|
||||
_This plugin requires a Rust version of at least **1.77.2**_
|
||||
|
||||
There are three general methods of installation that we can recommend.
|
||||
|
||||
1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked)
|
||||
2. Pull sources directly from Github using git tags / revision hashes (most secure)
|
||||
3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use)
|
||||
|
||||
Install the Core plugin by adding the following to your `Cargo.toml` file:
|
||||
|
||||
`src-tauri/Cargo.toml`
|
||||
|
||||
```toml
|
||||
# you can add the dependencies on the `[dependencies]` section if you do not target mobile
|
||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||
tauri-plugin-updater = "2.0.0"
|
||||
# alternatively with Git:
|
||||
tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
```
|
||||
|
||||
You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
|
||||
|
||||
```sh
|
||||
pnpm add @tauri-apps/plugin-updater
|
||||
# or
|
||||
npm add @tauri-apps/plugin-updater
|
||||
# or
|
||||
yarn add @tauri-apps/plugin-updater
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
First you need to register the core plugin with Tauri:
|
||||
|
||||
`src-tauri/src/lib.rs`
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
#[cfg(desktop)]
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
```
|
||||
|
||||
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
|
||||
|
||||
```javascript
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
const update = await check()
|
||||
if (update) {
|
||||
await update.downloadAndInstall()
|
||||
// Relaunch the app on macOS and Linux to run the newly install version
|
||||
await relaunch()
|
||||
}
|
||||
```
|
||||
|
||||
Note that for these APIs to work you have to properly configure the updater first and generate updater artifacts. Please refer to the [guide on our website](https://v2.tauri.app/plugin/updater/) for this.
|
||||
|
||||
## Contributing
|
||||
|
||||
PRs accepted. Please make sure to read the Contributing Guide before making a pull request.
|
||||
|
||||
## Partners
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://crabnebula.dev" target="_blank">
|
||||
<img src="https://github.com/tauri-apps/plugins-workspace/raw/v2/.github/sponsors/crabnebula.svg" alt="CrabNebula" width="283">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri).
|
||||
|
||||
## License
|
||||
|
||||
Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy.
|
||||
|
||||
MIT or MIT/Apache 2.0 where applicable.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Security Policy
|
||||
|
||||
**Do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.**
|
||||
|
||||
Include as much of the following information:
|
||||
|
||||
- Type of issue (e.g. improper input parsing, privilege escalation, etc.)
|
||||
- The location of the affected source code (tag/branch/commit or direct URL)
|
||||
- Any special configuration required to reproduce the issue
|
||||
- The distribution affected or used to help us with reproduction of the issue
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Ideally a reproduction repository
|
||||
- Impact of the issue, including how an attacker might exploit the issue
|
||||
|
||||
We prefer to receive reports in English.
|
||||
|
||||
## Contact
|
||||
|
||||
Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new).
|
||||
|
||||
Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app).
|
||||
+1
@@ -0,0 +1 @@
|
||||
if("__TAURI__"in window){var __TAURI_PLUGIN_UPDATER__=function(t){"use strict";function e(t,e,s,n){if("function"==typeof e?t!==e||!n:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===s?n:"a"===s?n.call(t):n?n.value:e.get(t)}function s(t,e,s,n,i){if("function"==typeof e||!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(t,s),s}var n,i,a,r,o;"function"==typeof SuppressedError&&SuppressedError;const d="__TAURI_TO_IPC_KEY__";class c{constructor(t){n.set(this,void 0),i.set(this,0),a.set(this,[]),r.set(this,void 0),s(this,n,t||(()=>{})),this.id=function(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}(t=>{const o=t.index;if("end"in t)return void(o==e(this,i,"f")?this.cleanupCallback():s(this,r,o));const d=t.message;if(o==e(this,i,"f")){for(e(this,n,"f").call(this,d),s(this,i,e(this,i,"f")+1);e(this,i,"f")in e(this,a,"f");){const t=e(this,a,"f")[e(this,i,"f")];e(this,n,"f").call(this,t),delete e(this,a,"f")[e(this,i,"f")],s(this,i,e(this,i,"f")+1)}e(this,i,"f")===e(this,r,"f")&&this.cleanupCallback()}else e(this,a,"f")[o]=d})}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(t){s(this,n,t)}get onmessage(){return e(this,n,"f")}[(n=new WeakMap,i=new WeakMap,a=new WeakMap,r=new WeakMap,d)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[d]()}}async function l(t,e={},s){return window.__TAURI_INTERNALS__.invoke(t,e,s)}class h{get rid(){return e(this,o,"f")}constructor(t){o.set(this,void 0),s(this,o,t)}async close(){return l("plugin:resources|close",{rid:this.rid})}}o=new WeakMap;class u extends h{constructor(t){super(t.rid),this.available=!0,this.currentVersion=t.currentVersion,this.version=t.version,this.date=t.date,this.body=t.body,this.rawJson=t.rawJson}async download(t,e){_(e);const s=new c;t&&(s.onmessage=t);const n=await l("plugin:updater|download",{onEvent:s,rid:this.rid,...e});this.downloadedBytes=new h(n)}async install(t){if(!this.downloadedBytes)throw new Error("Update.install called before Update.download");await l("plugin:updater|install",{updateRid:this.rid,bytesRid:this.downloadedBytes.rid,...t}),this.downloadedBytes=void 0}async downloadAndInstall(t,e){_(e);const s=new c;t&&(s.onmessage=t),await l("plugin:updater|download_and_install",{onEvent:s,rid:this.rid,...e})}async close(){await(this.downloadedBytes?.close()),await super.close()}}function _(t){t?.headers&&(t.headers=Array.from(new Headers(t.headers).entries()))}return t.Update=u,t.check=async function(t){_(t);const e=await l("plugin:updater|check",{...t});return e?new u(e):null},t}({});Object.defineProperty(window.__TAURI__,"updater",{value:__TAURI_PLUGIN_UPDATER__})}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const COMMANDS: &[&str] = &["check", "download", "install", "download_and_install"];
|
||||
|
||||
fn main() {
|
||||
tauri_plugin::Builder::new(COMMANDS)
|
||||
.global_api_script_path("./api-iife.js")
|
||||
.build();
|
||||
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
let mobile = target_os == "ios" || target_os == "android";
|
||||
alias("desktop", !mobile);
|
||||
alias("mobile", mobile);
|
||||
}
|
||||
|
||||
// creates a cfg alias if `has_feature` is true.
|
||||
// `alias` must be a snake case string.
|
||||
fn alias(alias: &str, has_feature: bool) {
|
||||
println!("cargo:rustc-check-cfg=cfg({alias})");
|
||||
if has_feature {
|
||||
println!("cargo:rustc-cfg={alias}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-check"
|
||||
description = "Enables the check command without any pre-configured scope."
|
||||
commands.allow = ["check"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-check"
|
||||
description = "Denies the check command without any pre-configured scope."
|
||||
commands.deny = ["check"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-download"
|
||||
description = "Enables the download command without any pre-configured scope."
|
||||
commands.allow = ["download"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-download"
|
||||
description = "Denies the download command without any pre-configured scope."
|
||||
commands.deny = ["download"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-download-and-install"
|
||||
description = "Enables the download_and_install command without any pre-configured scope."
|
||||
commands.allow = ["download_and_install"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-download-and-install"
|
||||
description = "Denies the download_and_install command without any pre-configured scope."
|
||||
commands.deny = ["download_and_install"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-install"
|
||||
description = "Enables the install command without any pre-configured scope."
|
||||
commands.allow = ["install"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-install"
|
||||
description = "Denies the install command without any pre-configured scope."
|
||||
commands.deny = ["install"]
|
||||
@@ -0,0 +1,130 @@
|
||||
## Default Permission
|
||||
|
||||
This permission set configures which kind of
|
||||
updater functions are exposed to the frontend.
|
||||
|
||||
#### Granted Permissions
|
||||
|
||||
The full workflow from checking for updates to installing them
|
||||
is enabled.
|
||||
|
||||
#### This default permission set includes the following:
|
||||
|
||||
- `allow-check`
|
||||
- `allow-download`
|
||||
- `allow-install`
|
||||
- `allow-download-and-install`
|
||||
|
||||
## Permission Table
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Identifier</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:allow-check`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the check command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:deny-check`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the check command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:allow-download`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the download command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:deny-download`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the download command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:allow-download-and-install`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the download_and_install command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:deny-download-and-install`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the download_and_install command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:allow-install`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the install command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:deny-install`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the install command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -0,0 +1,18 @@
|
||||
"$schema" = "schemas/schema.json"
|
||||
[default]
|
||||
description = """
|
||||
This permission set configures which kind of
|
||||
updater functions are exposed to the frontend.
|
||||
|
||||
#### Granted Permissions
|
||||
|
||||
The full workflow from checking for updates to installing them
|
||||
is enabled.
|
||||
|
||||
"""
|
||||
permissions = [
|
||||
"allow-check",
|
||||
"allow-download",
|
||||
"allow-install",
|
||||
"allow-download-and-install",
|
||||
]
|
||||
@@ -0,0 +1,354 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PermissionFile",
|
||||
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"default": {
|
||||
"description": "The default permission set for the plugin",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DefaultPermission"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"set": {
|
||||
"description": "A list of permissions sets defined",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionSet"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"description": "A list of inlined permissions",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Permission"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"DefaultPermission": {
|
||||
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"permissions"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the permission.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"description": "All permissions this set contains.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PermissionSet": {
|
||||
"description": "A set of direct permissions grouped together under a new name.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"description",
|
||||
"identifier",
|
||||
"permissions"
|
||||
],
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "A unique identifier for the permission.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does.",
|
||||
"type": "string"
|
||||
},
|
||||
"permissions": {
|
||||
"description": "All permissions this set contains.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionKind"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Permission": {
|
||||
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the permission.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"identifier": {
|
||||
"description": "A unique identifier for the permission.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"commands": {
|
||||
"description": "Allowed or denied commands when using this permission.",
|
||||
"default": {
|
||||
"allow": [],
|
||||
"deny": []
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Commands"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scope": {
|
||||
"description": "Allowed or denied scoped when using this permission.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Scopes"
|
||||
}
|
||||
]
|
||||
},
|
||||
"platforms": {
|
||||
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Target"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Commands": {
|
||||
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Allowed command.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Denied command, which takes priority.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scopes": {
|
||||
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Data that defines what is allowed by the scope.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Value": {
|
||||
"description": "All supported ACL values.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Represents a null JSON value.",
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`bool`].",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "Represents a valid ACL [`Number`].",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`String`].",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Represents a list of other [`Value`]s.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Represents a map of [`String`] keys to [`Value`]s.",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Number": {
|
||||
"description": "A valid ACL number.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Represents an [`i64`].",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`f64`].",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Target": {
|
||||
"description": "Platform target.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "MacOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"macOS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Windows.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"windows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Linux.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Android.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "iOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iOS"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionKind": {
|
||||
"type": "string",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::{Result, Update, UpdaterExt};
|
||||
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::Serialize;
|
||||
use tauri::{ipc::Channel, Manager, Resource, ResourceId, Runtime, Webview};
|
||||
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub enum DownloadEvent {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Started {
|
||||
content_length: Option<u64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Progress {
|
||||
chunk_length: usize,
|
||||
},
|
||||
Finished,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Metadata {
|
||||
rid: ResourceId,
|
||||
current_version: String,
|
||||
version: String,
|
||||
date: Option<String>,
|
||||
body: Option<String>,
|
||||
raw_json: serde_json::Value,
|
||||
}
|
||||
|
||||
struct DownloadedBytes(pub Vec<u8>);
|
||||
impl Resource for DownloadedBytes {}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn check<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
timeout: Option<u64>,
|
||||
proxy: Option<String>,
|
||||
target: Option<String>,
|
||||
allow_downgrades: Option<bool>,
|
||||
) -> Result<Option<Metadata>> {
|
||||
let mut builder = webview.updater_builder();
|
||||
if let Some(headers) = headers {
|
||||
for (k, v) in headers {
|
||||
builder = builder.header(k, v)?;
|
||||
}
|
||||
}
|
||||
if let Some(timeout) = timeout {
|
||||
builder = builder.timeout(Duration::from_millis(timeout));
|
||||
}
|
||||
if let Some(ref proxy) = proxy {
|
||||
let url = Url::parse(proxy.as_str())?;
|
||||
builder = builder.proxy(url);
|
||||
}
|
||||
if let Some(target) = target {
|
||||
builder = builder.target(target);
|
||||
}
|
||||
if allow_downgrades.unwrap_or(false) {
|
||||
builder = builder.version_comparator(|current, update| update.version != current);
|
||||
}
|
||||
|
||||
let updater = builder.build()?;
|
||||
let update = updater.check().await?;
|
||||
|
||||
if let Some(update) = update {
|
||||
let formatted_date = if let Some(date) = update.date {
|
||||
let formatted_date = date
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|_| crate::Error::FormatDate)?;
|
||||
Some(formatted_date)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let metadata = Metadata {
|
||||
current_version: update.current_version.clone(),
|
||||
version: update.version.clone(),
|
||||
date: formatted_date,
|
||||
body: update.body.clone(),
|
||||
raw_json: update.raw_json.clone(),
|
||||
rid: webview.resources_table().add(update),
|
||||
};
|
||||
Ok(Some(metadata))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn download<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
rid: ResourceId,
|
||||
on_event: Channel<DownloadEvent>,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
timeout: Option<u64>,
|
||||
) -> Result<ResourceId> {
|
||||
let update = webview.resources_table().get::<Update>(rid)?;
|
||||
|
||||
let mut update = (*update).clone();
|
||||
|
||||
if let Some(headers) = headers {
|
||||
let mut map = HeaderMap::new();
|
||||
for (k, v) in headers {
|
||||
map.append(HeaderName::from_str(&k)?, HeaderValue::from_str(&v)?);
|
||||
}
|
||||
update.headers = map;
|
||||
}
|
||||
|
||||
if let Some(timeout) = timeout {
|
||||
update.timeout = Some(Duration::from_millis(timeout));
|
||||
}
|
||||
|
||||
let mut first_chunk = true;
|
||||
let bytes = update
|
||||
.download(
|
||||
|chunk_length, content_length| {
|
||||
if first_chunk {
|
||||
first_chunk = !first_chunk;
|
||||
let _ = on_event.send(DownloadEvent::Started { content_length });
|
||||
}
|
||||
let _ = on_event.send(DownloadEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {
|
||||
let _ = on_event.send(DownloadEvent::Finished);
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(webview.resources_table().add(DownloadedBytes(bytes)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
update_rid: ResourceId,
|
||||
bytes_rid: ResourceId,
|
||||
restart_after_install: Option<bool>,
|
||||
) -> Result<()> {
|
||||
let update = webview.resources_table().get::<Update>(update_rid)?;
|
||||
let bytes = webview
|
||||
.resources_table()
|
||||
.get::<DownloadedBytes>(bytes_rid)?;
|
||||
|
||||
if let Some(restart_after_install) = restart_after_install {
|
||||
let update = (*update).clone();
|
||||
update
|
||||
.restart_after_install(restart_after_install)
|
||||
.install(&bytes.0)?;
|
||||
} else {
|
||||
update.install(&bytes.0)?;
|
||||
}
|
||||
|
||||
let _ = webview.resources_table().close(bytes_rid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn download_and_install<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
rid: ResourceId,
|
||||
on_event: Channel<DownloadEvent>,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
timeout: Option<u64>,
|
||||
restart_after_install: Option<bool>,
|
||||
) -> Result<()> {
|
||||
let update = webview.resources_table().get::<Update>(rid)?;
|
||||
|
||||
let mut update = (*update).clone();
|
||||
|
||||
if let Some(headers) = headers {
|
||||
let mut map = HeaderMap::new();
|
||||
for (k, v) in headers {
|
||||
map.append(HeaderName::from_str(&k)?, HeaderValue::from_str(&v)?);
|
||||
}
|
||||
update.headers = map;
|
||||
}
|
||||
|
||||
if let Some(timeout) = timeout {
|
||||
update.timeout = Some(Duration::from_millis(timeout));
|
||||
}
|
||||
|
||||
if let Some(restart_after_install) = restart_after_install {
|
||||
update = update.restart_after_install(restart_after_install);
|
||||
}
|
||||
|
||||
let mut first_chunk = true;
|
||||
|
||||
update
|
||||
.download_and_install(
|
||||
|chunk_length, content_length| {
|
||||
if first_chunk {
|
||||
first_chunk = !first_chunk;
|
||||
let _ = on_event.send(DownloadEvent::Started { content_length });
|
||||
}
|
||||
let _ = on_event.send(DownloadEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {
|
||||
let _ = on_event.send(DownloadEvent::Finished);
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use std::{ffi::OsString, fmt::Display};
|
||||
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use url::Url;
|
||||
|
||||
/// Install modes for the Windows update.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Default)]
|
||||
pub enum WindowsUpdateInstallMode {
|
||||
/// Specifies there's a basic UI during the installation process, including a final dialog box at the end.
|
||||
BasicUi,
|
||||
/// The quiet mode means there's no user interaction required.
|
||||
/// Requires admin privileges if the installer does.
|
||||
Quiet,
|
||||
/// Specifies unattended mode, which means the installation only shows a progress bar.
|
||||
#[default]
|
||||
Passive,
|
||||
}
|
||||
|
||||
impl WindowsUpdateInstallMode {
|
||||
/// Returns the associated `msiexec.exe` arguments.
|
||||
pub fn msiexec_args(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::BasicUi => &["/qb+"],
|
||||
Self::Quiet => &["/quiet"],
|
||||
Self::Passive => &["/passive"],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn msi_restart_after_install_args(&self) -> &'static [&'static str] {
|
||||
&["AUTOLAUNCHAPP=True"]
|
||||
}
|
||||
|
||||
/// Returns the associated nsis arguments.
|
||||
pub fn nsis_args(&self) -> &'static [&'static str] {
|
||||
// `/P`: Passive
|
||||
// `/S`: Silent
|
||||
// `/R`: Restart
|
||||
match self {
|
||||
Self::Passive => &["/P"],
|
||||
Self::Quiet => &["/S"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn nsis_restart_after_install_args(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::BasicUi => &[],
|
||||
_ => &["/R"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for WindowsUpdateInstallMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Self::BasicUi => "basicUi",
|
||||
Self::Quiet => "quiet",
|
||||
Self::Passive => "passive",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WindowsConfig {
|
||||
/// Additional arguments given to the NSIS or WiX installer.
|
||||
///
|
||||
/// Note: this applies to both WiX and NSIS installers
|
||||
#[serde(
|
||||
default,
|
||||
alias = "installer-args",
|
||||
deserialize_with = "deserialize_os_string"
|
||||
)]
|
||||
pub installer_args: Vec<OsString>,
|
||||
/// Updating mode, defaults to `passive` mode.
|
||||
///
|
||||
/// See [`WindowsUpdateInstallMode`] for more info.
|
||||
#[serde(default, alias = "install-mode")]
|
||||
pub install_mode: WindowsUpdateInstallMode,
|
||||
}
|
||||
|
||||
fn deserialize_os_string<'de, D>(deserializer: D) -> Result<Vec<OsString>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(Vec::<String>::deserialize(deserializer)?
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Updater configuration.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
/// Dangerously allow using insecure transport protocols for update endpoints.
|
||||
pub dangerous_insecure_transport_protocol: bool,
|
||||
/// Dangerously accept invalid TLS certificates for update requests.
|
||||
pub dangerous_accept_invalid_certs: bool,
|
||||
/// Dangerously accept invalid hostnames for TLS certificates for update requests.
|
||||
pub dangerous_accept_invalid_hostnames: bool,
|
||||
/// Updater endpoints.
|
||||
pub endpoints: Vec<Url>,
|
||||
/// Signature public key.
|
||||
pub pubkey: String,
|
||||
/// The Windows configuration for the updater.
|
||||
pub windows: Option<WindowsConfig>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Config {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Config {
|
||||
#[serde(default, alias = "dangerous-insecure-transport-protocol")]
|
||||
pub dangerous_insecure_transport_protocol: bool,
|
||||
#[serde(default, alias = "dangerous-accept-invalid-certs")]
|
||||
pub dangerous_accept_invalid_certs: bool,
|
||||
#[serde(default, alias = "dangerous-accept-invalid-hostnames")]
|
||||
pub dangerous_accept_invalid_hostnames: bool,
|
||||
#[serde(default)]
|
||||
pub endpoints: Vec<Url>,
|
||||
pub pubkey: String,
|
||||
pub windows: Option<WindowsConfig>,
|
||||
}
|
||||
|
||||
let config = Config::deserialize(deserializer)?;
|
||||
|
||||
validate_endpoints(
|
||||
&config.endpoints,
|
||||
config.dangerous_insecure_transport_protocol,
|
||||
)
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
|
||||
Ok(Self {
|
||||
dangerous_insecure_transport_protocol: config.dangerous_insecure_transport_protocol,
|
||||
dangerous_accept_invalid_certs: config.dangerous_accept_invalid_certs,
|
||||
dangerous_accept_invalid_hostnames: config.dangerous_accept_invalid_hostnames,
|
||||
endpoints: config.endpoints,
|
||||
pubkey: config.pubkey,
|
||||
windows: config.windows,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_endpoints(
|
||||
endpoints: &[Url],
|
||||
dangerous_insecure_transport_protocol: bool,
|
||||
) -> crate::Result<()> {
|
||||
if !dangerous_insecure_transport_protocol {
|
||||
for url in endpoints {
|
||||
if url.scheme() != "https" {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
eprintln!("[\x1b[33mWARNING\x1b[0m] The updater endpoint \"{url}\" doesn't use `https` protocol. This is allowed in development but will fail in release builds.");
|
||||
eprintln!("[\x1b[33mWARNING\x1b[0m] if this is a desired behavior, you can enable `dangerousInsecureTransportProtocol` in the plugin configuration");
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
return Err(crate::Error::InsecureTransportProtocol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use serde::{Serialize, Serializer};
|
||||
use thiserror::Error;
|
||||
|
||||
/// All errors that can occur while running the updater.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
/// Endpoints are not sent.
|
||||
#[error("Updater does not have any endpoints set.")]
|
||||
EmptyEndpoints,
|
||||
/// IO errors.
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
/// Semver errors.
|
||||
#[error(transparent)]
|
||||
Semver(#[from] semver::Error),
|
||||
/// Serialization errors.
|
||||
#[error(transparent)]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
/// Could not fetch a valid response from the server.
|
||||
#[error("Could not fetch a valid release JSON from the remote")]
|
||||
ReleaseNotFound,
|
||||
/// Unsupported app architecture.
|
||||
#[error("Unsupported application architecture, expected one of `x86`, `x86_64`, `arm` or `aarch64`.")]
|
||||
UnsupportedArch,
|
||||
/// Operating system is not supported.
|
||||
#[error("Unsupported OS, expected one of `linux`, `darwin` or `windows`.")]
|
||||
UnsupportedOs,
|
||||
/// Failed to determine updater package extract path
|
||||
#[error("Failed to determine updater package extract path.")]
|
||||
FailedToDetermineExtractPath,
|
||||
/// Url parsing errors.
|
||||
#[error(transparent)]
|
||||
UrlParse(#[from] url::ParseError),
|
||||
/// `reqwest` crate errors.
|
||||
#[error(transparent)]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
/// The platform was not found in the updater JSON response.
|
||||
#[error("the platform `{0}` was not found in the response `platforms` object")]
|
||||
TargetNotFound(String),
|
||||
/// Neither the platform nor the fallback platform was found in the updater JSON response.
|
||||
#[error(
|
||||
"None of the fallback platforms `{0:?}` were found in the response `platforms` object"
|
||||
)]
|
||||
TargetsNotFound(Vec<String>),
|
||||
/// Download failed
|
||||
#[error("`{0}`")]
|
||||
Network(String),
|
||||
/// `minisign_verify` errors.
|
||||
#[error(transparent)]
|
||||
Minisign(#[from] minisign_verify::Error),
|
||||
/// `base64` errors.
|
||||
#[error(transparent)]
|
||||
Base64(#[from] base64::DecodeError),
|
||||
/// UTF8 Errors in signature.
|
||||
#[error("The signature {0} could not be decoded, please check if it is a valid base64 string. The signature must be the contents of the `.sig` file generated by the Tauri bundler, as a string.")]
|
||||
SignatureUtf8(String),
|
||||
#[cfg(all(target_os = "windows", feature = "zip"))]
|
||||
/// `zip` errors.
|
||||
#[error(transparent)]
|
||||
Extract(#[from] zip::result::ZipError),
|
||||
/// Temp dir is not on same mount mount. This prevents our updater to rename the AppImage to a temp file.
|
||||
#[error("temp directory is not on the same mount point as the AppImage")]
|
||||
TempDirNotOnSameMountPoint,
|
||||
#[error("binary for the current target not found in the archive")]
|
||||
BinaryNotFoundInArchive,
|
||||
#[error("failed to create temporary directory")]
|
||||
TempDirNotFound,
|
||||
#[error("Authentication failed or was cancelled")]
|
||||
AuthenticationFailed,
|
||||
#[error("Failed to install .deb package")]
|
||||
DebInstallFailed,
|
||||
#[error("Failed to install package")]
|
||||
PackageInstallFailed,
|
||||
#[error("invalid updater binary format")]
|
||||
InvalidUpdaterFormat,
|
||||
#[error(transparent)]
|
||||
Http(#[from] http::Error),
|
||||
#[error(transparent)]
|
||||
InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
|
||||
#[error(transparent)]
|
||||
InvalidHeaderName(#[from] http::header::InvalidHeaderName),
|
||||
#[error("Failed to format date")]
|
||||
FormatDate,
|
||||
/// The configured updater endpoint must use a secure protocol like `https`
|
||||
#[error("The configured updater endpoint must use a secure protocol like `https`.")]
|
||||
InsecureTransportProtocol,
|
||||
#[error(transparent)]
|
||||
Tauri(#[from] tauri::Error),
|
||||
}
|
||||
|
||||
impl Serialize for Error {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.to_string().as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! In-app updates for Tauri applications.
|
||||
//!
|
||||
//! Supported platforms: Windows, Linux and macOS.
|
||||
//!
|
||||
//! ## Cargo features
|
||||
//!
|
||||
//! - **zip** *(enabled by default)*: Adds support for compressed updater bundles from Tauri v1.
|
||||
//! - **rustls-tls** *(enabled by default)*: Enables TLS functionality provided by `rustls`.
|
||||
//! - **native-tls**: Enables TLS functionality provided by `native-tls`.
|
||||
//! - **native-tls-vendored**: Enables the `vendored` feature of `native-tls`.
|
||||
//! - **system-proxy** *(enabled by default)*: Use Windows and macOS system proxy settings automatically.
|
||||
|
||||
#![doc(
|
||||
html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
|
||||
html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
|
||||
)]
|
||||
|
||||
use std::{ffi::OsString, sync::Arc};
|
||||
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use semver::Version;
|
||||
use tauri::{
|
||||
plugin::{Builder as PluginBuilder, TauriPlugin},
|
||||
Manager, Runtime,
|
||||
};
|
||||
|
||||
mod commands;
|
||||
mod config;
|
||||
mod error;
|
||||
mod updater;
|
||||
|
||||
pub use config::Config;
|
||||
pub use error::{Error, Result};
|
||||
pub use updater::*;
|
||||
|
||||
/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the updater APIs.
|
||||
pub trait UpdaterExt<R: Runtime> {
|
||||
/// Gets the updater builder to build and updater
|
||||
/// that can manually check if an update is available.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_updater::UpdaterExt;
|
||||
/// tauri::Builder::default()
|
||||
/// .setup(|app| {
|
||||
/// let handle = app.handle().clone();
|
||||
/// tauri::async_runtime::spawn(async move {
|
||||
/// let response = handle.updater_builder().build().unwrap().check().await;
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
fn updater_builder(&self) -> UpdaterBuilder;
|
||||
|
||||
/// Gets the updater to manually check if an update is available.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_updater::UpdaterExt;
|
||||
/// tauri::Builder::default()
|
||||
/// .setup(|app| {
|
||||
/// let handle = app.handle().clone();
|
||||
/// tauri::async_runtime::spawn(async move {
|
||||
/// let response = handle.updater().unwrap().check().await;
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
fn updater(&self) -> Result<Updater>;
|
||||
}
|
||||
|
||||
impl<R: Runtime, T: Manager<R>> UpdaterExt<R> for T {
|
||||
fn updater_builder(&self) -> UpdaterBuilder {
|
||||
let app = self.app_handle();
|
||||
let UpdaterState {
|
||||
config,
|
||||
target,
|
||||
version_comparator,
|
||||
headers,
|
||||
} = self.state::<UpdaterState>().inner();
|
||||
|
||||
let mut builder = UpdaterBuilder::new(app, config.clone()).headers(headers.clone());
|
||||
|
||||
if let Some(target) = target {
|
||||
builder = builder.target(target);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
builder = builder.current_exe_args(self.env().args_os);
|
||||
}
|
||||
|
||||
builder.version_comparator = version_comparator.clone();
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd"
|
||||
))]
|
||||
{
|
||||
let env = app.env();
|
||||
if let Some(appimage) = env.appimage {
|
||||
builder = builder.executable_path(appimage);
|
||||
}
|
||||
}
|
||||
|
||||
let app_handle = app.app_handle().clone();
|
||||
builder = builder.on_before_exit(move || {
|
||||
app_handle.cleanup_before_exit();
|
||||
});
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
fn updater(&self) -> Result<Updater> {
|
||||
self.updater_builder().build()
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdaterState {
|
||||
target: Option<String>,
|
||||
config: Config,
|
||||
version_comparator: Option<VersionComparator>,
|
||||
headers: HeaderMap,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Builder {
|
||||
target: Option<String>,
|
||||
pubkey: Option<String>,
|
||||
installer_args: Vec<OsString>,
|
||||
headers: HeaderMap,
|
||||
default_version_comparator: Option<VersionComparator>,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn target(mut self, target: impl Into<String>) -> Self {
|
||||
self.target.replace(target.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pubkey<S: Into<String>>(mut self, pubkey: S) -> Self {
|
||||
self.pubkey.replace(pubkey.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds an additional argument to pass to the Windows installer.
|
||||
pub fn installer_args<I, S>(mut self, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
self.installer_args.extend(args.into_iter().map(Into::into));
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds multiple additional arguments to pass to the Windows installer.
|
||||
pub fn installer_arg<S>(mut self, arg: S) -> Self
|
||||
where
|
||||
S: Into<OsString>,
|
||||
{
|
||||
self.installer_args.push(arg.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Removes all the additional arguments to pass to the Windows installer.
|
||||
///
|
||||
/// Note: this only removes the additional arguments added through [`Self::installer_args`],
|
||||
/// not the ones managed by us (e.g. `/UPDATER` flag passed to the NSIS installer)
|
||||
pub fn clear_installer_args(mut self) -> Self {
|
||||
self.installer_args.clear();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn header<K, V>(mut self, key: K, value: V) -> Result<Self>
|
||||
where
|
||||
HeaderName: TryFrom<K>,
|
||||
<HeaderName as TryFrom<K>>::Error: Into<http::Error>,
|
||||
HeaderValue: TryFrom<V>,
|
||||
<HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
|
||||
{
|
||||
let key: std::result::Result<HeaderName, http::Error> = key.try_into().map_err(Into::into);
|
||||
let value: std::result::Result<HeaderValue, http::Error> =
|
||||
value.try_into().map_err(Into::into);
|
||||
self.headers.insert(key?, value?);
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn headers(mut self, headers: HeaderMap) -> Self {
|
||||
self.headers = headers;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn default_version_comparator<
|
||||
F: Fn(Version, RemoteRelease) -> bool + Send + Sync + 'static,
|
||||
>(
|
||||
mut self,
|
||||
f: F,
|
||||
) -> Self {
|
||||
self.default_version_comparator.replace(Arc::new(f));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build<R: Runtime>(self) -> TauriPlugin<R, Config> {
|
||||
let pubkey = self.pubkey;
|
||||
let target = self.target;
|
||||
let version_comparator = self.default_version_comparator;
|
||||
let installer_args = self.installer_args;
|
||||
let headers = self.headers;
|
||||
PluginBuilder::<R, Config>::new("updater")
|
||||
.setup(move |app, api| {
|
||||
let mut config = api.config().clone();
|
||||
if let Some(pubkey) = pubkey {
|
||||
config.pubkey = pubkey;
|
||||
}
|
||||
if let Some(windows) = &mut config.windows {
|
||||
windows.installer_args.extend(installer_args);
|
||||
}
|
||||
app.manage(UpdaterState {
|
||||
target,
|
||||
config,
|
||||
version_comparator,
|
||||
headers,
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::check,
|
||||
commands::download,
|
||||
commands::install,
|
||||
commands::download_and_install,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
}
|
||||
+1758
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user