Add signed launcher self-updates and publish Linux 0.1.3

This commit is contained in:
Emil
2026-09-10 02:47:16 +03:00
parent bf43254c57
commit bb4b1f8051
55 changed files with 6286 additions and 43 deletions
+25 -4
View File
@@ -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
+2 -1
View File
@@ -25,8 +25,9 @@ jobs:
- name: Install Linux desktop dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf minisign
- run: npm ci
- run: npm test
- run: npm run build
- run: python3 -m unittest discover -s scripts -p 'test_*.py'
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
+26 -2
View File
@@ -63,6 +63,27 @@ payload are in `/root/shacraft` on the ShaCraft host; see
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 is supported for AppImage only. Preserve executable
permissions and use same-directory atomic replacement after verification.
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
@@ -99,6 +120,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
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.
- `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
@@ -106,8 +129,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
+35 -3
View File
@@ -17,7 +17,7 @@
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 при необходимости.
@@ -52,13 +52,45 @@
HTTPS verifier из контейнера, подпись manifest и SHA-256 мода проверены.
Backend Docker: 404 tests + 48 subtests, включая истечение, отзыв session,
чужую identity, whitelist, резервирование имён и атомарное погашение.
- [ ] Полный вход в production Aeronautics через установленный лаунчер
подтвердить отдельно; изолированный мир не заменяет проверку полного модпака.
- [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 остаются отдельными задачами.
## Связанные серверные риски
Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны
+14 -1
View File
@@ -37,14 +37,27 @@ 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 и dev-бинарник обновляются вручную.
С версии 0.1.2 нужен один ручной переход на новый AppImage. Пакеты Windows/macOS
с этим механизмом ещё требуют публикации и проверки установки.
## Навигация
- [Архитектура](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) — инструкции для следующего разработчика/агента.
+53 -6
View File
@@ -22,9 +22,12 @@ 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.
Linux AppImage replacement is supported; the first upgrade from 0.1.2 is manual.
Windows/macOS packages still require publication and actual installation tests.
Not yet implemented: a user-selectable profile directory and a "reset managed
files only" recovery action. OS code signing/notarization is separate from the
updater signatures and is not certified by this implementation.
## Data flow
@@ -143,7 +146,8 @@ proof with a shared key embedded in distributed binaries.
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.
@@ -173,6 +177,36 @@ 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.
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;
@@ -180,8 +214,9 @@ 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
@@ -201,3 +236,15 @@ 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.
+195
View File
@@ -0,0 +1,195 @@
# 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. On Linux, automatic
replacement is for AppImage installations; deb installations and 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 current 0.1.3 release follows 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` (`.AppImage`), `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.
## 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`. It performs verification
through the standard minisign CLI, without implementing cryptography in Python.
`--minisign /absolute/path/to/minisign` supports a locally extracted tool without
installing a global package. Run these examples from the launcher repository,
substituting the actual release version and tested filenames.
1. Stage immutable, tested packages below a local downloads root. The following
example assumes the Linux artifact already exists at
`/tmp/shacraft-release/downloads/0.1.3/ShaCraft.Launcher_0.1.3_amd64.AppImage`
and release notes exist at `/tmp/shacraft-release/notes.txt`. Create signatures
with the Tauri CLI; `.sig` is written beside each artifact:
```bash
npm run tauri -- signer sign \
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
/tmp/shacraft-release/downloads/0.1.3/ShaCraft.Launcher_0.1.3_amd64.AppImage
```
2. Prepare a deterministic payload after verifying every package signature.
Repeat `--artifact PLATFORM=FILENAME` for each tested platform included in this
release. Do not list a deb, dmg, nonexistent package or untested architecture:
```bash
python3 scripts/publish_launcher_update.py prepare \
--version 0.1.3 \
--downloads-root /tmp/shacraft-release/downloads \
--artifact linux-x86_64=ShaCraft.Launcher_0.1.3_amd64.AppImage \
--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. Unit tests,
packaging or a browser mock alone do not establish successful installation.
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.
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/).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "shacraft-launcher-ui",
"version": "0.1.2",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shacraft-launcher-ui",
"version": "0.1.2",
"version": "0.1.3",
"dependencies": {
"@tauri-apps/api": "2.11.1",
"lucide-react": "1.41.0",
+2 -2
View File
@@ -2,13 +2,13 @@
"name": "shacraft-launcher-ui",
"license": "MIT",
"private": true,
"version": "0.1.2",
"version": "0.1.3",
"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",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
+1
View File
@@ -0,0 +1 @@
__pycache__/
+290
View File
@@ -0,0 +1,290 @@
#!/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 stat
import subprocess
import tempfile
ORIGIN = "https://shacraft.ru/downloads/shacraft-launcher/"
PLATFORMS = {
"linux-x86_64": (".AppImage",),
"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
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 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")
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)
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()
+5
View File
@@ -0,0 +1,5 @@
{
"bundle": {
"createUpdaterArtifacts": false
}
}
+156
View File
@@ -0,0 +1,156 @@
"""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 tempfile
import unittest
import publish_launcher_update as publisher
MINISIGN = os.environ.get("SHACRAFT_TEST_MINISIGN", "minisign")
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")
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"),
):
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"}')
@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(b"isolated ShaCraft updater fixture; not an executable")
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())
if __name__ == "__main__":
unittest.main()
+329 -8
View File
@@ -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,14 +3361,17 @@ dependencies = [
[[package]]
name = "shacraft-launcher"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
"flate2",
"getrandom 0.3.4",
"md-5",
"minisign-verify",
"reqwest 0.12.28",
"reqwest 0.13.4",
"semver",
"serde",
"serde_json",
"sha1",
@@ -3231,9 +3379,11 @@ dependencies = [
"tar",
"tauri",
"tauri-build",
"tauri-plugin-updater",
"tempfile",
"url",
"zeroize",
"zip",
"zip 2.4.2",
]
[[package]]
@@ -3257,6 +3407,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"
@@ -3479,7 +3645,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3546,7 +3712,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3642,6 +3808,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"
@@ -3652,7 +3865,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3675,7 +3888,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -3742,6 +3955,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"
@@ -4419,6 +4645,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"
@@ -4676,6 +4911,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"
@@ -4709,13 +4953,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"
@@ -4746,6 +5007,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"
@@ -4758,6 +5025,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"
@@ -4770,12 +5043,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"
@@ -4788,6 +5073,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"
@@ -4800,6 +5091,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"
@@ -4812,6 +5109,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"
@@ -4824,6 +5127,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"
@@ -4888,7 +5197,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -5045,6 +5354,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"
+9 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shacraft-launcher"
version = "0.1.2"
version = "0.1.3"
description = "ShaCraft Minecraft launcher"
authors = ["ShaCraft"]
license = "MIT"
@@ -24,8 +24,16 @@ ed25519-dalek = { version = "2", features = ["pkcs8"] }
getrandom = "0.3"
zeroize = "1"
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"] }
tempfile = "3"
+3
View File
@@ -163,6 +163,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> {
@@ -219,7 +220,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 {
+1
View File
@@ -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};
+194
View File
@@ -0,0 +1,194 @@
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(),
)
.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());
}
}
app.restart()
}
+24 -1
View File
@@ -14,13 +14,32 @@ mod settings;
mod shacraft_account;
mod storage;
mod trusted_http;
mod updater;
mod commands;
mod operations;
pub fn run() {
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,
@@ -41,7 +60,11 @@ pub fn run() {
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");
+72 -2
View File
@@ -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());
}
}
+727
View File
@@ -0,0 +1,727 @@
//! 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";
const MAX_METADATA_BYTES: usize = 192 * 1024;
const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024;
const BAD_METADATA: &str =
"Не удалось подтвердить подлинность сведений об обновлении. Повторите проверку позже.";
const BAD_SIGNATURE: &str = "Подпись обновления не прошла проверку. Установка отменена.";
#[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,
#[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(),
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 unsupported_reason<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
#[cfg(target_os = "linux")]
{
let env = app.env();
let valid = match (
env.appimage.as_ref(),
env.appdir.as_ref(),
std::env::current_exe(),
) {
(Some(image), Some(directory), Ok(executable)) => linux_appimage_supported(
std::path::Path::new(image),
std::path::Path::new(directory),
&executable,
),
_ => false,
};
if !valid {
return Some("Автообновление в Linux доступно в AppImage. Установите AppImage с shacraft.ru и запускайте его.".into());
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
return Some("Для этой платформы доступна только ручная установка обновлений.".into());
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")]
{
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.
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())
}
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)
}
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) -> Result<(), 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)?;
let available = platforms.contains_key(&target)
|| ["appimage", "nsis", "msi", "app"]
.iter()
.any(|bundle| platforms.contains_key(&format!("{target}-{bundle}")));
if !available {
return Err("Обновление для вашей платформы пока не опубликовано.".into());
}
Ok(())
}
fn validate_download_url(url: &Url, version: &str) -> 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") {
filename.ends_with(".AppImage")
} 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(())
}
/// 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,
) -> 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)?;
require_platform(&metadata)?;
if !newer_version(&metadata, current)? {
return Ok(None);
}
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)?;
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)?;
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)?;
verify_signature(bytes, &update.signature, key)?;
#[cfg(target_os = "linux")]
{
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").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").is_err(),
"{value}"
);
}
}
#[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":{}})).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")
.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
);
});
}
}
+13 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ShaCraft Launcher",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "ru.shacraft.launcher",
"build": {
"beforeDevCommand": "npm run dev",
@@ -28,6 +28,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"icon": [
"icons/32x32.png",
@@ -36,5 +37,16 @@
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDk0N0MzODEwMjg1OUVCNDEKUldSQjYxa29FRGg4bEdKSkFWUzZUNDZhRFN4cGIwL0FvVnl0blhrOWtSMWhSOWxHMkU1aGs5L2oK",
"endpoints": [
"https://shacraft.ru/launcher/updates/stable.json"
],
"windows": {
"installMode": "passive"
}
}
}
}
+17
View File
@@ -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"
}
+15 -5
View File
@@ -9,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'
@@ -33,9 +34,11 @@ 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 })
@@ -43,6 +46,7 @@ export function App() {
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]}` : 'Подготовка…'
@@ -66,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}
@@ -75,8 +79,14 @@ 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) }} />
+51
View File
@@ -0,0 +1,51 @@
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'
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' ? 'Проверяем подпись и устанавливаем…'
: `Скачиваем обновление${percent === null ? '…' : ` · ${percent}%`}`}</p>
: checking ? <p>Проверяем обновления</p>
: status?.supported === false ? <p>{status.reason || 'Для этой установки обновление доступно вручную на shacraft.ru/help#launcher.'}</p>
: status?.version ? <p className="update-success">Доступна версия {status.version}</p>
: state.checked && !error ? <p>У вас последняя версия.</p>
: <p>Проверка новой версии лаунчера.</p>}
{working && <progress aria-label="Загрузка обновления лаунчера" max={100} value={phase === 'installing' ? undefined : percent ?? undefined} />}
</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>}
{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>
}
+7 -3
View File
@@ -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}
+83
View File
@@ -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 }
}
+11
View File
@@ -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()
@@ -31,6 +32,10 @@ export const native = {
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 = {
@@ -48,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)),
])
}
+76
View File
@@ -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)
})
+59
View File
@@ -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)))
}
+25 -1
View File
@@ -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; }
+15
View File
@@ -80,3 +80,18 @@ export interface GameExitedPayload {
profileId: string
exitCode: number | null
}
export interface LauncherUpdateStatus {
currentVersion: string
supported: boolean
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
}
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "6aa2854f314481a459be1189b02c65a2450789ab"
},
"path_in_vcs": "plugins/updater"
}
+190
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+44
View File
@@ -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
View File
@@ -0,0 +1,103 @@
![plugin-updater](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/updater/banner.png)
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
View File
@@ -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
View File
@@ -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__})}
+25
View File
@@ -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"]
@@ -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>
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
}
File diff suppressed because it is too large Load Diff