Add signed launcher self-updates and publish Linux 0.1.3
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 обязательны
|
||||
|
||||
@@ -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) — инструкции для следующего разработчика/агента.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/).
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": false
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Generated
+329
-8
@@ -1770,6 +1770,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.20",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -1975,6 +2005,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2217,6 +2253,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2232,6 +2269,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2295,12 +2344,32 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2817,15 +2886,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2893,6 +2967,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -2903,6 +2989,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
@@ -2935,6 +3048,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -2992,6 +3114,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
@@ -3216,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) }} />
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react'
|
||||
import { errorMessage, singleFlight } from '../services/async'
|
||||
import { isNative, native, watchLauncherUpdate } from '../services/native'
|
||||
import { initialUpdaterState, updateBlocksOperations, updaterReducer } from '../state/updater'
|
||||
|
||||
// StrictMode re-runs effects; share the pending native check without installing
|
||||
// anything. Manual checks always make a fresh request.
|
||||
const startupCheck = singleFlight(async () => {
|
||||
const status = await native.updateStatus()
|
||||
if (!status.supported || (status.stage && ['downloading', 'installing', 'ready'].includes(status.stage))) return { status, checked: false, error: null }
|
||||
try {
|
||||
return { status: await native.checkUpdate(), checked: true, error: null }
|
||||
} catch (error) {
|
||||
return { status, checked: false, error: errorMessage(error, 'Не удалось проверить обновления. Повторите попытку.') }
|
||||
}
|
||||
})
|
||||
|
||||
export function useLauncherUpdate(blocked: boolean) {
|
||||
const [state, dispatch] = useReducer(updaterReducer, initialUpdaterState)
|
||||
const [eventsReady, setEventsReady] = useState(false)
|
||||
const [eventError, setEventError] = useState<string | null>(null)
|
||||
const pending = useRef(false)
|
||||
const mounted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
mounted.current = true
|
||||
const subscription = watchLauncherUpdate((progress) => {
|
||||
if (active) dispatch({ type: 'progress', progress })
|
||||
})
|
||||
void subscription.ready.then(() => {
|
||||
if (active) { setEventsReady(true); setEventError(null) }
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setEventError(errorMessage(reason, 'Не удалось подключить события обновления. Перезапустите лаунчер.'))
|
||||
})
|
||||
pending.current = true
|
||||
void startupCheck().then((result) => {
|
||||
if (active) dispatch({ type: 'loaded', ...result })
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
|
||||
}).finally(() => { if (active) pending.current = false })
|
||||
return () => { active = false; mounted.current = false; subscription.dispose() }
|
||||
}, [])
|
||||
|
||||
const check = async () => {
|
||||
if (!isNative() || pending.current || updateBlocksOperations(state)) return
|
||||
pending.current = true
|
||||
dispatch({ type: 'check' })
|
||||
try {
|
||||
const status = await native.checkUpdate()
|
||||
if (mounted.current) dispatch({ type: 'loaded', status, checked: true })
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
if (!isNative() || pending.current || blocked || !eventsReady || state.phase !== 'idle' ||
|
||||
!state.status?.supported || !state.status.version) return
|
||||
pending.current = true
|
||||
dispatch({ type: 'install' })
|
||||
try {
|
||||
await native.installUpdate()
|
||||
if (mounted.current) dispatch({ type: 'ready' })
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось установить обновление. Повторите попытку.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
const restart = async () => {
|
||||
if (!isNative() || pending.current || blocked || state.phase !== 'ready') return
|
||||
pending.current = true
|
||||
dispatch({ type: 'restart' })
|
||||
try {
|
||||
await native.restartAfterUpdate()
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось перезапустить лаунчер. Закройте его и откройте снова.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
return { state, eventsReady, eventError, blocked, locksOperations: updateBlocksOperations(state), check, install, restart }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
|
||||
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
|
||||
ShaCraftAccount, ShaCraftLoginResult, SyncResult,
|
||||
LauncherUpdateStatus, LauncherUpdateProgress,
|
||||
} from '../types/launcher'
|
||||
|
||||
export const isNative = () => typeof window !== 'undefined' && isTauri()
|
||||
@@ -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)),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { equal, match } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { initialUpdaterState, updateBlocksOperations, updatePercent, updaterReducer } from './updater.ts'
|
||||
|
||||
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
|
||||
status: { currentVersion: '0.1.3', supported: true, version: '0.1.4' } })
|
||||
const downloading = updaterReducer(available, { type: 'install' })
|
||||
|
||||
test('checking and available update allow play; installation locks operations until restart', () => {
|
||||
equal(updateBlocksOperations(available), false)
|
||||
equal(updateBlocksOperations(updaterReducer(available, { type: 'check' })), false)
|
||||
equal(updateBlocksOperations(downloading), true)
|
||||
const ready = updaterReducer(downloading, { type: 'ready' })
|
||||
equal(updateBlocksOperations(ready), true)
|
||||
equal(updaterReducer(ready, { type: 'check' }), ready)
|
||||
equal(updaterReducer(ready, { type: 'install' }), ready)
|
||||
const restarting = updaterReducer(ready, { type: 'restart' })
|
||||
equal(updateBlocksOperations(restarting), true)
|
||||
})
|
||||
|
||||
test('an installation failure permits playing and retrying; a restart failure retains the lock', () => {
|
||||
const failed = updaterReducer(downloading, { type: 'failed', error: 'Подпись не совпадает' })
|
||||
equal(updateBlocksOperations(failed), false)
|
||||
equal(failed.status?.version, '0.1.4')
|
||||
const retry = updaterReducer(failed, { type: 'install' })
|
||||
equal(retry.error, null)
|
||||
equal(retry.phase, 'downloading')
|
||||
const ready = updaterReducer(retry, { type: 'ready' })
|
||||
const restartError = updaterReducer(updaterReducer(ready, { type: 'restart' }), { type: 'failed', error: 'Перезапустите вручную' })
|
||||
equal(restartError.phase, 'ready')
|
||||
match(restartError.error ?? '', /вручную/)
|
||||
equal(updateBlocksOperations(restartError), true)
|
||||
})
|
||||
|
||||
test('late progress cannot undo completion or a failed installation', () => {
|
||||
const installing = updaterReducer(downloading, { type: 'progress', progress: { stage: 'installing', downloadedBytes: 20 } })
|
||||
equal(updaterReducer(installing, { type: 'progress', progress: { stage: 'downloading', downloadedBytes: 10 } }), installing)
|
||||
const ready = updaterReducer(installing, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } })
|
||||
equal(updaterReducer(ready, { type: 'progress', progress: { stage: 'installing', downloadedBytes: 20 } }), ready)
|
||||
const failed = updaterReducer(downloading, { type: 'failed', error: 'Сбой сети' })
|
||||
equal(updaterReducer(failed, { type: 'ready' }), failed)
|
||||
equal(updaterReducer(failed, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } }), failed)
|
||||
})
|
||||
|
||||
test('native status restores an update after a webview reload; early events preserve readiness', () => {
|
||||
for (const stage of ['downloading', 'installing', 'ready'] as const) {
|
||||
const restored = updaterReducer(initialUpdaterState, { type: 'loaded', checked: false,
|
||||
status: { ...available.status!, stage } })
|
||||
equal(restored.phase, stage)
|
||||
equal(updateBlocksOperations(restored), true)
|
||||
}
|
||||
const earlyReady = updaterReducer(initialUpdaterState, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } })
|
||||
const staleStatus = updaterReducer(earlyReady, { type: 'loaded', checked: false,
|
||||
status: { ...available.status!, stage: 'downloading' } })
|
||||
equal(staleStatus.phase, 'ready')
|
||||
equal(staleStatus.status?.currentVersion, '0.1.3')
|
||||
})
|
||||
|
||||
test('unsupported package and no available version never enter installation', () => {
|
||||
const unsupported = updaterReducer(initialUpdaterState, { type: 'loaded', checked: false,
|
||||
status: { currentVersion: '0.1.3', supported: false, reason: 'Используйте AppImage' } })
|
||||
equal(updaterReducer(unsupported, { type: 'install' }), unsupported)
|
||||
const current = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
|
||||
status: { currentVersion: '0.1.3', supported: true } })
|
||||
equal(updaterReducer(current, { type: 'install' }), current)
|
||||
})
|
||||
|
||||
test('unknown, invalid and excessive progress cannot produce misleading percentages', () => {
|
||||
equal(updatePercent(null), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 1 }), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: NaN, totalBytes: 20 }), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 1, totalBytes: Infinity }), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 10, totalBytes: 20 }), 50)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 30, totalBytes: 20 }), 100)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: -1, totalBytes: 20 }), 0)
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { LauncherUpdateProgress, LauncherUpdateStatus } from '../types/launcher'
|
||||
|
||||
export interface UpdaterState {
|
||||
phase: 'loading' | 'idle' | 'checking' | 'downloading' | 'installing' | 'ready' | 'restarting'
|
||||
status: LauncherUpdateStatus | null
|
||||
progress: LauncherUpdateProgress | null
|
||||
error: string | null
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
export const initialUpdaterState: UpdaterState = {
|
||||
phase: 'loading', status: null, progress: null, error: null, checked: false,
|
||||
}
|
||||
|
||||
type UpdaterAction =
|
||||
| { type: 'loaded'; status: LauncherUpdateStatus; checked: boolean; error?: string | null }
|
||||
| { type: 'check' }
|
||||
| { type: 'install' }
|
||||
| { type: 'progress'; progress: LauncherUpdateProgress }
|
||||
| { type: 'ready' }
|
||||
| { type: 'restart' }
|
||||
| { type: 'failed'; error: string }
|
||||
|
||||
export function updateBlocksOperations(state: UpdaterState): boolean {
|
||||
return ['downloading', 'installing', 'ready', 'restarting'].includes(state.phase)
|
||||
}
|
||||
|
||||
export function updaterReducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
|
||||
switch (action.type) {
|
||||
case 'loaded':
|
||||
if (updateBlocksOperations(state)) return { ...state, status: action.status }
|
||||
return { ...state, phase: action.status.stage === 'ready' || action.status.stage === 'downloading' || action.status.stage === 'installing'
|
||||
? action.status.stage : 'idle', status: action.status,
|
||||
checked: action.checked, error: action.error ?? null }
|
||||
case 'check':
|
||||
return updateBlocksOperations(state) ? state : { ...state, phase: 'checking', error: null }
|
||||
case 'install':
|
||||
return state.phase !== 'idle' || !state.status?.supported || !state.status.version ? state
|
||||
: { ...state, phase: 'downloading', progress: null, error: null }
|
||||
case 'progress':
|
||||
if (state.phase !== 'loading' && state.phase !== 'downloading' && state.phase !== 'installing') return state
|
||||
// A delayed download event cannot revert an installation to downloading.
|
||||
if (state.phase === 'installing' && action.progress.stage === 'downloading') return state
|
||||
return { ...state, phase: action.progress.stage, progress: action.progress }
|
||||
case 'ready':
|
||||
return state.phase === 'downloading' || state.phase === 'installing'
|
||||
? { ...state, phase: 'ready', error: null } : state
|
||||
case 'restart':
|
||||
return state.phase === 'ready' ? { ...state, phase: 'restarting', error: null } : state
|
||||
case 'failed':
|
||||
return { ...state, phase: state.phase === 'ready' || state.phase === 'restarting' ? 'ready' : 'idle', error: action.error }
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePercent(progress: LauncherUpdateProgress | null): number | null {
|
||||
if (!progress || !progress.totalBytes || progress.totalBytes <= 0 ||
|
||||
!Number.isFinite(progress.totalBytes) || !Number.isFinite(progress.downloadedBytes)) return null
|
||||
return Math.max(0, Math.min(100, Math.round(progress.downloadedBytes / progress.totalBytes * 100)))
|
||||
}
|
||||
+25
-1
@@ -130,7 +130,7 @@ button:disabled { cursor: default; }
|
||||
.settings-drawer.open { transform: translateX(0); }
|
||||
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; }
|
||||
.drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; }
|
||||
.drawer-title h2 { margin: 0; font-family: 'Unbounded'; font-size: 25px; }
|
||||
.drawer-title h2 { margin: 0; font-family: 'Unbounded', sans-serif; font-size: 21px; }
|
||||
.drawer-title button { border: 0; background: #222923; width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; cursor: pointer; }
|
||||
.drawer-title button svg { width: 18px; }
|
||||
.range-setting { display: grid; background: #191f1a; padding: 18px; border-radius: 13px; margin-bottom: 12px; }
|
||||
@@ -157,6 +157,30 @@ button:disabled { cursor: default; }
|
||||
.account-hint { font-size: 11px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.recovery-codes { white-space: pre-line; font-size: 15px; max-height: 45vh; overflow: auto; }
|
||||
.settings-feedback button { padding: 6px 10px; background: var(--panel-2); border: 1px solid var(--line); cursor: pointer; }
|
||||
|
||||
.update-banner { position: absolute; top: 70px; right: 28px; z-index: 10; display: flex; align-items: center; gap: 9px; max-width: 440px; padding: 10px 14px; border: 1px solid #325939; border-radius: 9px; background: #17291bdc; color: #b6e5b8; font-size: 11px; cursor: pointer; box-shadow: 0 6px 24px #0003; }
|
||||
.update-banner:hover { background: #203a25; }
|
||||
.update-banner-dot { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--green); }
|
||||
.launcher-update { border: 1px solid #2c3e30; border-radius: 12px; padding: 17px; margin-bottom: 24px; background: #17201a; }
|
||||
.launcher-update-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.launcher-update-heading h3 { margin: 0; font-size: 13px; font-weight: 750; }
|
||||
.launcher-update-heading > span { color: #a6bba8; font-size: 11px; }
|
||||
.launcher-update p { font-size: 11px; line-height: 1.65; margin: 10px 0 0; color: #97a89a; }
|
||||
.launcher-update p.update-success { color: #b0e2b1; }
|
||||
.launcher-update .update-error { color: #eaae89; }
|
||||
.launcher-update progress { width: 100%; height: 5px; accent-color: var(--green); margin-top: 12px; }
|
||||
.update-actions { display: flex; align-items: stretch; flex-direction: column; gap: 9px; margin-top: 15px; }
|
||||
.update-actions button { display: flex; justify-content: center; align-items: center; gap: 8px; border: 1px solid #35543b; border-radius: 7px; min-height: 35px; padding: 8px 10px; font-size: 11px; cursor: pointer; }
|
||||
.update-actions button:disabled { cursor: default; opacity: .48; }
|
||||
.update-actions button svg { width: 14px; height: 14px; }
|
||||
.update-primary { background: var(--green); color: #102313; font-weight: 800; }
|
||||
.update-primary:hover:not(:disabled) { background: #91df93; }
|
||||
.update-check { background: transparent; color: #b6c6b9; }
|
||||
.update-check:hover:not(:disabled) { background: #263b2c; }
|
||||
.update-notes { margin-top: 10px; font-size: 11px; color: #b6c6b9; }
|
||||
.update-notes summary { cursor: pointer; }
|
||||
.update-notes p { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow: auto; }
|
||||
.launcher-update .update-hint { color: #a8a58e; }
|
||||
.status-error, .build-state .status-error, .text-setting > .status-error { color: #eea18f; overflow-wrap: anywhere; }
|
||||
.build-state .status-error { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "6aa2854f314481a459be1189b02c65a2450789ab"
|
||||
},
|
||||
"path_in_vcs": "plugins/updater"
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
authors = ["Tauri Programme within The Commons Conservancy"]
|
||||
build = "build.rs"
|
||||
links = "tauri-plugin-updater"
|
||||
exclude = [
|
||||
"/banner.png",
|
||||
"/guest-js",
|
||||
"/package.json",
|
||||
"/rollup.config.js",
|
||||
"/tests",
|
||||
"/tsconfig.json",
|
||||
]
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "In-app updates for Tauri applications."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/tauri-apps/plugins-workspace"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
no-default-features = true
|
||||
features = ["zip"]
|
||||
|
||||
[package.metadata.platforms.support.windows]
|
||||
level = "full"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.linux]
|
||||
level = "full"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.macos]
|
||||
level = "full"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.android]
|
||||
level = "none"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.ios]
|
||||
level = "none"
|
||||
notes = ""
|
||||
|
||||
[features]
|
||||
default = [
|
||||
"rustls-tls",
|
||||
"system-proxy",
|
||||
"zip",
|
||||
]
|
||||
native-tls = ["reqwest/native-tls"]
|
||||
native-tls-vendored = ["reqwest/native-tls-vendored"]
|
||||
rustls-tls = [
|
||||
"reqwest/rustls-no-provider",
|
||||
"dep:rustls",
|
||||
]
|
||||
system-proxy = ["reqwest/system-proxy"]
|
||||
zip = [
|
||||
"dep:zip",
|
||||
"dep:tar",
|
||||
"dep:flate2",
|
||||
]
|
||||
|
||||
[lib]
|
||||
name = "tauri_plugin_updater"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies.base64]
|
||||
version = "0.22"
|
||||
|
||||
[dependencies.futures-util]
|
||||
version = "0.3"
|
||||
|
||||
[dependencies.http]
|
||||
version = "1"
|
||||
|
||||
[dependencies.infer]
|
||||
version = "0.19"
|
||||
|
||||
[dependencies.log]
|
||||
version = "0.4.21"
|
||||
|
||||
[dependencies.minisign-verify]
|
||||
version = "0.2"
|
||||
|
||||
[dependencies.percent-encoding]
|
||||
version = "2.3"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.13"
|
||||
features = [
|
||||
"json",
|
||||
"stream",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[dependencies.rustls]
|
||||
version = "0.23"
|
||||
features = ["ring"]
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[dependencies.semver]
|
||||
version = "1"
|
||||
features = ["serde"]
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1"
|
||||
features = ["derive"]
|
||||
|
||||
[dependencies.serde_json]
|
||||
version = "1"
|
||||
|
||||
[dependencies.tauri]
|
||||
version = "2.10"
|
||||
default-features = false
|
||||
|
||||
[dependencies.tempfile]
|
||||
version = "3.20"
|
||||
|
||||
[dependencies.thiserror]
|
||||
version = "2"
|
||||
|
||||
[dependencies.time]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"parsing",
|
||||
"formatting",
|
||||
]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1"
|
||||
|
||||
[dependencies.url]
|
||||
version = "2"
|
||||
|
||||
[build-dependencies.tauri-plugin]
|
||||
version = "2.5"
|
||||
features = ["build"]
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.dirs]
|
||||
version = "6"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.flate2]
|
||||
version = "1"
|
||||
optional = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.tar]
|
||||
version = "0.4"
|
||||
optional = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.flate2]
|
||||
version = "1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.osakit]
|
||||
version = "0.3"
|
||||
features = ["full"]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.tar]
|
||||
version = "0.4"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies.windows-sys]
|
||||
version = "0.60.0"
|
||||
features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Shell",
|
||||
]
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies.zip]
|
||||
version = "4"
|
||||
optional = true
|
||||
default-features = false
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
[package]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
description = "In-app updates for Tauri applications."
|
||||
edition = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
license = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
links = "tauri-plugin-updater"
|
||||
exclude = [
|
||||
"/banner.png",
|
||||
"/guest-js",
|
||||
"/package.json",
|
||||
"/rollup.config.js",
|
||||
"/tests",
|
||||
"/tsconfig.json",
|
||||
]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
no-default-features = true
|
||||
features = ["zip"]
|
||||
|
||||
[package.metadata.platforms.support]
|
||||
windows = { level = "full", notes = "" }
|
||||
linux = { level = "full", notes = "" }
|
||||
macos = { level = "full", notes = "" }
|
||||
android = { level = "none", notes = "" }
|
||||
ios = { level = "none", notes = "" }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { workspace = true, features = ["build"] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
tokio = "1"
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
] }
|
||||
rustls = { version = "0.23", default-features = false, features = [
|
||||
"ring",
|
||||
], optional = true }
|
||||
url = { workspace = true }
|
||||
http = "1"
|
||||
minisign-verify = "0.2"
|
||||
time = { version = "0.3", features = ["parsing", "formatting"] }
|
||||
base64 = "0.22"
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
futures-util = "0.3"
|
||||
tempfile = "3.20"
|
||||
infer = "0.19"
|
||||
percent-encoding = "2.3"
|
||||
|
||||
[target."cfg(target_os = \"windows\")".dependencies]
|
||||
zip = { version = "4", default-features = false, optional = true }
|
||||
windows-sys = { version = "0.60.0", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
[target."cfg(target_os = \"linux\")".dependencies]
|
||||
dirs = "6"
|
||||
tar = { version = "0.4", optional = true }
|
||||
flate2 = { version = "1", optional = true }
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tar = "0.4"
|
||||
flate2 = "1"
|
||||
osakit = { version = "0.3", features = ["full"] }
|
||||
|
||||
[features]
|
||||
default = ["rustls-tls", "system-proxy", "zip"]
|
||||
zip = ["dep:zip", "dep:tar", "dep:flate2"]
|
||||
native-tls = ["reqwest/native-tls"]
|
||||
native-tls-vendored = ["reqwest/native-tls-vendored"]
|
||||
rustls-tls = ["reqwest/rustls-no-provider", "dep:rustls"]
|
||||
system-proxy = ["reqwest/system-proxy"]
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
SPDXVersion: SPDX-2.1
|
||||
DataLicense: CC0-1.0
|
||||
PackageName: tauri
|
||||
DataFormat: SPDXRef-1
|
||||
PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
|
||||
PackageHomePage: https://tauri.app
|
||||
PackageLicenseDeclared: Apache-2.0
|
||||
PackageLicenseDeclared: MIT
|
||||
PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy
|
||||
PackageSummary: <text>Tauri is a rust project that enables developers to make secure
|
||||
and small desktop applications using a web frontend.
|
||||
</text>
|
||||
PackageComment: <text>The package includes the following libraries; see
|
||||
Relationship information.
|
||||
</text>
|
||||
Created: 2019-05-20T09:00:00Z
|
||||
PackageDownloadLocation: git://github.com/tauri-apps/tauri
|
||||
PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git
|
||||
PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git
|
||||
Creator: Person: Daniel Thompson-Yvetot
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 - Present Tauri Apps Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
# ShaCraft metadata-only updater patch
|
||||
|
||||
This directory vendors the Rust/build portion of the official
|
||||
`tauri-plugin-updater` **2.11.0** crate. Files were copied from the local Cargo
|
||||
registry cache, and every copied file was checked byte-for-byte against the
|
||||
cached `.crate` archive before applying this patch. No replacement crate was
|
||||
downloaded for this vendoring step.
|
||||
|
||||
Upstream: <https://github.com/tauri-apps/plugins-workspace/tree/6aa2854f314481a459be1189b02c65a2450789ab/plugins/updater>
|
||||
|
||||
The published archive's SHA-256 is
|
||||
`b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606`.
|
||||
The original registry `.cargo_vcs_info.json`, normalized `Cargo.toml` and
|
||||
`Cargo.toml.orig` are retained for provenance. Unused upstream JS source, package
|
||||
lockfile, changelog and Cargo cache marker are omitted; `api-iife.js` is retained
|
||||
because the official build script references it.
|
||||
|
||||
## Local change
|
||||
|
||||
Only `src/updater.rs` changes upstream Rust behavior. The new public method
|
||||
`Updater::check_metadata(&self, raw_json: serde_json::Value) -> Result<Option<Update>>`
|
||||
is synchronous and performs **no HTTP requests**. It uses the existing release
|
||||
deserializer, current-version/comparator decision, platform URL/signature
|
||||
selection and `Update` construction. `Updater::check()` keeps its original
|
||||
endpoint iteration and validation, then calls this same method. Its accepted
|
||||
release is parsed again by the helper; endpoint selection and error semantics
|
||||
are preserved.
|
||||
|
||||
ShaCraft fetches a size-limited metadata response and verifies its signature in
|
||||
its own native trust boundary before passing that exact JSON value to this
|
||||
method. This avoids asking the upstream `check()` path to fetch and parse a
|
||||
second, potentially unbounded metadata response. This helper does not itself
|
||||
authenticate metadata; callers must enforce their own policy.
|
||||
|
||||
The upstream package download, Minisign verification and all platform installer
|
||||
implementations are unchanged by this patch. ShaCraft's own native integration
|
||||
may choose a separate bounded download and installation path.
|
||||
|
||||
## License and maintenance
|
||||
|
||||
Upstream is dual licensed **Apache-2.0 OR MIT**. Both full license files,
|
||||
`LICENSE.spdx`, copyright notices and `SECURITY.md` remain in this directory.
|
||||
Rebase this small patch when upgrading the dependency, retain upstream notices,
|
||||
and repeat native updater tests before release.
|
||||
+103
@@ -0,0 +1,103 @@
|
||||

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