diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0837f79..a549f7d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -13,6 +13,22 @@ concurrency: cancel-in-progress: true jobs: + admission-client: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + - name: Check the Minigames admission companion + working-directory: admission-client + run: ./gradlew test build --no-daemon + - uses: actions/upload-artifact@v4 + with: + name: shacraft-admission-client + if-no-files-found: error + path: admission-client/build/libs/shacraft-admission-client-0.1.0.jar check: runs-on: ubuntu-22.04 steps: diff --git a/.gitignore b/.gitignore index a295e3e..fb3d307 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ src-tauri/gen/ *.p12 *.pfx *.sig + +admission-client/.gradle/ +admission-client/build/ +graphify-out/ diff --git a/AGENTS.md b/AGENTS.md index 2e985f5..49f6428 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,9 @@ Cross-platform desktop launcher for the ShaCraft Minecraft network. It is a Tauri 2 application: React/Vite is the UI and Rust owns all filesystem, -network and process-adjacent work. The current production profile is -**Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21). +network and process-adjacent work. Profiles are **Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21) +and **Minigames** (Minecraft 26.2, Fabric 0.19.5, Java 25). See the staged +Minigames integration record below; published launcher status is separate. This repository owns the launcher only. The server-side API and published payload are in `/root/shacraft` on the ShaCraft host; see @@ -21,8 +22,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see ## Trust model -- The only supported remote profile manifest endpoint is - `https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`. +- The only supported remote profile manifest endpoints are the fixed + `https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest` and + `https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest`. The read-only Aeronautics player-count endpoint `https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it is display-only and is never allowed to influence downloads or launching. @@ -217,3 +219,29 @@ Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies signed-manifest retrieval and download/repair/restoration of the admission jar only in a temporary directory. Mac 0.1.5 connection failure remains unclassified pending exact error/log; this is not a verified macOS fix or desktop UI test. + +## Minigames integration (2026-09-13, staged) + +- Native profile mapping is fixed: aeronautics → aoc; minigames → minigames. + Both display/claim the canonical existing aoc nickname. The backend enforces + the current shared aoc subscription and whitelist for Minigames as well. + Ticket requests and responses remain bound to the selected server; never + accept an aoc ticket as a Minigames ticket. +- `fabric.rs` adds independent exact HTTPS domains `meta.fabricmc.net` and + `maven.fabricmc.net`. It verifies profile identity/parent/main class, bounded + metadata, portable Maven coordinates, hashes and sizes before the existing + atomic library installer. Unknown loaders now fail manifest validation. +- Minigames launches with a native-owned Quick Play endpoint + `135.106.154.86:25568`. The manifest cannot choose a game destination. +- `admission-client/` owns the small client-only Fabric 26.2 companion. Its + configuration-phase proof uses `minigames` in the existing Ed25519 transcript, + verifies the actual socket, canonical nickname and nonce, and signs once per + process. Only a fresh launch can retry a consumed ticket. Java receives only + the ephemeral ticket and private key in its child environment, never the + website session/password. Keep server verification on Paper before world + entry with an early duplicate UUID guard. +- The standalone Paper admission adapter and backend remain server-project + responsibilities. Do not put map/SMASH source into this launcher repository. +- Production updater publication requires a separately built, monotonically + newer launcher release and existing operator signatures. Source tests or a + client jar alone do not update installed 0.1.5 launchers. diff --git a/README.md b/README.md index 38ec584..5b5f31f 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,15 @@ Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/): React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов. -Реализованы подписанная синхронизация Aeronautics, проверка/восстановление -модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и -NeoForge, настройки памяти и ника, обработка установки/запуска/выхода. +Реализованы отдельные профили Aeronautics (Minecraft 1.21.1, NeoForge, Java 21) +и Minigames (Minecraft 26.2, Fabric, Java 25), подписанная синхронизация файлов, +проверка/восстановление модов и конфигурации, установка игры и Java, настройки +памяти, обработка установки/запуска/выхода. Minigames добавлен в кандидате 0.1.6; +статус публикации фиксируется в [записи релиза](docs/release-0.1.6.md). Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник -берётся только из подтверждённой привязки Aeronautics, а не из редактируемых -локальных настроек. Пароли не сохраняются; сессию можно отозвать. +общий для обоих профилей и берётся только из подтверждённой привязки `aoc`. +Тикет входа привязан к выбранному серверу; локальные настройки не выбирают личность. Пароли не сохраняются; сессию можно отозвать. Microsoft OAuth-модуль сохранён отдельно, но не используется текущим сценарием запуска; для его активации потребуются client ID и API approval. @@ -52,8 +54,8 @@ push/PR; workflow на main-push/ручном запуске собирает Wi Для deb система запрашивает права администратора; пароль не передаётся лаунчеру. Deb 0.1.3 и ниже нужно один раз обновить вручную до 0.1.4. Dev-бинарник обновляется вручную. С версии 0.1.2 нужен ручной переход на новый AppImage. -Пакеты Windows/macOS -с этим механизмом ещё требуют публикации и проверки установки. +Пакеты Windows/macOS 0.1.5 опубликованы. Проверка реальной установки на каждой +платформе остаётся отдельной от сборки и автоматических тестов. ## Навигация diff --git a/admission-client/README.md b/admission-client/README.md new file mode 100644 index 0000000..13b2485 --- /dev/null +++ b/admission-client/README.md @@ -0,0 +1,47 @@ +# ShaCraft Minigames admission client + +Client-only Fabric companion for Minecraft 26.2, Java 25, Fabric Loader 0.19.5 +and Fabric API 0.160.0+26.2. This is account admission, not an anti-cheat or a +proof that the original launcher binary is running. + +Build with Java 25: `./gradlew test build --no-daemon`. +Output: `build/libs/shacraft-admission-client-0.1.0.jar`. +Publish that jar and the pinned Fabric API jar in the signed Minigames profile. + +The native launcher supplies `SHACRAFT_ADMISSION_TICKET` and +`SHACRAFT_ADMISSION_PRIVATE_KEY` only to the final Java child environment. +The client accepts a CONFIGURATION payload on `shacraft_admission:challenge` +with three Minecraft UTF strings: server ID (16), nickname (16), nonce (43). +It verifies server `minigames`, the exact current game nickname and actual +socket `135.106.154.86:25568`, then signs once with the ephemeral Ed25519 key. +The response on `shacraft_admission:proof` contains ticket (43) and standard +Base64 signature (88). The transcript has no final newline: + +``` +shacraft-admission-v1 +{ticket_id} +minigames +{mc_username} +{nonce} +``` + +The private key and website session never go onto the Minecraft wire. Errors +are redacted. A fresh game launch is needed for another connection after a +proof has been sent. `SHACRAFT_ADMISSION_ALLOW_LOOPBACK=1` additionally permits +literal loopback sockets for isolated tests; normal releases do not set it. +Paper must fail closed before world entry and reject unauthenticated duplicate +UUIDs before the vanilla duplicate-player eviction. The backend checks the +current shared aoc account access and atomically redeems the server-bound ticket. + +Three unit tests cover exact signature binding, invalid/cross-server fields +and socket allowlisting. The unchanged production companion also passed actual Minecraft 26.2 +configuration negotiation and entered a local Paper lobby with a synthetic +backend ticket; see [receipt](../docs/verification/minigames-fabric-2026-09-13.json). +The public server and other platforms still require their own rollout checks. + +The client explicitly advertises its single challenge receiver with vanilla +`minecraft:register` at the start of configuration. Fabric normally waits for +the server's registration first, while Paper gates plugin sends on that client +advertisement. This bootstrap uses the pinned Fabric API's RegistrationPayload; +update it and rerun live negotiation checks when upgrading Fabric API. It is +queued after INIT so vanilla has switched outbound protocol to CONFIGURATION. diff --git a/admission-client/build.gradle b/admission-client/build.gradle new file mode 100644 index 0000000..60658df --- /dev/null +++ b/admission-client/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'net.fabricmc.fabric-loom' version "${loom_version}" +} + +repositories { mavenCentral() } + +loom { + splitEnvironmentSourceSets() + mods { + 'shacraft_admission' { + sourceSet sourceSets.main + sourceSet sourceSets.client + } + } +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + implementation "net.fabricmc:fabric-loader:${project.loader_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + testImplementation platform('org.junit:junit-bom:5.12.2') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +processResources { + inputs.property 'version', project.version + filesMatching('fabric.mod.json') { expand version: project.version } +} + +tasks.withType(JavaCompile).configureEach { options.release = 25 } +java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() } +test { useJUnitPlatform() } + +// Pure HTTP/validation tests use the same client implementation without launching Minecraft. +sourceSets.test.compileClasspath += sourceSets.client.output +sourceSets.test.runtimeClasspath += sourceSets.client.output diff --git a/admission-client/gradle.properties b/admission-client/gradle.properties new file mode 100644 index 0000000..41648e0 --- /dev/null +++ b/admission-client/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx2G +org.gradle.parallel=false +org.gradle.configuration-cache=false +minecraft_version=26.2 +loader_version=0.19.5 +loom_version=1.17.20 +fabric_api_version=0.160.0+26.2 +version=0.1.0 +group=ru.shacraft diff --git a/admission-client/gradle/wrapper/gradle-wrapper.jar b/admission-client/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/admission-client/gradle/wrapper/gradle-wrapper.jar differ diff --git a/admission-client/gradle/wrapper/gradle-wrapper.properties b/admission-client/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..62221af --- /dev/null +++ b/admission-client/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +networkTimeout=30000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/admission-client/gradlew b/admission-client/gradlew new file mode 100755 index 0000000..04c0e4e --- /dev/null +++ b/admission-client/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/admission-client/gradlew.bat b/admission-client/gradlew.bat new file mode 100644 index 0000000..62ce92d --- /dev/null +++ b/admission-client/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/admission-client/settings.gradle b/admission-client/settings.gradle new file mode 100644 index 0000000..528cdbb --- /dev/null +++ b/admission-client/settings.gradle @@ -0,0 +1,8 @@ +pluginManagement { + repositories { + maven { url = 'https://maven.fabricmc.net/' } + mavenCentral() + gradlePluginPortal() + } +} +rootProject.name = 'shacraft-admission-client' diff --git a/admission-client/src/client/java/ru/shacraft/admission/ClientAdmission.java b/admission-client/src/client/java/ru/shacraft/admission/ClientAdmission.java new file mode 100644 index 0000000..86f6cd6 --- /dev/null +++ b/admission-client/src/client/java/ru/shacraft/admission/ClientAdmission.java @@ -0,0 +1,57 @@ +package ru.shacraft.admission; + +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicBoolean; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking; +import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationConnectionEvents; +import net.fabricmc.fabric.impl.networking.RegistrationPayload; +import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket; +import java.util.List; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.minecraft.network.chat.Component; + +/** The account session remains in the native launcher, never in Minecraft. */ +public final class ClientAdmission implements ClientModInitializer { + private static final AtomicBoolean USED = new AtomicBoolean(); + + @Override public void onInitializeClient() { + PayloadTypeRegistry.clientboundConfiguration().register(AdmissionPayloads.Challenge.TYPE, AdmissionPayloads.Challenge.CODEC); + PayloadTypeRegistry.serverboundConfiguration().register(AdmissionPayloads.Proof.TYPE, AdmissionPayloads.Proof.CODEC); + ClientConfigurationNetworking.registerGlobalReceiver(AdmissionPayloads.Challenge.TYPE, ClientAdmission::challenge); + // Paper waits for vanilla channel advertisement, while Fabric normally waits + // for the server's registration first. Bootstrap our one fixed receiver. + // INIT runs in the listener constructor; schedule() queues until after vanilla + // has switched the outbound protocol from LOGIN to CONFIGURATION. + ClientConfigurationConnectionEvents.INIT.register((listener, client) -> client.schedule(() -> + listener.send(new ServerboundCustomPayloadPacket(new RegistrationPayload( + RegistrationPayload.REGISTER, List.of(AdmissionPayloads.Challenge.TYPE.id())))))); + } + + private static void challenge(AdmissionPayloads.Challenge challenge, ClientConfigurationNetworking.Context context) { + var connection = context.packetContext().orElseThrow(net.fabricmc.fabric.api.networking.v1.context.PacketContext.CONNECTION); + boolean loopback = "1".equals(System.getenv("SHACRAFT_ADMISSION_ALLOW_LOOPBACK")); + if (!(connection.getRemoteAddress() instanceof InetSocketAddress remote) + || remote.getAddress() == null + || !AdmissionProof.allowedTarget(remote.getAddress().getHostAddress(), remote.getPort(), loopback) + || !AdmissionProof.SERVER_ID.equals(challenge.serverId()) + || !context.client().getUser().getName().equals(challenge.nickname()) + || !AdmissionProof.validOpaque(challenge.nonce())) { + deny(context); return; + } + String ticket = System.getenv("SHACRAFT_ADMISSION_TICKET"); + String privateKey = System.getenv("SHACRAFT_ADMISSION_PRIVATE_KEY"); + if (!AdmissionProof.validOpaque(ticket) || !USED.compareAndSet(false, true)) { + deny(context); return; + } + try { + String signature = AdmissionProof.sign(privateKey, ticket, challenge.serverId(), challenge.nickname(), challenge.nonce()); + context.responseSender().sendPacket(new AdmissionPayloads.Proof(ticket, signature)); + } catch (Exception invalidKey) { deny(context); } + } + + private static void deny(ClientConfigurationNetworking.Context context) { + context.responseSender().disconnect(Component.literal( + "Не удалось подтвердить вход ShaCraft. Закройте игру и запустите её заново через ShaCraft Launcher.")); + } +} diff --git a/admission-client/src/main/java/ru/shacraft/admission/AdmissionPayloads.java b/admission-client/src/main/java/ru/shacraft/admission/AdmissionPayloads.java new file mode 100644 index 0000000..8f4a481 --- /dev/null +++ b/admission-client/src/main/java/ru/shacraft/admission/AdmissionPayloads.java @@ -0,0 +1,28 @@ +package ru.shacraft.admission; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public final class AdmissionPayloads { + private AdmissionPayloads() {} + + public record Challenge(String serverId, String nickname, String nonce) implements CustomPacketPayload { + public static final Type TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "challenge")); + public static final StreamCodec CODEC = StreamCodec.of( + (buffer, value) -> { buffer.writeUtf(value.serverId, 16); buffer.writeUtf(value.nickname, 16); buffer.writeUtf(value.nonce, 43); }, + buffer -> new Challenge(buffer.readUtf(16), buffer.readUtf(16), buffer.readUtf(43))); + @Override public Type type() { return TYPE; } + @Override public String toString() { return "AdmissionChallenge[redacted]"; } + } + + public record Proof(String ticket, String signature) implements CustomPacketPayload { + public static final Type TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "proof")); + public static final StreamCodec CODEC = StreamCodec.of( + (buffer, value) -> { buffer.writeUtf(value.ticket, 43); buffer.writeUtf(value.signature, 88); }, + buffer -> new Proof(buffer.readUtf(43), buffer.readUtf(88))); + @Override public Type type() { return TYPE; } + @Override public String toString() { return "AdmissionProof[redacted]"; } + } +} diff --git a/admission-client/src/main/java/ru/shacraft/admission/AdmissionProof.java b/admission-client/src/main/java/ru/shacraft/admission/AdmissionProof.java new file mode 100644 index 0000000..4187554 --- /dev/null +++ b/admission-client/src/main/java/ru/shacraft/admission/AdmissionProof.java @@ -0,0 +1,67 @@ +package ru.shacraft.admission; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.regex.Pattern; + +/** The only client credential is an ephemeral private key supplied by the launcher. */ +public final class AdmissionProof { + public static final String SERVER_ID = "minigames"; + private static final Pattern OPAQUE = Pattern.compile("[A-Za-z0-9_-]{43}"); + private static final Pattern NICKNAME = Pattern.compile("[A-Za-z0-9_]{3,16}"); + + private AdmissionProof() {} + + public static boolean validOpaque(String value) { + return value != null && OPAQUE.matcher(value).matches(); + } + + public static byte[] transcript(String ticket, String serverId, String nickname, String nonce) { + if (!validOpaque(ticket) || !validOpaque(nonce) || !SERVER_ID.equals(serverId) + || nickname == null || !NICKNAME.matcher(nickname).matches()) { + throw new IllegalArgumentException("Invalid admission challenge"); + } + return ("shacraft-admission-v1\n" + ticket + "\n" + serverId + "\n" + + nickname + "\n" + nonce).getBytes(StandardCharsets.UTF_8); + } + + public static String sign(String encodedPrivateKey, String ticket, String serverId, + String nickname, String nonce) throws Exception { + if (encodedPrivateKey == null || encodedPrivateKey.length() > 256) { + throw new IllegalArgumentException("Missing admission key"); + } + byte[] encoded = Base64.getDecoder().decode(encodedPrivateKey); + try { + PrivateKey key = KeyFactory.getInstance("Ed25519") + .generatePrivate(new PKCS8EncodedKeySpec(encoded)); + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(key); + signer.update(transcript(ticket, serverId, nickname, nonce)); + return Base64.getEncoder().encodeToString(signer.sign()); + } finally { + java.util.Arrays.fill(encoded, (byte) 0); + } + } + + public static boolean validSignature(String value) { + if (value == null || value.length() != 88) return false; + try { + byte[] decoded = Base64.getDecoder().decode(value); + return decoded.length == 64 && Base64.getEncoder().encodeToString(decoded).equals(value); + } catch (IllegalArgumentException invalid) { + return false; + } + } + + public static boolean allowedTarget(String host, int port, boolean allowLoopback) { + if (host == null) return false; + if (allowLoopback && (host.equals("127.0.0.1") || host.equals("::1") || host.equals("[::1]") || host.equals("0:0:0:0:0:0:0:1"))) { + return port > 0 && port <= 65535; + } + return port == 25568 && (host.equalsIgnoreCase("shacraft.ru") || host.equals("135.106.154.86")); + } +} diff --git a/admission-client/src/main/resources/fabric.mod.json b/admission-client/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..0fafe49 --- /dev/null +++ b/admission-client/src/main/resources/fabric.mod.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "id": "shacraft_admission", + "version": "${version}", + "name": "ShaCraft Minigames Admission", + "description": "Account-bound admission to ShaCraft Minigames.", + "environment": "client", + "entrypoints": { "client": ["ru.shacraft.admission.ClientAdmission"] }, + "depends": { "fabricloader": ">=0.19.5", "minecraft": "26.2", "java": ">=25", "fabric-networking-api-v1": "*" } +} diff --git a/admission-client/src/test/java/ru/shacraft/admission/AdmissionProofTest.java b/admission-client/src/test/java/ru/shacraft/admission/AdmissionProofTest.java new file mode 100644 index 0000000..560e1e7 --- /dev/null +++ b/admission-client/src/test/java/ru/shacraft/admission/AdmissionProofTest.java @@ -0,0 +1,32 @@ +package ru.shacraft.admission; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.charset.StandardCharsets; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class AdmissionProofTest { + private static final String TICKET = "A".repeat(43), NONCE = "B".repeat(43); + @Test void signsExactServerBoundTranscript() throws Exception { + var pair=KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + String signed=AdmissionProof.sign(Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded()), TICKET,"minigames","Pilot_1",NONCE); + var verifier=Signature.getInstance("Ed25519"); verifier.initVerify(pair.getPublic()); + verifier.update(("shacraft-admission-v1\n"+TICKET+"\nminigames\nPilot_1\n"+NONCE).getBytes(StandardCharsets.UTF_8)); + assertTrue(verifier.verify(Base64.getDecoder().decode(signed))); + verifier.update(AdmissionProof.transcript(TICKET,"minigames","Other",NONCE)); + assertFalse(verifier.verify(Base64.getDecoder().decode(signed))); + } + @Test void rejectsCrossServerAndMalformedFields() { + for (String[] v:new String[][]{{TICKET,"aoc","Pilot",NONCE},{TICKET,"minigames","Bad\nName",NONCE},{"bad","minigames","Pilot",NONCE},{TICKET,"minigames","Pilot","bad"}}) + assertThrows(IllegalArgumentException.class,()->AdmissionProof.transcript(v[0],v[1],v[2],v[3])); + } + @Test void trustsOnlyMinigamesSocketAndExplicitLocalTests() { + assertTrue(AdmissionProof.allowedTarget("135.106.154.86",25568,false)); + assertFalse(AdmissionProof.allowedTarget("135.106.154.86",25567,false)); + assertFalse(AdmissionProof.allowedTarget("127.0.0.1",25568,false)); + assertTrue(AdmissionProof.allowedTarget("127.0.0.1",25570,true)); + assertFalse(AdmissionProof.allowedTarget("attacker.invalid",25568,true)); + } +} diff --git a/docs/game-trust-boundary.md b/docs/game-trust-boundary.md index 2fe8882..877b1c8 100644 --- a/docs/game-trust-boundary.md +++ b/docs/game-trust-boundary.md @@ -90,3 +90,21 @@ URL — cannot redirect a download to an attacker-controlled host in any of these domains. When adding a new game-related download, verify its host is one of the ones above (or add a new hardcoded constant following the same pattern) rather than accepting a URL from anywhere else. + +## 5. Fabric (`fabric.rs`, Minigames) + +Metadata comes only from `https://meta.fabricmc.net/v2/versions/loader/` for +manifest-selected, validated version identifiers. Profile ID, parent and +KnotClient main class must match. At most 512 KiB metadata and 32 libraries +are accepted. Library URLs are constructed only below +`https://maven.fabricmc.net/` from portable three-part Maven coordinates; +other metadata origins are rejected. Libraries require a 40-hex SHA-1 and a +positive size up to 64 MiB. When Fabric metadata omits either for its loader +jar, the fixed Maven's `.sha1` sidecar and HEAD provide them. Existing verified +downloads perform the hash/size check and atomic rename. The ShaCraft signed +manifest cannot select Fabric metadata URLs, repositories or launch targets. + +Minecraft 26.2's official version metadata requires Java 25. `runtime.rs` +already provisions a separate Adoptium Java 25 runtime without changing the +Aeronautics Java 21 runtime. Client companion mods and Fabric API are separately +approved ShaCraft managed files in the signed Minigames profile. diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index b3be442..d1a06f8 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -2,7 +2,7 @@ ## Current capability -The launcher persists local settings, synchronises Aeronautics mod/config +The launcher persists local settings, synchronises profile mod/config files from the signed ShaCraft v2 manifest, installs the exact Minecraft + NeoForge version the manifest specifies, and launches the game. A player signs in with the same local ShaCraft account used on the website. The current @@ -320,3 +320,42 @@ Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies signed-manifest retrieval and download/repair/restoration of the admission jar only in a temporary directory. Mac 0.1.5 connection failure remains unclassified pending exact error/log; this is not a verified macOS fix or desktop UI test. + +## Minigames alongside Aeronautics (2026-09-13) + +The new native allowlist adds profile `minigames` at +`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest` and +its display-only count at `https://shacraft.ru/api/online/minigames`. +Aeronautics remains a separate profile and keeps its previous managed files. +Minigames uses Minecraft 26.2, Fabric Loader 0.19.5, Java 25 and its own +`profiles/minigames` game directory. The signed payload supplies Fabric API +0.160.0+26.2 and `mods/shacraft-admission-client-0.1.0.jar`; it never supplies +Paper, the server plugins, maps, credentials or game-download URLs. + +`fabric.rs` obtains an exact parent/loader profile from fixed Fabric metadata, +checks its identity and KnotClient entry point, and converts bounded Maven +library entries into the existing verified library contract. Each artifact +URL is constructed from a validated coordinate below fixed Fabric Maven; +metadata-supplied alternative origins are rejected. SHA-1 and size come from +Fabric metadata, or the same Maven's hash sidecar and HEAD for the loader jar. +Java provisioning already accepts exactly Java 25. Automatic installation on +a platform still requires a real cold-install check on that platform. + +Both profiles claim/display the canonical `aoc` nickname. Only the native +profile mapping determines ticket `server_id` (`aoc` or `minigames`), and the +response must match that exact server. The backend is responsible for shared +access checks at both issuance and redemption. Existing account sessions and +settings need no migration. No copied subscription/whitelist grant is trusted. + +Minigames adds native `--quickPlayMultiplayer 135.106.154.86:25568` at launch. +The client-only Fabric companion validates the actual socket target and signs +the existing configuration challenge with `minigames` in the transcript. +Paper performs verification before entry; its early duplicate UUID gate must +run before vanilla would evict the existing player. There is no proxy and no +client-side shared secret. One ticket is used for one game connection; a fresh +launcher start is required after expiry, consumption or a failed proof attempt. + +The integration is staged until server authentication, signed profile payload, +and a newer signed launcher release are deployed and checked together. The +existing public 0.1.5 binary cannot select the new profile by a website-only +catalog change. Preserve both catalog entries when publishing either profile. diff --git a/docs/release-0.1.6.md b/docs/release-0.1.6.md new file mode 100644 index 0000000..4056938 --- /dev/null +++ b/docs/release-0.1.6.md @@ -0,0 +1,105 @@ +# Launcher 0.1.6 candidate + +Adds a separate Minigames profile (Minecraft 26.2 / Fabric 0.19.5 / Java 25) +while keeping Aeronautics and its installed profile intact. Both use the +existing canonical aoc nickname and shared access. Tickets remain bound to +the selected server. Minigames connects to 135.106.154.86:25568 and proves +account admission during configuration before world entry. + +Local checks: 87 native tests, 33 UI tests, TypeScript/Vite, three Java client +proof tests, an official Fabric metadata resolution check and the Linux native +release build. The [real Fabric/Paper smoke](verification/minigames-fabric-2026-09-13.json) +passed with a synthetic account: the unchanged production companion entered +the lobby and the backend consumed its Minigames ticket. Cross-platform CI +and production rollout results must be recorded before calling this published. No updater feed has +been changed by preparing this candidate. + +## Build contract + +Git remote: `git@github.com:emil28092005/shacraft-launcher.git`. +The production admission line is branch `codex/launcher-updater`; `main` is a +divergent unreleased prototype and must remain unchanged for this release. +Push the reviewed commit to the production branch, then dispatch +`gh workflow run build.yml --ref codex/launcher-updater --repo emil28092005/shacraft-launcher`. +The check workflow runs on branch pushes. Verify each run's head SHA equals the +reviewed commit before downloading artifacts. A main push also triggers the +build matrix, but is not the publication path for this candidate. +CI publishes unsigned artifacts named: + +- `shacraft-launcher-linux-x64`: AppImage and deb. +- `shacraft-launcher-windows-x64`: NSIS exe and MSI. +- `shacraft-launcher-macos-arm64`: aarch64 app.tar.gz and DMG. +- `shacraft-launcher-macos-x64`: x86_64 app.tar.gz and DMG. + +`.github/workflows/check.yml` additionally tests the Java 25 admission client +and uploads `shacraft-admission-client`. It has no production credentials. +Download the artifacts from the checked run at the exact reviewed commit with +`gh run download RUN_ID --repo emil28092005/shacraft-launcher --dir STAGING`. + +## Signing and publication + +Stage renamed ASCII filenames below a local downloads root, for example +`/tmp/shacraft-release-0.1.6/downloads/0.1.6/`. Preserve already published +0.1.5 bytes. Expected updater filenames: + +- `ShaCraft.Launcher_0.1.6_amd64.AppImage` +- `ShaCraft.Launcher_0.1.6_amd64.deb` +- `ShaCraft.Launcher_0.1.6_x64-setup.exe` +- `ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz` +- `ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz` + +MSI and DMG are additional manual downloads; the updater uses EXE and app.tar.gz. +Inspect package versions, architecture and contents before signing. Sign each +chosen file with the existing local operator key: + +```bash +npm run tauri -- signer sign --private-key-path /home/emil/.local/share/shacraft-updater/production.key ARTIFACT +``` + +The key file is mode 0600 and remains local. Never read its contents into logs, +copy it to CI/server or substitute a different signing identity. Existing +`production.key.pub` is sufficient for every later verification/publication. + +After creating a UTF-8 release notes file, prepare the payload: + +```bash +python3 scripts/publish_launcher_update.py prepare \ + --version 0.1.6 \ + --downloads-root /tmp/shacraft-release-0.1.6/downloads \ + --artifact linux-x86_64=ShaCraft.Launcher_0.1.6_amd64.AppImage \ + --artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.6_amd64.AppImage \ + --artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.6_amd64.deb \ + --artifact windows-x86_64=ShaCraft.Launcher_0.1.6_x64-setup.exe \ + --artifact darwin-aarch64=ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz \ + --artifact darwin-x86_64=ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz \ + --notes-file /tmp/shacraft-release-0.1.6/notes.txt \ + --public-key /home/emil/.local/share/shacraft-updater/production.key.pub \ + --payload /tmp/shacraft-release-0.1.6/release.payload.json +npm run tauri -- signer sign \ + --private-key-path /home/emil/.local/share/shacraft-updater/production.key \ + /tmp/shacraft-release-0.1.6/release.payload.json +``` + +Upload only public packages, signatures, payload, public key and publisher. +Server downloads root is `/root/shacraft/caddy/www/downloads/shacraft-launcher`; +public artifact URLs are `https://shacraft.ru/downloads/shacraft-launcher/0.1.6/` +followed by the checked filename. Preserve the old feed before publication. +With the exact staged server paths, run the existing publisher first with +`--dry-run`, then without it: + +```bash +python3 publish_launcher_update.py publish \ + --downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \ + --public-key PUBLIC_KEY_FILE \ + --payload SIGNED_PAYLOAD_FILE \ + --signature PAYLOAD_SIGNATURE_FILE \ + --output /root/shacraft/data/launcher/updates/stable.json \ + --dry-run +``` + +Publication depends on the signed Minigames profile containing the final client +and Fabric API jars, the healthy Paper admission gate, and the shared-access +backend endpoints being available. Verify public HTTPS package hashes, feed +signatures and an actual client admission before updating the website buttons. +OS Authenticode/Apple notarization and cold installations on other platforms +remain distinct from successful native CI/builds. diff --git a/docs/verification/minigames-fabric-2026-09-13.json b/docs/verification/minigames-fabric-2026-09-13.json new file mode 100644 index 0000000..7c6e8d7 --- /dev/null +++ b/docs/verification/minigames-fabric-2026-09-13.json @@ -0,0 +1,33 @@ +{ + "recorded_at": "2026-09-13T19:32:59.436884+00:00", + "result": "joined_world", + "client": "Minecraft 26.2 / Fabric Loader 0.19.5 / Fabric API 0.160.0+26.2", + "java": "25.0.2", + "client_jar_sha256": "3335626b7c8fdd233e8531ad382594398c7df48842b1b919934d1154dd4ba8f1", + "client_jar_size": 10667, + "profile": "minigames", + "synthetic_player": "AdmissionPilot", + "endpoint": "127.0.0.1:25608", + "environment": "isolated Xvfb :95; separate temporary game directory; synthetic account/ticket; explicit local socket test flag", + "server_evidence": [ + "[22:32:02 INFO]: AdmissionPilot joined the game", + "[22:32:02 INFO]: AdmissionPilot[/127.0.0.1:54602] logged in with entity id 77 at ([minecraft:shacraft_lobby_v2]0.5, 96.0, 43.5)" + ], + "verified": [ + "production companion jar bytes unchanged", + "queued minecraft:register during configuration", + "server-bound Ed25519 response accepted", + "actual vanilla client entered lobby world" + ], + "not_tested": [ + "production public endpoint login", + "Windows/macOS cold installation", + "full GUI launcher install/account flow" + ], + "notes": "Offline client emits Microsoft profile-certificate HTTP401; admission and world entry succeeded. Real account/session/password not used.", + "backend_evidence": { + "source": "backend agent read-only synthetic SQLite query", + "issued_minigames_tickets_for_player": 1, + "consumed_minigames_tickets_for_player": 1 + } +} diff --git a/package-lock.json b/package-lock.json index f831320..ae523a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shacraft-launcher-ui", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shacraft-launcher-ui", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@tauri-apps/api": "2.11.1", "lucide-react": "1.41.0", diff --git a/package.json b/package.json index d11dd23..455916e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "shacraft-launcher-ui", "license": "MIT", "private": true, - "version": "0.1.5", + "version": "0.1.6", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 27bcc9f..904b50a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3361,7 +3361,7 @@ dependencies = [ [[package]] name = "shacraft-launcher" -version = "0.1.5" +version = "0.1.6" dependencies = [ "base64 0.22.1", "ed25519-dalek", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a6d798d..98ff6d0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shacraft-launcher" -version = "0.1.5" +version = "0.1.6" description = "ShaCraft Minecraft launcher" authors = ["ShaCraft"] license = "MIT" diff --git a/src-tauri/src/admission.rs b/src-tauri/src/admission.rs index 881ffbd..f577842 100644 --- a/src-tauri/src/admission.rs +++ b/src-tauri/src/admission.rs @@ -19,11 +19,21 @@ use zeroize::Zeroizing; pub(crate) const TICKET_ENV: &str = "SHACRAFT_ADMISSION_TICKET"; pub(crate) const PRIVATE_KEY_ENV: &str = "SHACRAFT_ADMISSION_PRIVATE_KEY"; +#[cfg(test)] const SERVER_ID: &str = "aoc"; + +pub(crate) fn server_for_profile(profile: &str) -> Result<&'static str, &'static str> { + match profile { + "aeronautics" => Ok("aoc"), + "minigames" => Ok("minigames"), + _ => Err("Unknown ShaCraft profile"), + } +} const MAX_LIFETIME_SECONDS: u64 = 600; // Deliberately no Debug, Clone or Serialize for secret-bearing values. pub(crate) struct AdmissionKey { + server_id: &'static str, public_key: String, private_key: Zeroizing, } @@ -43,13 +53,15 @@ pub(crate) struct TicketResponse { } pub(crate) struct Admission { + server_id: &'static str, ticket_id: Zeroizing, private_key: Zeroizing, identity: PlayerIdentity, } impl AdmissionKey { - pub(crate) fn generate() -> Result { + pub(crate) fn generate(server_id: &'static str) -> Result { + if !matches!(server_id, "aoc" | "minigames") { return Err("Unknown ShaCraft server"); } let mut seed = Zeroizing::new([0_u8; 32]); getrandom::fill(seed.as_mut()) .map_err(|_| "Не удалось создать защищённый ключ входа. Повторите запуск лаунчера.")?; @@ -65,6 +77,7 @@ impl AdmissionKey { .to_pkcs8_der() .map_err(|_| "Не удалось подготовить защищённый ключ входа.")?; Ok(Self { + server_id, public_key, private_key: Zeroizing::new(STANDARD.encode(encoded.as_bytes())), }) @@ -72,7 +85,7 @@ impl AdmissionKey { pub(crate) fn request(&self) -> TicketRequest<'_> { TicketRequest { - server_id: SERVER_ID, + server_id: self.server_id, public_key: &self.public_key, } } @@ -92,12 +105,13 @@ impl AdmissionKey { .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); if !valid_ticket || !valid_nickname - || response.server_id != SERVER_ID + || response.server_id != self.server_id || !(1..=MAX_LIFETIME_SECONDS).contains(&response.expires_in_seconds) { return Err("Сервер вернул некорректное разрешение на вход. Повторите попытку позже."); } Ok(Admission { + server_id: self.server_id, ticket_id, private_key: self.private_key, identity: PlayerIdentity::Offline { @@ -108,6 +122,7 @@ impl AdmissionKey { } impl Admission { + pub(crate) fn server_id(&self) -> &str { self.server_id } pub(crate) fn identity(&self) -> &PlayerIdentity { &self.identity } @@ -132,10 +147,25 @@ mod tests { } } + #[test] + fn profile_tickets_are_server_bound_with_shared_canonical_identity() { + assert_eq!(server_for_profile("aeronautics").unwrap(), "aoc"); + assert_eq!(server_for_profile("minigames").unwrap(), "minigames"); + assert!(server_for_profile("../../other").is_err()); + assert!(AdmissionKey::generate("other").is_err()); + assert!(AdmissionKey::generate("minigames").unwrap().bind(response()).is_err()); + let key=AdmissionKey::generate("minigames").unwrap(); + assert_eq!(serde_json::to_value(key.request()).unwrap()["server_id"], "minigames"); + let mut payload=response(); payload.server_id="minigames".into(); + let admitted=key.bind(payload).unwrap(); + assert_eq!(admitted.server_id(), "minigames"); + assert_eq!(admitted.identity().name(), "Canonical_Name"); + } + #[test] fn generates_distinct_keys_and_only_sends_the_public_key() { - let key = AdmissionKey::generate().unwrap(); - let other = AdmissionKey::generate().unwrap(); + let key = AdmissionKey::generate("aoc").unwrap(); + let other = AdmissionKey::generate("aoc").unwrap(); assert_ne!(key.public_key, other.public_key); let payload = serde_json::to_value(key.request()).unwrap(); assert_eq!(payload.as_object().unwrap().len(), 2); @@ -173,7 +203,7 @@ mod tests { for mutate in mutations { let mut payload = response(); mutate(&mut payload); - assert!(AdmissionKey::generate().unwrap().bind(payload).is_err()); + assert!(AdmissionKey::generate("aoc").unwrap().bind(payload).is_err()); } } @@ -181,7 +211,7 @@ mod tests { fn secrets_only_enter_the_child_environment_and_identity_comes_from_ticket() { let original_ticket = std::env::var_os(TICKET_ENV); let original_key = std::env::var_os(PRIVATE_KEY_ENV); - let admission = AdmissionKey::generate().unwrap().bind(response()).unwrap(); + let admission = AdmissionKey::generate("aoc").unwrap().bind(response()).unwrap(); let mut command = Command::new("java"); command .arg("-Xmx6144M") diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index 489a7be..ecbdc5a 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -1,6 +1,6 @@ use super::data_dir; use crate::{ - java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime, + fabric, java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime, settings, shacraft_account, }; use reqwest::blocking::Client; @@ -56,6 +56,9 @@ fn resolve_merged_version( ) .map_err(|error| error.to_string())?; mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string()) + } else if manifest.minecraft.loader.kind == "fabric" { + let child = fabric::fetch_profile(&manifest.minecraft.version, &manifest.minecraft.loader.version)?; + mojang::merge_versions(&vanilla, Some(&child)).map_err(|error| error.to_string()) } else { mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string()) } @@ -205,7 +208,7 @@ pub(crate) async fn launch_game( // through spawn so local logout/account switching cannot race issuance. let _account_permit = account_operation.acquire("ShaCraft account operation")?; let admission = - shacraft_account::issue_admission(&data_dir).map_err(|error| error.to_string())?; + shacraft_account::issue_admission(&data_dir, crate::admission::server_for_profile(&profile_id)?).map_err(|error| error.to_string())?; let request = launch::LaunchRequest { java_executable: Path::new(&java_install.executable), game_dir: &game_dir, diff --git a/src-tauri/src/fabric.rs b/src-tauri/src/fabric.rs new file mode 100644 index 0000000..afe29ed --- /dev/null +++ b/src-tauri/src/fabric.rs @@ -0,0 +1,220 @@ +//! Fabric metadata and Maven are fixed, independent game trust domains. +//! The signed ShaCraft manifest selects versions, never arbitrary loader URLs. +use crate::mojang::{Artifact, LibraryDownloads, VersionJson}; +use reqwest::blocking::Client; +use serde::Deserialize; +use std::{io::Read, time::Duration}; + +pub(crate) const MAVEN_HOST: &str = "maven.fabricmc.net"; +const HOSTS: [&str; 2] = ["meta.fabricmc.net", MAVEN_HOST]; +const MAX_PROFILE: usize = 512 * 1024; + +#[derive(Deserialize)] +struct FabricLibrary { + name: String, + url: String, + sha1: Option, + size: Option, +} + +fn coordinate_path(name: &str) -> Result { + let pieces: Vec<_> = name.split(':').collect(); + if pieces.len() != 3 + || pieces.iter().any(|p| { + !crate::manifest::is_portable_component(p) + || *p == "." + || *p == ".." + || !p + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c)) + }) + || pieces[0].split('.').any(|p| !crate::manifest::is_portable_component(p)) + { + return Err("Invalid Fabric library coordinate".into()); + } + Ok(format!( + "{}/{}/{}/{}-{}.jar", + pieces[0].replace('.', "/"), + pieces[1], + pieces[2], + pieces[1], + pieces[2] + )) +} + +fn get(client: &Client, url: &str, limit: usize) -> Result, String> { + let response = client + .get(url) + .send() + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + let mut bytes = Vec::new(); + response + .take((limit + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > limit { + return Err("Fabric metadata exceeds its size limit".into()); + } + Ok(bytes) +} + +pub(crate) fn fetch_profile(minecraft: &str, loader: &str) -> Result { + let client = + crate::trusted_http::client(&HOSTS, Duration::from_secs(30)).map_err(|e| e.to_string())?; + // Defence in depth: these values normally already passed manifest validation. + if [minecraft, loader].iter().any(|v| { + !crate::manifest::is_portable_component(v) + || v.contains('/') + || !v + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c)) + }) { + return Err("Invalid Fabric version".into()); + } + let bytes = get( + &client, + &format!("https://meta.fabricmc.net/v2/versions/loader/{minecraft}/{loader}/profile/json"), + MAX_PROFILE, + )?; + normalize(&client, &bytes, minecraft, loader) +} + +fn normalize( + client: &Client, + bytes: &[u8], + minecraft: &str, + loader: &str, +) -> Result { + // Flattening libraries would consume the same key twice; parse the two views explicitly. + let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?; + let parent = value.get("inheritsFrom").and_then(|v| v.as_str()); + let mut version: VersionJson = + serde_json::from_value(value.clone()).map_err(|e| e.to_string())?; + if parent != Some(minecraft) + || version.id != format!("fabric-loader-{loader}-{minecraft}") + || version.main_class != "net.fabricmc.loader.impl.launch.knot.KnotClient" + { + return Err("Fabric profile identity mismatch".into()); + } + let artifacts: Vec = + serde_json::from_value(value["libraries"].clone()).map_err(|e| e.to_string())?; + if artifacts.is_empty() || artifacts.len() > 32 { + return Err("Invalid Fabric library count".into()); + } + for (library, artifact) in version.libraries.iter_mut().zip(artifacts) { + if artifact.url != "https://maven.fabricmc.net/" { + return Err("Untrusted Fabric Maven URL".into()); + } + let path = coordinate_path(&artifact.name)?; + let url = format!("https://{MAVEN_HOST}/{path}"); + let sha1 = match artifact.sha1 { + Some(hash) => hash, + None => String::from_utf8(get(client, &format!("{url}.sha1"), 128)?) + .map_err(|e| e.to_string())? + .trim() + .to_owned(), + }; + let size = match artifact.size { + Some(size) => size, + None => client + .head(&url) + .send() + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())? + .headers() + .get(reqwest::header::CONTENT_LENGTH) + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .ok_or("Fabric library has no size")?, + }; + if sha1.len() != 40 + || !sha1.bytes().all(|b| b.is_ascii_hexdigit()) + || size == 0 + || size > 64 * 1024 * 1024 + { + return Err("Invalid Fabric library hash or size".into()); + } + library.downloads = Some(LibraryDownloads { + artifact: Some(Artifact { + path, + url, + sha1, + size, + }), + }); + } + Ok(version) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn maven_coordinates_cannot_escape_library_directory() { + assert_eq!( + coordinate_path("net.fabricmc:fabric-loader:0.19.5").unwrap(), + "net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar" + ); + for bad in [ + "x:y:../evil", + "a..b:c:1", + "x:/tmp:1", + "x:y:1:extra", + "x:y:\\evil", + "x:y:..", + "CON:y:1", + "a:y.:1", + "a:AUX:1", + ] { + assert!(coordinate_path(bad).is_err(), "{bad}"); + } + } + #[test] + fn loader_identity_and_maven_origin_are_checked_before_downloads() { + let client = Client::new(); + let base = serde_json::json!({"id":"fabric-loader-0.19.5-26.2", "inheritsFrom":"26.2", "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient", "libraries":[{"name":"net.fabricmc:fabric-loader:0.19.5", "url":"https://maven.fabricmc.net/", "sha1":"a".repeat(40), "size":42}]}); + assert!(normalize( + &client, + &serde_json::to_vec(&base).unwrap(), + "26.2", + "0.19.5" + ) + .is_ok()); + for (field, value) in [ + ("inheritsFrom", "1.21.1"), + ("mainClass", "attacker.Main"), + ("id", "wrong"), + ] { + let mut bad = base.clone(); + bad[field] = value.into(); + assert!(normalize( + &client, + &serde_json::to_vec(&bad).unwrap(), + "26.2", + "0.19.5" + ) + .is_err()); + } + let mut bad = base; + bad["libraries"][0]["url"] = "https://attacker.invalid/".into(); + assert!(normalize( + &client, + &serde_json::to_vec(&bad).unwrap(), + "26.2", + "0.19.5" + ) + .is_err()); + } + #[test] + #[ignore = "downloads official Fabric profile metadata"] + fn live_resolves_fabric_26_2() { + let profile = fetch_profile("26.2", "0.19.5").unwrap(); + assert!(profile + .libraries + .iter() + .all(|library| library.downloads.is_some())); + } +} diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index 5338163..42040b9 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -245,6 +245,10 @@ fn build_command(request: &LaunchRequest) -> Result { for argument in game_args { command.arg(substitute(&argument, &vars)); } + // This endpoint is native-owned; a manifest cannot redirect game admission. + if request.admission.server_id() == "minigames" { + command.args(["--quickPlayMultiplayer", "135.106.154.86:25568"]); + } command.current_dir(request.profile_dir); let log_file = fs::File::create(request.log_path)?; @@ -268,59 +272,62 @@ mod tests { let game_dir = directory.join("game"); let profile_dir = directory.join("profile"); let log_path = directory.join("game.log"); - let vanilla: mojang::VersionJson = serde_json::from_value(serde_json::json!({ - "id": "1.21.1", - "mainClass": "net.minecraft.client.main.Main", - "arguments": { - "game": ["--username", "${auth_player_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}"], - "jvm": ["-cp", "${classpath}", "-Dlauncher=${launcher_name}"] - }, - "assetIndex": {"id": "17", "sha1": "0".repeat(40), "size": 1, "url": "https://piston-meta.mojang.com/assets"}, - "downloads": {"client": {"sha1": "0".repeat(40), "size": 1, "url": "https://piston-data.mojang.com/client.jar"}} - })).unwrap(); - let merged = mojang::merge_versions(&vanilla, None).unwrap(); - let response = serde_json::from_value(serde_json::json!({ - "ticket_id": URL_SAFE_NO_PAD.encode([73_u8; 32]), "mc_username": "Ticket_Name", - "server_id": "aoc", "expires_in_seconds": 600 - })) - .unwrap(); - let admission = AdmissionKey::generate().unwrap().bind(response).unwrap(); - let request = LaunchRequest { - java_executable: Path::new("java"), - game_dir: &game_dir, - profile_dir: &profile_dir, - merged: &merged, - admission: &admission, - memory_mb: 6144, - log_path: &log_path, - }; - let command = build_command(&request).unwrap(); - let env: HashMap<_, _> = command.get_envs().collect(); - let proof = [TICKET_ENV, PRIVATE_KEY_ENV] - .map(|name| env[std::ffi::OsStr::new(name)].unwrap().to_str().unwrap()); - let arguments: Vec<_> = command - .get_args() - .map(|arg| arg.to_string_lossy()) - .collect(); - assert!(arguments - .windows(2) - .any(|args| args == ["--username", "Ticket_Name"])); - assert!(arguments - .windows(2) - .any(|args| args == ["--accessToken", "0"])); - for secret in proof { - assert!(arguments.iter().all(|argument| !argument.contains(secret))); - for path in [ - log_path.clone(), - game_dir.join(".shacraft-client-id"), - profile_dir.join(".shacraft-jvm.args"), - ] { - if path.exists() { - assert!(!fs::read_to_string(path).unwrap().contains(secret)); + for server_id in ["aoc", "minigames"] { + let vanilla: mojang::VersionJson = serde_json::from_value(serde_json::json!({ + "id": "1.21.1", + "mainClass": "net.minecraft.client.main.Main", + "arguments": { + "game": ["--username", "${auth_player_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}"], + "jvm": ["-cp", "${classpath}", "-Dlauncher=${launcher_name}"] + }, + "assetIndex": {"id": "17", "sha1": "0".repeat(40), "size": 1, "url": "https://piston-meta.mojang.com/assets"}, + "downloads": {"client": {"sha1": "0".repeat(40), "size": 1, "url": "https://piston-data.mojang.com/client.jar"}} + })).unwrap(); + let merged = mojang::merge_versions(&vanilla, None).unwrap(); + let response = serde_json::from_value(serde_json::json!({ + "ticket_id": URL_SAFE_NO_PAD.encode([73_u8; 32]), "mc_username": "Ticket_Name", + "server_id": server_id, "expires_in_seconds": 600 + })) + .unwrap(); + let admission = AdmissionKey::generate(server_id).unwrap().bind(response).unwrap(); + let request = LaunchRequest { + java_executable: Path::new("java"), + game_dir: &game_dir, + profile_dir: &profile_dir, + merged: &merged, + admission: &admission, + memory_mb: 6144, + log_path: &log_path, + }; + let command = build_command(&request).unwrap(); + let env: HashMap<_, _> = command.get_envs().collect(); + let proof = [TICKET_ENV, PRIVATE_KEY_ENV] + .map(|name| env[std::ffi::OsStr::new(name)].unwrap().to_str().unwrap()); + let arguments: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect(); + assert!(arguments + .windows(2) + .any(|args| args == ["--username", "Ticket_Name"])); + assert!(arguments + .windows(2) + .any(|args| args == ["--accessToken", "0"])); + assert_eq!(arguments.windows(2).any(|pair| pair == ["--quickPlayMultiplayer", "135.106.154.86:25568"]), server_id == "minigames"); + for secret in proof { + assert!(arguments.iter().all(|argument| !argument.contains(secret))); + for path in [ + log_path.clone(), + game_dir.join(".shacraft-client-id"), + profile_dir.join(".shacraft-jvm.args"), + ] { + if path.exists() { + assert!(!fs::read_to_string(path).unwrap().contains(secret)); + } } } + drop(command); } - drop(command); fs::remove_dir_all(directory).unwrap(); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d555b39..fba1939 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod admission; #[cfg(target_os = "linux")] mod deb_updater; mod download; +mod fabric; mod java; mod launch; mod manifest; diff --git a/src-tauri/src/manifest.rs b/src-tauri/src/manifest.rs index fb296e4..3c19eca 100644 --- a/src-tauri/src/manifest.rs +++ b/src-tauri/src/manifest.rs @@ -91,7 +91,7 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> { )); } if !is_version(&manifest.minecraft.version) - || manifest.minecraft.loader.kind.trim().is_empty() + || !matches!(manifest.minecraft.loader.kind.as_str(), "neoforge" | "fabric" | "vanilla") || !is_version(&manifest.minecraft.loader.version) { return Err(ManifestError::Invalid( diff --git a/src-tauri/src/mojang.rs b/src-tauri/src/mojang.rs index e812858..7ff82a1 100644 --- a/src-tauri/src/mojang.rs +++ b/src-tauri/src/mojang.rs @@ -58,6 +58,7 @@ pub fn library_http_client() -> Result { MOJANG_HOSTS[2], MOJANG_HOSTS[3], crate::neoforge::NEOFORGE_HOST, + crate::fabric::MAVEN_HOST, ], std::time::Duration::from_secs(10 * 60), ) @@ -67,7 +68,7 @@ pub fn library_http_client() -> Result { /// own fixed Maven. The profile itself comes from the SHA-256-verified /// NeoForge installer, never from the ShaCraft manifest. fn is_allowed_library_host(url: &str) -> bool { - is_allowed_host(url) || crate::neoforge::is_allowed_host(url) + is_allowed_host(url) || crate::neoforge::is_allowed_host(url) || crate::trusted_http::allows(url, &[crate::fabric::MAVEN_HOST]) } #[derive(Debug)] diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index 0cd269f..6fd879e 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -13,6 +13,8 @@ use std::{ const AERONAUTICS_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest"; +const MINIGAMES_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest"; +const MINIGAMES_ONLINE: &str = "https://shacraft.ru/api/online/minigames"; const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc"; const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ="; const MANIFEST_KEY_ID: &str = "2026-09-06"; @@ -71,6 +73,7 @@ impl fmt::Display for RemoteError { pub fn fetch_manifest(profile_id: &str) -> Result { let url = match profile_id { "aeronautics" => AERONAUTICS_MANIFEST, + "minigames" => MINIGAMES_MANIFEST, _ => return Err(RemoteError::UnknownProfile), }; let client = Client::builder() @@ -125,6 +128,7 @@ fn fetch_manifest_bytes(client: &Client, url: &str) -> Result, RemoteErr pub fn fetch_server_status(profile_id: &str) -> Result { let url = match profile_id { "aeronautics" => AERONAUTICS_ONLINE, + "minigames" => MINIGAMES_ONLINE, _ => return Err(RemoteError::UnknownProfile), }; let client = Client::builder() diff --git a/src-tauri/src/shacraft_account.rs b/src-tauri/src/shacraft_account.rs index 467e636..36802f7 100644 --- a/src-tauri/src/shacraft_account.rs +++ b/src-tauri/src/shacraft_account.rs @@ -274,9 +274,10 @@ pub fn claim_nickname(data_dir: &Path, nickname: &str) -> Result Result { let token = load_session(data_dir)?; - let key = crate::admission::AdmissionKey::generate() + let key = crate::admission::AdmissionKey::generate(server_id) .map_err(|message| AccountError::Api(message.into()))?; let response = client()? .post(format!("{API_ORIGIN}{ADMISSION_ENDPOINT}")) @@ -359,7 +360,7 @@ mod tests { let directory = temporary_directory(); crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap(); assert!(matches!( - issue_admission(&directory), + issue_admission(&directory, "aoc"), Err(AccountError::InvalidSession) )); fs::remove_dir_all(directory).unwrap(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ad548be..daae003 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShaCraft Launcher", - "version": "0.1.5", + "version": "0.1.6", "identifier": "ru.shacraft.launcher", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx index e60698f..b051f88 100644 --- a/src/components/AccountSettings.tsx +++ b/src/components/AccountSettings.tsx @@ -43,7 +43,7 @@ export function AccountSettings({ session, locked }: { session: ReturnType { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>