Add Minigames profile and Fabric admission in launcher 0.1.6
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -10,3 +10,7 @@ src-tauri/gen/
|
||||
*.p12
|
||||
*.pfx
|
||||
*.sig
|
||||
|
||||
admission-client/.gradle/
|
||||
admission-client/build/
|
||||
graphify-out/
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
Cross-platform desktop launcher for the ShaCraft Minecraft network. It is a
|
||||
Tauri 2 application: React/Vite is the UI and Rust owns all filesystem,
|
||||
network and process-adjacent work. The current production profile is
|
||||
**Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21).
|
||||
network and process-adjacent work. Profiles are **Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21)
|
||||
and **Minigames** (Minecraft 26.2, Fabric 0.19.5, Java 25). See the 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.
|
||||
|
||||
@@ -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 опубликованы. Проверка реальной установки на каждой
|
||||
платформе остаётся отдельной от сборки и автоматических тестов.
|
||||
|
||||
## Навигация
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ShaCraft Minigames admission client
|
||||
|
||||
Client-only Fabric companion for Minecraft 26.2, Java 25, Fabric Loader 0.19.5
|
||||
and Fabric API 0.160.0+26.2. This is account admission, not an anti-cheat or a
|
||||
proof that the original launcher binary is running.
|
||||
|
||||
Build with Java 25: `./gradlew test build --no-daemon`.
|
||||
Output: `build/libs/shacraft-admission-client-0.1.0.jar`.
|
||||
Publish that jar and the pinned Fabric API jar in the signed Minigames profile.
|
||||
|
||||
The native launcher supplies `SHACRAFT_ADMISSION_TICKET` and
|
||||
`SHACRAFT_ADMISSION_PRIVATE_KEY` only to the final Java child environment.
|
||||
The client accepts a CONFIGURATION payload on `shacraft_admission:challenge`
|
||||
with three Minecraft UTF strings: server ID (16), nickname (16), nonce (43).
|
||||
It verifies server `minigames`, the exact current game nickname and actual
|
||||
socket `135.106.154.86:25568`, then signs once with the ephemeral Ed25519 key.
|
||||
The response on `shacraft_admission:proof` contains ticket (43) and standard
|
||||
Base64 signature (88). The transcript has no final newline:
|
||||
|
||||
```
|
||||
shacraft-admission-v1
|
||||
{ticket_id}
|
||||
minigames
|
||||
{mc_username}
|
||||
{nonce}
|
||||
```
|
||||
|
||||
The private key and website session never go onto the Minecraft wire. Errors
|
||||
are redacted. A fresh game launch is needed for another connection after a
|
||||
proof has been sent. `SHACRAFT_ADMISSION_ALLOW_LOOPBACK=1` additionally permits
|
||||
literal loopback sockets for isolated tests; normal releases do not set it.
|
||||
Paper must fail closed before world entry and reject unauthenticated duplicate
|
||||
UUIDs before the vanilla duplicate-player eviction. The backend checks the
|
||||
current shared aoc account access and atomically redeems the server-bound ticket.
|
||||
|
||||
Three unit tests cover exact signature binding, invalid/cross-server fields
|
||||
and socket allowlisting. The unchanged production companion also passed actual Minecraft 26.2
|
||||
configuration negotiation and entered a local Paper lobby with a synthetic
|
||||
backend ticket; see [receipt](../docs/verification/minigames-fabric-2026-09-13.json).
|
||||
The public server and other platforms still require their own rollout checks.
|
||||
|
||||
The client explicitly advertises its single challenge receiver with vanilla
|
||||
`minecraft:register` at the start of configuration. Fabric normally waits for
|
||||
the server's registration first, while Paper gates plugin sends on that client
|
||||
advertisement. This bootstrap uses the pinned Fabric API's RegistrationPayload;
|
||||
update it and rerun live negotiation checks when upgrading Fabric API. It is
|
||||
queued after INIT so vanilla has switched outbound protocol to CONFIGURATION.
|
||||
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
id 'net.fabricmc.fabric-loom' version "${loom_version}"
|
||||
}
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
loom {
|
||||
splitEnvironmentSourceSets()
|
||||
mods {
|
||||
'shacraft_admission' {
|
||||
sourceSet sourceSets.main
|
||||
sourceSet sourceSets.client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
minecraft "com.mojang:minecraft:${project.minecraft_version}"
|
||||
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
|
||||
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
|
||||
testImplementation platform('org.junit:junit-bom:5.12.2')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
processResources {
|
||||
inputs.property 'version', project.version
|
||||
filesMatching('fabric.mod.json') { expand version: project.version }
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach { options.release = 25 }
|
||||
java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() }
|
||||
test { useJUnitPlatform() }
|
||||
|
||||
// Pure HTTP/validation tests use the same client implementation without launching Minecraft.
|
||||
sourceSets.test.compileClasspath += sourceSets.client.output
|
||||
sourceSets.test.runtimeClasspath += sourceSets.client.output
|
||||
@@ -0,0 +1,9 @@
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configuration-cache=false
|
||||
minecraft_version=26.2
|
||||
loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.2
|
||||
version=0.1.0
|
||||
group=ru.shacraft
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
|
||||
networkTimeout=30000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1,8 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url = 'https://maven.fabricmc.net/' }
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
rootProject.name = 'shacraft-admission-client'
|
||||
@@ -0,0 +1,57 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationConnectionEvents;
|
||||
import net.fabricmc.fabric.impl.networking.RegistrationPayload;
|
||||
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
|
||||
import java.util.List;
|
||||
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** The account session remains in the native launcher, never in Minecraft. */
|
||||
public final class ClientAdmission implements ClientModInitializer {
|
||||
private static final AtomicBoolean USED = new AtomicBoolean();
|
||||
|
||||
@Override public void onInitializeClient() {
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(AdmissionPayloads.Challenge.TYPE, AdmissionPayloads.Challenge.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(AdmissionPayloads.Proof.TYPE, AdmissionPayloads.Proof.CODEC);
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AdmissionPayloads.Challenge.TYPE, ClientAdmission::challenge);
|
||||
// Paper waits for vanilla channel advertisement, while Fabric normally waits
|
||||
// for the server's registration first. Bootstrap our one fixed receiver.
|
||||
// INIT runs in the listener constructor; schedule() queues until after vanilla
|
||||
// has switched the outbound protocol from LOGIN to CONFIGURATION.
|
||||
ClientConfigurationConnectionEvents.INIT.register((listener, client) -> client.schedule(() ->
|
||||
listener.send(new ServerboundCustomPayloadPacket(new RegistrationPayload(
|
||||
RegistrationPayload.REGISTER, List.of(AdmissionPayloads.Challenge.TYPE.id()))))));
|
||||
}
|
||||
|
||||
private static void challenge(AdmissionPayloads.Challenge challenge, ClientConfigurationNetworking.Context context) {
|
||||
var connection = context.packetContext().orElseThrow(net.fabricmc.fabric.api.networking.v1.context.PacketContext.CONNECTION);
|
||||
boolean loopback = "1".equals(System.getenv("SHACRAFT_ADMISSION_ALLOW_LOOPBACK"));
|
||||
if (!(connection.getRemoteAddress() instanceof InetSocketAddress remote)
|
||||
|| remote.getAddress() == null
|
||||
|| !AdmissionProof.allowedTarget(remote.getAddress().getHostAddress(), remote.getPort(), loopback)
|
||||
|| !AdmissionProof.SERVER_ID.equals(challenge.serverId())
|
||||
|| !context.client().getUser().getName().equals(challenge.nickname())
|
||||
|| !AdmissionProof.validOpaque(challenge.nonce())) {
|
||||
deny(context); return;
|
||||
}
|
||||
String ticket = System.getenv("SHACRAFT_ADMISSION_TICKET");
|
||||
String privateKey = System.getenv("SHACRAFT_ADMISSION_PRIVATE_KEY");
|
||||
if (!AdmissionProof.validOpaque(ticket) || !USED.compareAndSet(false, true)) {
|
||||
deny(context); return;
|
||||
}
|
||||
try {
|
||||
String signature = AdmissionProof.sign(privateKey, ticket, challenge.serverId(), challenge.nickname(), challenge.nonce());
|
||||
context.responseSender().sendPacket(new AdmissionPayloads.Proof(ticket, signature));
|
||||
} catch (Exception invalidKey) { deny(context); }
|
||||
}
|
||||
|
||||
private static void deny(ClientConfigurationNetworking.Context context) {
|
||||
context.responseSender().disconnect(Component.literal(
|
||||
"Не удалось подтвердить вход ShaCraft. Закройте игру и запустите её заново через ShaCraft Launcher."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
public final class AdmissionPayloads {
|
||||
private AdmissionPayloads() {}
|
||||
|
||||
public record Challenge(String serverId, String nickname, String nonce) implements CustomPacketPayload {
|
||||
public static final Type<Challenge> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "challenge"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Challenge> CODEC = StreamCodec.of(
|
||||
(buffer, value) -> { buffer.writeUtf(value.serverId, 16); buffer.writeUtf(value.nickname, 16); buffer.writeUtf(value.nonce, 43); },
|
||||
buffer -> new Challenge(buffer.readUtf(16), buffer.readUtf(16), buffer.readUtf(43)));
|
||||
@Override public Type<Challenge> type() { return TYPE; }
|
||||
@Override public String toString() { return "AdmissionChallenge[redacted]"; }
|
||||
}
|
||||
|
||||
public record Proof(String ticket, String signature) implements CustomPacketPayload {
|
||||
public static final Type<Proof> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "proof"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Proof> CODEC = StreamCodec.of(
|
||||
(buffer, value) -> { buffer.writeUtf(value.ticket, 43); buffer.writeUtf(value.signature, 88); },
|
||||
buffer -> new Proof(buffer.readUtf(43), buffer.readUtf(88)));
|
||||
@Override public Type<Proof> type() { return TYPE; }
|
||||
@Override public String toString() { return "AdmissionProof[redacted]"; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** The only client credential is an ephemeral private key supplied by the launcher. */
|
||||
public final class AdmissionProof {
|
||||
public static final String SERVER_ID = "minigames";
|
||||
private static final Pattern OPAQUE = Pattern.compile("[A-Za-z0-9_-]{43}");
|
||||
private static final Pattern NICKNAME = Pattern.compile("[A-Za-z0-9_]{3,16}");
|
||||
|
||||
private AdmissionProof() {}
|
||||
|
||||
public static boolean validOpaque(String value) {
|
||||
return value != null && OPAQUE.matcher(value).matches();
|
||||
}
|
||||
|
||||
public static byte[] transcript(String ticket, String serverId, String nickname, String nonce) {
|
||||
if (!validOpaque(ticket) || !validOpaque(nonce) || !SERVER_ID.equals(serverId)
|
||||
|| nickname == null || !NICKNAME.matcher(nickname).matches()) {
|
||||
throw new IllegalArgumentException("Invalid admission challenge");
|
||||
}
|
||||
return ("shacraft-admission-v1\n" + ticket + "\n" + serverId + "\n"
|
||||
+ nickname + "\n" + nonce).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static String sign(String encodedPrivateKey, String ticket, String serverId,
|
||||
String nickname, String nonce) throws Exception {
|
||||
if (encodedPrivateKey == null || encodedPrivateKey.length() > 256) {
|
||||
throw new IllegalArgumentException("Missing admission key");
|
||||
}
|
||||
byte[] encoded = Base64.getDecoder().decode(encodedPrivateKey);
|
||||
try {
|
||||
PrivateKey key = KeyFactory.getInstance("Ed25519")
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(encoded));
|
||||
Signature signer = Signature.getInstance("Ed25519");
|
||||
signer.initSign(key);
|
||||
signer.update(transcript(ticket, serverId, nickname, nonce));
|
||||
return Base64.getEncoder().encodeToString(signer.sign());
|
||||
} finally {
|
||||
java.util.Arrays.fill(encoded, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean validSignature(String value) {
|
||||
if (value == null || value.length() != 88) return false;
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(value);
|
||||
return decoded.length == 64 && Base64.getEncoder().encodeToString(decoded).equals(value);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean allowedTarget(String host, int port, boolean allowLoopback) {
|
||||
if (host == null) return false;
|
||||
if (allowLoopback && (host.equals("127.0.0.1") || host.equals("::1") || host.equals("[::1]") || host.equals("0:0:0:0:0:0:0:1"))) {
|
||||
return port > 0 && port <= 65535;
|
||||
}
|
||||
return port == 25568 && (host.equalsIgnoreCase("shacraft.ru") || host.equals("135.106.154.86"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "shacraft_admission",
|
||||
"version": "${version}",
|
||||
"name": "ShaCraft Minigames Admission",
|
||||
"description": "Account-bound admission to ShaCraft Minigames.",
|
||||
"environment": "client",
|
||||
"entrypoints": { "client": ["ru.shacraft.admission.ClientAdmission"] },
|
||||
"depends": { "fabricloader": ">=0.19.5", "minecraft": "26.2", "java": ">=25", "fabric-networking-api-v1": "*" }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.Signature;
|
||||
import java.util.Base64;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdmissionProofTest {
|
||||
private static final String TICKET = "A".repeat(43), NONCE = "B".repeat(43);
|
||||
@Test void signsExactServerBoundTranscript() throws Exception {
|
||||
var pair=KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||
String signed=AdmissionProof.sign(Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded()), TICKET,"minigames","Pilot_1",NONCE);
|
||||
var verifier=Signature.getInstance("Ed25519"); verifier.initVerify(pair.getPublic());
|
||||
verifier.update(("shacraft-admission-v1\n"+TICKET+"\nminigames\nPilot_1\n"+NONCE).getBytes(StandardCharsets.UTF_8));
|
||||
assertTrue(verifier.verify(Base64.getDecoder().decode(signed)));
|
||||
verifier.update(AdmissionProof.transcript(TICKET,"minigames","Other",NONCE));
|
||||
assertFalse(verifier.verify(Base64.getDecoder().decode(signed)));
|
||||
}
|
||||
@Test void rejectsCrossServerAndMalformedFields() {
|
||||
for (String[] v:new String[][]{{TICKET,"aoc","Pilot",NONCE},{TICKET,"minigames","Bad\nName",NONCE},{"bad","minigames","Pilot",NONCE},{TICKET,"minigames","Pilot","bad"}})
|
||||
assertThrows(IllegalArgumentException.class,()->AdmissionProof.transcript(v[0],v[1],v[2],v[3]));
|
||||
}
|
||||
@Test void trustsOnlyMinigamesSocketAndExplicitLocalTests() {
|
||||
assertTrue(AdmissionProof.allowedTarget("135.106.154.86",25568,false));
|
||||
assertFalse(AdmissionProof.allowedTarget("135.106.154.86",25567,false));
|
||||
assertFalse(AdmissionProof.allowedTarget("127.0.0.1",25568,false));
|
||||
assertTrue(AdmissionProof.allowedTarget("127.0.0.1",25570,true));
|
||||
assertFalse(AdmissionProof.allowedTarget("attacker.invalid",25568,true));
|
||||
}
|
||||
}
|
||||
@@ -90,3 +90,21 @@ URL — cannot redirect a download to an attacker-controlled host in any of
|
||||
these domains. When adding a new game-related download, verify its host is
|
||||
one of the ones above (or add a new hardcoded constant following the same
|
||||
pattern) rather than accepting a URL from anywhere else.
|
||||
|
||||
## 5. Fabric (`fabric.rs`, Minigames)
|
||||
|
||||
Metadata comes only from `https://meta.fabricmc.net/v2/versions/loader/` for
|
||||
manifest-selected, validated version identifiers. Profile ID, parent and
|
||||
KnotClient main class must match. At most 512 KiB metadata and 32 libraries
|
||||
are accepted. Library URLs are constructed only below
|
||||
`https://maven.fabricmc.net/` from portable three-part Maven coordinates;
|
||||
other metadata origins are rejected. Libraries require a 40-hex SHA-1 and a
|
||||
positive size up to 64 MiB. When Fabric metadata omits either for its loader
|
||||
jar, the fixed Maven's `.sha1` sidecar and HEAD provide them. Existing verified
|
||||
downloads perform the hash/size check and atomic rename. The ShaCraft signed
|
||||
manifest cannot select Fabric metadata URLs, repositories or launch targets.
|
||||
|
||||
Minecraft 26.2's official version metadata requires Java 25. `runtime.rs`
|
||||
already provisions a separate Adoptium Java 25 runtime without changing the
|
||||
Aeronautics Java 21 runtime. Client companion mods and Fabric API are separately
|
||||
approved ShaCraft managed files in the signed Minigames profile.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"recorded_at": "2026-09-13T19:32:59.436884+00:00",
|
||||
"result": "joined_world",
|
||||
"client": "Minecraft 26.2 / Fabric Loader 0.19.5 / Fabric API 0.160.0+26.2",
|
||||
"java": "25.0.2",
|
||||
"client_jar_sha256": "3335626b7c8fdd233e8531ad382594398c7df48842b1b919934d1154dd4ba8f1",
|
||||
"client_jar_size": 10667,
|
||||
"profile": "minigames",
|
||||
"synthetic_player": "AdmissionPilot",
|
||||
"endpoint": "127.0.0.1:25608",
|
||||
"environment": "isolated Xvfb :95; separate temporary game directory; synthetic account/ticket; explicit local socket test flag",
|
||||
"server_evidence": [
|
||||
"[22:32:02 INFO]: AdmissionPilot joined the game",
|
||||
"[22:32:02 INFO]: AdmissionPilot[/127.0.0.1:54602] logged in with entity id 77 at ([minecraft:shacraft_lobby_v2]0.5, 96.0, 43.5)"
|
||||
],
|
||||
"verified": [
|
||||
"production companion jar bytes unchanged",
|
||||
"queued minecraft:register during configuration",
|
||||
"server-bound Ed25519 response accepted",
|
||||
"actual vanilla client entered lobby world"
|
||||
],
|
||||
"not_tested": [
|
||||
"production public endpoint login",
|
||||
"Windows/macOS cold installation",
|
||||
"full GUI launcher install/account flow"
|
||||
],
|
||||
"notes": "Offline client emits Microsoft profile-certificate HTTP401; admission and world entry succeeded. Real account/session/password not used.",
|
||||
"backend_evidence": {
|
||||
"source": "backend agent read-only synthetic SQLite query",
|
||||
"issued_minigames_tickets_for_player": 1,
|
||||
"consumed_minigames_tickets_for_player": 1
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -3361,7 +3361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
description = "ShaCraft Minecraft launcher"
|
||||
authors = ["ShaCraft"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
@@ -43,13 +53,15 @@ pub(crate) struct TicketResponse {
|
||||
}
|
||||
|
||||
pub(crate) struct Admission {
|
||||
server_id: &'static str,
|
||||
ticket_id: Zeroizing<String>,
|
||||
private_key: Zeroizing<String>,
|
||||
identity: PlayerIdentity,
|
||||
}
|
||||
|
||||
impl AdmissionKey {
|
||||
pub(crate) fn generate() -> Result<Self, &'static str> {
|
||||
pub(crate) fn generate(server_id: &'static str) -> Result<Self, &'static str> {
|
||||
if !matches!(server_id, "aoc" | "minigames") { return Err("Unknown ShaCraft server"); }
|
||||
let mut seed = Zeroizing::new([0_u8; 32]);
|
||||
getrandom::fill(seed.as_mut())
|
||||
.map_err(|_| "Не удалось создать защищённый ключ входа. Повторите запуск лаунчера.")?;
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Fabric metadata and Maven are fixed, independent game trust domains.
|
||||
//! The signed ShaCraft manifest selects versions, never arbitrary loader URLs.
|
||||
use crate::mojang::{Artifact, LibraryDownloads, VersionJson};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use std::{io::Read, time::Duration};
|
||||
|
||||
pub(crate) const MAVEN_HOST: &str = "maven.fabricmc.net";
|
||||
const HOSTS: [&str; 2] = ["meta.fabricmc.net", MAVEN_HOST];
|
||||
const MAX_PROFILE: usize = 512 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FabricLibrary {
|
||||
name: String,
|
||||
url: String,
|
||||
sha1: Option<String>,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
fn coordinate_path(name: &str) -> Result<String, String> {
|
||||
let pieces: Vec<_> = name.split(':').collect();
|
||||
if pieces.len() != 3
|
||||
|| pieces.iter().any(|p| {
|
||||
!crate::manifest::is_portable_component(p)
|
||||
|| *p == "."
|
||||
|| *p == ".."
|
||||
|| !p
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
|
||||
})
|
||||
|| pieces[0].split('.').any(|p| !crate::manifest::is_portable_component(p))
|
||||
{
|
||||
return Err("Invalid Fabric library coordinate".into());
|
||||
}
|
||||
Ok(format!(
|
||||
"{}/{}/{}/{}-{}.jar",
|
||||
pieces[0].replace('.', "/"),
|
||||
pieces[1],
|
||||
pieces[2],
|
||||
pieces[1],
|
||||
pieces[2]
|
||||
))
|
||||
}
|
||||
|
||||
fn get(client: &Client, url: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.take((limit + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if bytes.len() > limit {
|
||||
return Err("Fabric metadata exceeds its size limit".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_profile(minecraft: &str, loader: &str) -> Result<VersionJson, String> {
|
||||
let client =
|
||||
crate::trusted_http::client(&HOSTS, Duration::from_secs(30)).map_err(|e| e.to_string())?;
|
||||
// Defence in depth: these values normally already passed manifest validation.
|
||||
if [minecraft, loader].iter().any(|v| {
|
||||
!crate::manifest::is_portable_component(v)
|
||||
|| v.contains('/')
|
||||
|| !v
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
|
||||
}) {
|
||||
return Err("Invalid Fabric version".into());
|
||||
}
|
||||
let bytes = get(
|
||||
&client,
|
||||
&format!("https://meta.fabricmc.net/v2/versions/loader/{minecraft}/{loader}/profile/json"),
|
||||
MAX_PROFILE,
|
||||
)?;
|
||||
normalize(&client, &bytes, minecraft, loader)
|
||||
}
|
||||
|
||||
fn normalize(
|
||||
client: &Client,
|
||||
bytes: &[u8],
|
||||
minecraft: &str,
|
||||
loader: &str,
|
||||
) -> Result<VersionJson, String> {
|
||||
// Flattening libraries would consume the same key twice; parse the two views explicitly.
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
|
||||
let parent = value.get("inheritsFrom").and_then(|v| v.as_str());
|
||||
let mut version: VersionJson =
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||
if parent != Some(minecraft)
|
||||
|| version.id != format!("fabric-loader-{loader}-{minecraft}")
|
||||
|| version.main_class != "net.fabricmc.loader.impl.launch.knot.KnotClient"
|
||||
{
|
||||
return Err("Fabric profile identity mismatch".into());
|
||||
}
|
||||
let artifacts: Vec<FabricLibrary> =
|
||||
serde_json::from_value(value["libraries"].clone()).map_err(|e| e.to_string())?;
|
||||
if artifacts.is_empty() || artifacts.len() > 32 {
|
||||
return Err("Invalid Fabric library count".into());
|
||||
}
|
||||
for (library, artifact) in version.libraries.iter_mut().zip(artifacts) {
|
||||
if artifact.url != "https://maven.fabricmc.net/" {
|
||||
return Err("Untrusted Fabric Maven URL".into());
|
||||
}
|
||||
let path = coordinate_path(&artifact.name)?;
|
||||
let url = format!("https://{MAVEN_HOST}/{path}");
|
||||
let sha1 = match artifact.sha1 {
|
||||
Some(hash) => hash,
|
||||
None => String::from_utf8(get(client, &format!("{url}.sha1"), 128)?)
|
||||
.map_err(|e| e.to_string())?
|
||||
.trim()
|
||||
.to_owned(),
|
||||
};
|
||||
let size = match artifact.size {
|
||||
Some(size) => size,
|
||||
None => client
|
||||
.head(&url)
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.ok_or("Fabric library has no size")?,
|
||||
};
|
||||
if sha1.len() != 40
|
||||
|| !sha1.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
|| size == 0
|
||||
|| size > 64 * 1024 * 1024
|
||||
{
|
||||
return Err("Invalid Fabric library hash or size".into());
|
||||
}
|
||||
library.downloads = Some(LibraryDownloads {
|
||||
artifact: Some(Artifact {
|
||||
path,
|
||||
url,
|
||||
sha1,
|
||||
size,
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maven_coordinates_cannot_escape_library_directory() {
|
||||
assert_eq!(
|
||||
coordinate_path("net.fabricmc:fabric-loader:0.19.5").unwrap(),
|
||||
"net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar"
|
||||
);
|
||||
for bad in [
|
||||
"x:y:../evil",
|
||||
"a..b:c:1",
|
||||
"x:/tmp:1",
|
||||
"x:y:1:extra",
|
||||
"x:y:\\evil",
|
||||
"x:y:..",
|
||||
"CON:y:1",
|
||||
"a:y.:1",
|
||||
"a:AUX:1",
|
||||
] {
|
||||
assert!(coordinate_path(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn loader_identity_and_maven_origin_are_checked_before_downloads() {
|
||||
let client = Client::new();
|
||||
let base = serde_json::json!({"id":"fabric-loader-0.19.5-26.2", "inheritsFrom":"26.2", "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient", "libraries":[{"name":"net.fabricmc:fabric-loader:0.19.5", "url":"https://maven.fabricmc.net/", "sha1":"a".repeat(40), "size":42}]});
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&base).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_ok());
|
||||
for (field, value) in [
|
||||
("inheritsFrom", "1.21.1"),
|
||||
("mainClass", "attacker.Main"),
|
||||
("id", "wrong"),
|
||||
] {
|
||||
let mut bad = base.clone();
|
||||
bad[field] = value.into();
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&bad).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
let mut bad = base;
|
||||
bad["libraries"][0]["url"] = "https://attacker.invalid/".into();
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&bad).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "downloads official Fabric profile metadata"]
|
||||
fn live_resolves_fabric_26_2() {
|
||||
let profile = fetch_profile("26.2", "0.19.5").unwrap();
|
||||
assert!(profile
|
||||
.libraries
|
||||
.iter()
|
||||
.all(|library| library.downloads.is_some()));
|
||||
}
|
||||
}
|
||||
+57
-50
@@ -245,6 +245,10 @@ fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
|
||||
for argument in game_args {
|
||||
command.arg(substitute(&argument, &vars));
|
||||
}
|
||||
// This endpoint is native-owned; a manifest cannot redirect game admission.
|
||||
if request.admission.server_id() == "minigames" {
|
||||
command.args(["--quickPlayMultiplayer", "135.106.154.86:25568"]);
|
||||
}
|
||||
command.current_dir(request.profile_dir);
|
||||
|
||||
let log_file = fs::File::create(request.log_path)?;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ mod admission;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod deb_updater;
|
||||
mod download;
|
||||
mod fabric;
|
||||
mod java;
|
||||
mod launch;
|
||||
mod manifest;
|
||||
|
||||
@@ -91,7 +91,7 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
));
|
||||
}
|
||||
if !is_version(&manifest.minecraft.version)
|
||||
|| manifest.minecraft.loader.kind.trim().is_empty()
|
||||
|| !matches!(manifest.minecraft.loader.kind.as_str(), "neoforge" | "fabric" | "vanilla")
|
||||
|| !is_version(&manifest.minecraft.loader.version)
|
||||
{
|
||||
return Err(ManifestError::Invalid(
|
||||
|
||||
@@ -58,6 +58,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
|
||||
MOJANG_HOSTS[2],
|
||||
MOJANG_HOSTS[3],
|
||||
crate::neoforge::NEOFORGE_HOST,
|
||||
crate::fabric::MAVEN_HOST,
|
||||
],
|
||||
std::time::Duration::from_secs(10 * 60),
|
||||
)
|
||||
@@ -67,7 +68,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
|
||||
/// own fixed Maven. The profile itself comes from the SHA-256-verified
|
||||
/// NeoForge installer, never from the ShaCraft manifest.
|
||||
fn is_allowed_library_host(url: &str) -> bool {
|
||||
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
|
||||
is_allowed_host(url) || crate::neoforge::is_allowed_host(url) || crate::trusted_http::allows(url, &[crate::fabric::MAVEN_HOST])
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -13,6 +13,8 @@ use std::{
|
||||
|
||||
const AERONAUTICS_MANIFEST: &str =
|
||||
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
||||
const MINIGAMES_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest";
|
||||
const MINIGAMES_ONLINE: &str = "https://shacraft.ru/api/online/minigames";
|
||||
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
|
||||
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
|
||||
const MANIFEST_KEY_ID: &str = "2026-09-06";
|
||||
@@ -71,6 +73,7 @@ impl fmt::Display for RemoteError {
|
||||
pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
||||
let url = match profile_id {
|
||||
"aeronautics" => AERONAUTICS_MANIFEST,
|
||||
"minigames" => MINIGAMES_MANIFEST,
|
||||
_ => return Err(RemoteError::UnknownProfile),
|
||||
};
|
||||
let client = Client::builder()
|
||||
@@ -125,6 +128,7 @@ fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteErr
|
||||
pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError> {
|
||||
let url = match profile_id {
|
||||
"aeronautics" => AERONAUTICS_ONLINE,
|
||||
"minigames" => MINIGAMES_ONLINE,
|
||||
_ => return Err(RemoteError::UnknownProfile),
|
||||
};
|
||||
let client = Client::builder()
|
||||
|
||||
@@ -274,9 +274,10 @@ pub fn claim_nickname(data_dir: &Path, nickname: &str) -> Result<Account, Accoun
|
||||
/// this response is exposed to the webview or persisted with account settings.
|
||||
pub(crate) fn issue_admission(
|
||||
data_dir: &Path,
|
||||
server_id: &'static str,
|
||||
) -> Result<crate::admission::Admission, AccountError> {
|
||||
let token = load_session(data_dir)?;
|
||||
let key = crate::admission::AdmissionKey::generate()
|
||||
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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -43,7 +43,7 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
|
||||
{account && !linkedNickname && (
|
||||
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
|
||||
<span><strong>Игровой ник</strong><small>Общий ник ShaCraft</small></span>
|
||||
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
|
||||
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
|
||||
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
|
||||
<button className="rail-button active" aria-label="Настройки" onClick={onSettings}><Settings /></button>
|
||||
</nav>
|
||||
<aside className="library-panel">
|
||||
<div className="library-heading"><p>Сборки</p><span>{servers.length} доступна</span></div>
|
||||
<div className="library-heading"><p>Сборки</p><span>{servers.length} сборки</span></div>
|
||||
<div className="server-list">
|
||||
{servers.map((server) => {
|
||||
const profile = profiles[server.profileId]
|
||||
@@ -33,7 +33,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
|
||||
return (
|
||||
<button key={server.id} className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
|
||||
aria-pressed={selected.id === server.id} disabled={locked} onClick={() => onSelect(server)}>
|
||||
<span className={`server-glyph ${server.id}`} aria-hidden="true">A</span>
|
||||
<span className={`server-glyph ${server.id}`} aria-hidden="true">{server.name.slice(0, 1)}</span>
|
||||
<span className="server-copy"><strong>{server.name}</strong><small>{status}</small></span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function ServerStage({ server, status, children }: { server: Server; stat
|
||||
<p>{server.kicker}</p><h1>{server.name}</h1><h2>{server.subtitle}</h2>
|
||||
<dl className="hero-meta">
|
||||
<div><dt>Загрузчик</dt><dd>{server.loader}</dd></div>
|
||||
<div><dt>Java</dt><dd>Версия 21</dd></div>
|
||||
<div><dt>Java</dt><dd>Версия {server.id === 'minigames' ? 25 : 21}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
{children}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { test } from 'node:test'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AccountSettings } from './AccountSettings'
|
||||
import { RecoveryCodesModal } from './RecoveryCodesModal'
|
||||
import { ServerStage } from './ServerStage'
|
||||
import { Library } from './Library'
|
||||
import { servers } from '../data/servers'
|
||||
import type { useAccount } from '../hooks/useAccount'
|
||||
|
||||
function session(): ReturnType<typeof useAccount> {
|
||||
@@ -31,7 +34,7 @@ test('linked identity is displayed read-only while an unlinked account offers ve
|
||||
doesNotMatch(linked, /<input|Привязать ник/)
|
||||
const unlinked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: { username: 'website_login', links: [] } }} locked={false} />)
|
||||
match(unlinked, /Привязать ник/)
|
||||
match(unlinked, /Aeronautics/)
|
||||
match(unlinked, /Общий ник ShaCraft/)
|
||||
})
|
||||
|
||||
test('recovery codes render only until explicitly acknowledged', () => {
|
||||
@@ -43,3 +46,15 @@ test('recovery codes render only until explicitly acknowledged', () => {
|
||||
match(html, /Я сохранил коды/)
|
||||
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
|
||||
})
|
||||
|
||||
test('both profiles remain selectable and display their own runtime requirements', () => {
|
||||
const library = renderToStaticMarkup(<Library selected={servers[1]!} profiles={{}} account={null}
|
||||
locked={false} native={false} onSelect={() => {}} onSettings={() => {}} />)
|
||||
match(library, /Aeronautics/)
|
||||
match(library, /Minigames/)
|
||||
equal((library.match(/class="server-row/g) ?? []).length, 2)
|
||||
const stage = (index: number) => renderToStaticMarkup(<ServerStage server={servers[index]!} status={null}>{null}</ServerStage>)
|
||||
match(stage(0), /Версия 21/)
|
||||
match(stage(1), /Версия 25/)
|
||||
match(stage(1), /Fabric 0.19.5/)
|
||||
})
|
||||
|
||||
@@ -12,4 +12,9 @@ export const servers: readonly [Server, ...Server[]] = [
|
||||
loader: 'NeoForge 21.1.248',
|
||||
profileId: 'aeronautics',
|
||||
},
|
||||
{
|
||||
id: 'minigames', kicker: 'Лобби и арены', name: 'Minigames',
|
||||
subtitle: 'Небесные острова. Сражения на аренах SMASH.',
|
||||
version: '26.2 · Fabric 0.19.5', loader: 'Fabric 0.19.5', profileId: 'minigames',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ShaCraftAccount } from '../types/launcher'
|
||||
import { isValidNickname } from './settings'
|
||||
|
||||
/** Only the authenticated account's verified Aeronautics link selects a name. */
|
||||
/** Only the authenticated account's verified aoc link selects the shared network nickname. */
|
||||
export function linkedNickname(account: ShaCraftAccount | null | undefined): string | null {
|
||||
const nickname = account?.links.find((link) => link.server_id === 'aoc')?.mc_username
|
||||
return nickname && isValidNickname(nickname) ? nickname : null
|
||||
|
||||
Reference in New Issue
Block a user