5 Commits
19 changed files with 1411 additions and 307 deletions
+9 -9
View File
@@ -39,15 +39,13 @@ payload are in `/root/shacraft` on the ShaCraft host; see
independent, hardcoded-host trust domains (Mojang, NeoForge, Microsoft,
Adoptium) that install and run the actual game. Do not let manifest data
control a URL in any of those domains.
- Account modes: the launcher supports launching as either a genuine
Microsoft account that owns Minecraft Java Edition (`src-tauri/src/msa.rs`,
device-code OAuth -> Xbox Live -> XSTS -> Minecraft Services) or as a local
offline profile (nickname + deterministic offline UUID, see
`src-tauri/src/session.rs`). The mode is an explicit player choice
(`account_mode` in settings); offline is never silently substituted for a
Microsoft session. The mc-aoc/mc-create servers' own `ONLINE_MODE=FALSE` +
whitelist + Login System are a separate, independent access-control layer
on the server side.
- ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the
hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The
revocable session token is stored locally with mode 600 on Unix. At launch,
the nickname is fetched from the verified `aoc` account link; the legacy
nickname in `settings.json` is ignored as an identity source. Server-side
whitelist enforcement and LoginSystem remain the final access-control
boundary, including for old launcher versions.
## Layout
@@ -66,6 +64,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
`MSA_CLIENT_ID`'s doc comment before touching login — it is currently a
placeholder pending ShaCraft's own Azure AD app registration and
Minecraft-API approval.
- `shacraft_account.rs` — local ShaCraft login/registration, session and
verified nickname-link API.
- `launch.rs` — builds and spawns the actual `java` process.
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
compatibility with already-written JSON.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ShaCraft
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+12 -7
View File
@@ -4,10 +4,10 @@
The launcher persists local settings, synchronises Aeronautics mod/config
files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
NeoForge version the manifest specifies, and launches the game. Players can
launch either with a real Microsoft account or with a local offline profile
(nickname + deterministic offline UUID) — see `docs/game-trust-boundary.md`
and `AGENTS.md`'s trust model section.
NeoForge version the manifest specifies, and launches the game. A player
signs in with the same local ShaCraft account used on the website. The game
identity is derived only from that account's verified Aeronautics nickname;
the legacy editable nickname setting is not trusted at launch.
The interface also shows a live Aeronautics player count from the fixed,
read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
@@ -33,7 +33,8 @@ Game itself (never controlled by the manifest above)
-> NeoForge's own installer, run headlessly (neoforge.rs)
-> generic inheritsFrom merge of the two version JSONs (mojang.rs)
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
-> real Microsoft/Xbox/Minecraft Services login (msa.rs)
-> verified ShaCraft account link (shacraft_account.rs)
-> deterministic offline UUID for the linked nickname (session.rs)
-> java process spawned with the merged classpath/args (launch.rs)
```
@@ -42,8 +43,9 @@ screenshots/resourcepacks) live below Tauri's `app_data_dir()/profiles/
<profile-id>` — this becomes `--gameDir`. The shared vanilla+NeoForge
install (versions/libraries/assets/runtime, reused across profiles that
target the same Minecraft version) lives at `app_data_dir()/game`. Settings
live at `app_data_dir()/settings.json`, the Microsoft refresh token at
`app_data_dir()/account.json` (mode 600). None of these should be assumed to
live at `app_data_dir()/settings.json`, and the revocable ShaCraft session at
`app_data_dir()/shacraft-session` (mode 600 on Unix). Passwords are never
written to disk. None of these should be assumed to
be the system `.minecraft` directory.
## Aeronautics contract
@@ -57,6 +59,9 @@ be the system `.minecraft` directory.
no launcher release.
- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
`cdn.shacraft.ru`.
- Account API origin: fixed `https://shacraft.ru`; redirects are rejected.
- Launch identity: the most recently verified `aoc` nickname returned by the
authenticated account API. Local nickname edits cannot select an identity.
## Planned but not implemented
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "shacraft-launcher-ui",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shacraft-launcher-ui",
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"@vitejs/plugin-react": "latest",
+2 -1
View File
@@ -1,7 +1,8 @@
{
"name": "shacraft-launcher-ui",
"license": "MIT",
"private": true,
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -3216,7 +3216,7 @@ dependencies = [
[[package]]
name = "shacraft-launcher"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
+2 -1
View File
@@ -1,8 +1,9 @@
[package]
name = "shacraft-launcher"
version = "0.1.0"
version = "0.1.1"
description = "ShaCraft Minecraft launcher"
authors = ["ShaCraft"]
license = "MIT"
edition = "2021"
[lib]
+89 -11
View File
@@ -73,12 +73,19 @@ pub fn file_hashes(path: &Path) -> io::Result<(String, String)> {
sha1.update(&buffer[..read]);
sha256.update(&buffer[..read]);
}
Ok((format!("{:x}", sha1.finalize()), format!("{:x}", sha256.finalize())))
Ok((
format!("{:x}", sha1.finalize()),
format!("{:x}", sha256.finalize()),
))
}
/// True if `path` already exists, matches `expected_size` (when given) and
/// `checksum`. Used to skip re-downloading files that are already current.
pub fn is_current(path: &Path, expected_size: Option<u64>, checksum: &Checksum) -> io::Result<bool> {
pub fn is_current(
path: &Path,
expected_size: Option<u64>,
checksum: &Checksum,
) -> io::Result<bool> {
let metadata = match path.metadata() {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
@@ -104,6 +111,42 @@ fn temp_path(target: &Path) -> Result<PathBuf, DownloadError> {
Ok(target.with_file_name(format!(".{file_name}.shacraft.part")))
}
/// Replaces `target` with a fully-written temporary sibling. Unix rename
/// replaces an existing file atomically, while Windows rename rejects an
/// existing destination. The backup dance keeps the old file recoverable if
/// the second rename fails (for example because antivirus briefly locks it).
pub(crate) fn replace_file(temporary: &Path, target: &Path) -> io::Result<()> {
#[cfg(not(windows))]
{
fs::rename(temporary, target)
}
#[cfg(windows)]
{
if !target.exists() {
return fs::rename(temporary, target);
}
let file_name = target
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "target has no valid filename")
})?;
let backup = target.with_file_name(format!(".{file_name}.shacraft.backup"));
match fs::remove_file(&backup) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
fs::rename(target, &backup)?;
if let Err(error) = fs::rename(temporary, target) {
let _ = fs::rename(&backup, target);
return Err(error);
}
let _ = fs::remove_file(backup);
Ok(())
}
}
/// Downloads `url` to `target`, verifying size (if known ahead of time) and
/// `checksum` before atomically renaming the temporary file into place.
/// `on_progress(downloaded_bytes, total_bytes)` is called after every chunk;
@@ -126,18 +169,28 @@ pub fn download_verified(
let total = expected_size.or_else(|| response.content_length());
if let (Some(expected), Some(length)) = (expected_size, response.content_length()) {
if expected != length {
return Err(DownloadError::SizeMismatch { expected, actual: length });
return Err(DownloadError::SizeMismatch {
expected,
actual: length,
});
}
}
let temporary = temp_path(target)?;
let result = write_and_verify(&mut response, &temporary, expected_size, checksum, total, &mut on_progress);
let result = write_and_verify(
&mut response,
&temporary,
expected_size,
checksum,
total,
&mut on_progress,
);
if let Err(error) = result {
let _ = fs::remove_file(&temporary);
return Err(error);
}
let bytes = result.unwrap();
fs::rename(&temporary, target).map_err(DownloadError::Io)?;
replace_file(&temporary, target).map_err(DownloadError::Io)?;
Ok(bytes)
}
@@ -160,7 +213,9 @@ fn write_and_verify(
if read == 0 {
break;
}
output.write_all(&buffer[..read]).map_err(DownloadError::Io)?;
output
.write_all(&buffer[..read])
.map_err(DownloadError::Io)?;
sha1.update(&buffer[..read]);
sha256.update(&buffer[..read]);
bytes += read as u64;
@@ -170,7 +225,10 @@ fn write_and_verify(
if let Some(expected) = expected_size {
if bytes != expected {
return Err(DownloadError::SizeMismatch { expected, actual: bytes });
return Err(DownloadError::SizeMismatch {
expected,
actual: bytes,
});
}
}
let sha1_hex = format!("{:x}", sha1.finalize());
@@ -183,14 +241,20 @@ fn write_and_verify(
#[cfg(test)]
mod tests {
use super::{file_hashes, is_current, Checksum};
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
use super::{file_hashes, is_current, replace_file, Checksum};
use std::{
fs, process,
time::{SystemTime, UNIX_EPOCH},
};
fn temp_file(contents: &[u8]) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!(
"shacraft-download-test-{}-{}",
process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::write(&path, contents).unwrap();
path
@@ -201,7 +265,10 @@ mod tests {
let path = temp_file(b"hello shacraft");
let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap();
assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4");
assert_eq!(sha256_hex, "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d");
assert_eq!(
sha256_hex,
"d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d"
);
fs::remove_file(path).unwrap();
}
@@ -220,4 +287,15 @@ mod tests {
let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz");
assert!(!is_current(&path, None, &Checksum::Sha256("0".repeat(64))).unwrap());
}
#[test]
fn replaces_an_existing_file() {
let target = temp_file(b"old");
let temporary = target.with_extension("replacement");
fs::write(&temporary, b"new").unwrap();
replace_file(&temporary, &target).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new");
assert!(!temporary.exists());
fs::remove_file(target).unwrap();
}
}
+26 -9
View File
@@ -2,7 +2,11 @@ use crate::download::ProgressCallback;
use crate::runtime::{self, RuntimeError};
use reqwest::blocking::Client;
use serde::Serialize;
use std::{env, fmt, path::{Path, PathBuf}, process::Command};
use std::{
env, fmt,
path::{Path, PathBuf},
process::Command,
};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -21,9 +25,14 @@ pub enum EnsureJavaError {
impl fmt::Display for EnsureJavaError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Provisioning(error) => write!(formatter, "Cannot install a Java runtime: {error}"),
Self::Provisioning(error) => {
write!(formatter, "Cannot install a Java runtime: {error}")
}
Self::ProvisionedButUnrecognised(path) => {
write!(formatter, "Installed a Java runtime at {path:?}, but it did not report a usable version")
write!(
formatter,
"Installed a Java runtime at {path:?}, but it did not report a usable version"
)
}
}
}
@@ -38,21 +47,29 @@ pub fn detect() -> Option<JavaInstallation> {
candidates().into_iter().find_map(check_candidate)
}
/// Returns a Java runtime with at least `required_major`, preferring
/// whatever the user already has installed. Only downloads and extracts a
/// Returns a Java runtime with exactly `required_major`, preferring a matching
/// installation already on the machine. Newer JVM majors are not assumed to
/// be compatible with the selected NeoForge/modpack version. Only downloads and extracts a
/// ShaCraft-managed Eclipse Temurin JRE under `runtime_root` (never touches
/// the user's own Java) when nothing suitable is already on the machine.
/// `on_progress` reports real download bytes when a JRE actually needs
/// fetching; it fires once with `(1, 1)` when an existing Java is reused.
pub fn ensure_java(client: &Client, runtime_root: &Path, required_major: u8, on_progress: &ProgressCallback) -> Result<JavaInstallation, EnsureJavaError> {
pub fn ensure_java(
client: &Client,
runtime_root: &Path,
required_major: u8,
on_progress: &ProgressCallback,
) -> Result<JavaInstallation, EnsureJavaError> {
if let Some(installation) = detect() {
if installation.major >= required_major {
if installation.major == required_major {
on_progress(1, 1);
return Ok(installation);
}
}
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress).map_err(EnsureJavaError::Provisioning)?;
check_candidate(executable.clone()).ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress)
.map_err(EnsureJavaError::Provisioning)?;
check_candidate(executable.clone())
.ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
}
fn candidates() -> Vec<PathBuf> {
+106 -15
View File
@@ -69,7 +69,12 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -
.libraries
.iter()
.filter(|library| mojang::rule_allows(&library.rules, &no_features))
.filter_map(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()))
.filter_map(|library| {
library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
})
.map(|artifact| game_dir.join("libraries").join(&artifact.path))
.collect();
entries.push(client_jar.to_path_buf());
@@ -78,7 +83,11 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -
// (for example on gson-2.10.1.jar), so preserve order and keep each path
// only once.
let entries = unique_classpath_entries(entries);
entries.iter().map(|path| path.display().to_string()).collect::<Vec<_>>().join(classpath_separator())
entries
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(classpath_separator())
}
/// A persistent-but-not-security-sensitive per-install identifier for the
@@ -104,7 +113,13 @@ static UUID_COUNTER: AtomicU64 = AtomicU64::new(0);
fn random_uuid_v4() -> String {
let mut hasher = Sha256::new();
hasher.update(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos().to_le_bytes());
hasher.update(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes(),
);
hasher.update(std::process::id().to_le_bytes());
hasher.update(UUID_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
let stack_marker = 0_u8;
@@ -114,7 +129,10 @@ fn random_uuid_v4() -> String {
bytes.copy_from_slice(&digest[0..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
let hex = bytes.iter().map(|byte| format!("{byte:02x}")).collect::<String>();
let hex = bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
crate::session::format_uuid_with_dashes(&hex)
}
@@ -129,6 +147,36 @@ fn substitute(template: &str, vars: &HashMap<&str, String>) -> String {
result
}
/// Java's argument-file syntax is independent of the platform shell. Keeping
/// the large JVM/module/classpath portion in an argfile avoids Windows'
/// 32,767 UTF-16 command-line limit while leaving account tokens out of it.
fn quote_argfile_argument(argument: &str) -> String {
let mut quoted = String::with_capacity(argument.len() + 2);
quoted.push('"');
for character in argument.chars() {
match character {
'\\' => quoted.push_str("\\\\"),
'"' => quoted.push_str("\\\""),
'\n' => quoted.push_str("\\n"),
'\r' => quoted.push_str("\\r"),
'\t' => quoted.push_str("\\t"),
other => quoted.push(other),
}
}
quoted.push('"');
quoted
}
fn write_jvm_argfile(path: &Path, arguments: &[String]) -> io::Result<()> {
let mut contents = arguments
.iter()
.map(|argument| quote_argfile_argument(argument))
.collect::<Vec<_>>()
.join("\n");
contents.push('\n');
fs::write(path, contents)
}
/// Builds the full `java` command line for `request.merged` and spawns it
/// detached, with stdout/stderr both redirected to `request.log_path`.
/// Never blocks on the child exiting — the caller decides how to observe
@@ -140,7 +188,8 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
fs::create_dir_all(&natives_dir)?;
let assets_root = request.game_dir.join("assets");
let libraries_dir = request.game_dir.join("libraries");
let client_jar = mojang::client_jar_path(request.game_dir, &request.merged.client_jar_version_id);
let client_jar =
mojang::client_jar_path(request.game_dir, &request.merged.client_jar_version_id);
let classpath = build_classpath(request.game_dir, request.merged, &client_jar);
let mut vars: HashMap<&str, String> = HashMap::new();
@@ -155,7 +204,10 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
vars.insert("assets_root", assets_root.display().to_string());
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
vars.insert("auth_uuid", request.identity.uuid());
vars.insert("auth_access_token", request.identity.access_token().to_string());
vars.insert(
"auth_access_token",
request.identity.access_token().to_string(),
);
vars.insert("clientid", launcher_client_id(request.game_dir)?);
vars.insert("auth_xuid", request.identity.xuid().to_string());
vars.insert("user_type", request.identity.user_type().to_string());
@@ -168,13 +220,24 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
vars.insert("classpath_separator", classpath_separator().to_string());
let no_features = HashMap::new();
let jvm_args = mojang::resolve_arguments(&request.merged.jvm_arguments, &no_features);
let jvm_args = mojang::resolve_arguments(&request.merged.jvm_arguments, &no_features)
.into_iter()
.map(|argument| substitute(&argument, &vars))
.collect::<Vec<_>>();
let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
let mut command = Command::new(request.java_executable);
command.arg(format!("-Xmx{}M", request.memory_mb));
for argument in jvm_args {
command.arg(substitute(&argument, &vars));
let memory_argument = format!("-Xmx{}M", request.memory_mb);
if cfg!(windows) {
let argfile = request.profile_dir.join(".shacraft-jvm.args");
let mut argfile_arguments = Vec::with_capacity(jvm_args.len() + 1);
argfile_arguments.push(memory_argument);
argfile_arguments.extend(jvm_args);
write_jvm_argfile(&argfile, &argfile_arguments)?;
command.arg(format!("@{}", argfile.display()));
} else {
command.arg(memory_argument);
command.args(jvm_args);
}
command.arg(&request.merged.main_class);
for argument in game_args {
@@ -199,12 +262,18 @@ mod tests {
let parts: Vec<&str> = id.split('-').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[2].chars().next().unwrap(), '4');
assert!(matches!(parts[3].chars().next().unwrap(), '8' | '9' | 'a' | 'b'));
assert!(matches!(
parts[3].chars().next().unwrap(),
'8' | '9' | 'a' | 'b'
));
}
#[test]
fn client_id_is_persisted_across_calls() {
let dir = std::env::temp_dir().join(format!("shacraft-launch-clientid-test-{}", std::process::id()));
let dir = std::env::temp_dir().join(format!(
"shacraft-launch-clientid-test-{}",
std::process::id()
));
let first = launcher_client_id(&dir).unwrap();
let second = launcher_client_id(&dir).unwrap();
assert_eq!(first, second);
@@ -217,12 +286,34 @@ mod tests {
vars.insert("auth_player_name", "Steve".to_string());
assert_eq!(substitute("--username", &vars), "--username");
assert_eq!(substitute("${auth_player_name}", &vars), "Steve");
assert_eq!(substitute("-Djava.library.path=${natives_directory}", &vars), "-Djava.library.path=${natives_directory}");
assert_eq!(
substitute("-Djava.library.path=${natives_directory}", &vars),
"-Djava.library.path=${natives_directory}"
);
}
#[test]
fn classpath_entries_are_unique() {
let entries = unique_classpath_entries(vec![PathBuf::from("gson.jar"), PathBuf::from("gson.jar"), PathBuf::from("client.jar")]);
assert_eq!(entries, vec![PathBuf::from("gson.jar"), PathBuf::from("client.jar")]);
let entries = unique_classpath_entries(vec![
PathBuf::from("gson.jar"),
PathBuf::from("gson.jar"),
PathBuf::from("client.jar"),
]);
assert_eq!(
entries,
vec![PathBuf::from("gson.jar"), PathBuf::from("client.jar")]
);
}
#[test]
fn quotes_java_argfile_arguments() {
assert_eq!(
quote_argfile_argument(r#"-Dpath=C:\\Users\\Jane Doe\\game"#),
r#""-Dpath=C:\\\\Users\\\\Jane Doe\\\\game""#
);
assert_eq!(
quote_argfile_argument(r#"-Dname="ShaCraft""#),
r#""-Dname=\"ShaCraft\"""#
);
}
}
+54 -17
View File
@@ -9,6 +9,7 @@ mod profile;
mod remote;
mod runtime;
mod session;
mod shacraft_account;
mod settings;
use reqwest::blocking::Client;
@@ -162,6 +163,48 @@ async fn save_settings(app: AppHandle, settings: settings::LauncherSettings) ->
.map_err(|error| error.to_string())
}
#[tauri::command]
async fn shacraft_authenticate(app: AppHandle, username: String, password: String, register: bool) -> Result<shacraft_account::LoginResult, String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || shacraft_account::authenticate(&data_dir, &username, &password, register))
.await.map_err(|error| format!("Account task failed: {error}"))?
.map_err(|error| error.to_string())
}
#[tauri::command]
async fn get_shacraft_account(app: AppHandle) -> Result<Option<shacraft_account::Account>, String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || match shacraft_account::get_account(&data_dir) {
Ok(account) => Ok(Some(account)),
Err(shacraft_account::AccountError::InvalidSession) => Ok(None),
Err(error) => Err(error.to_string()),
}).await.map_err(|error| format!("Account task failed: {error}"))?
}
#[tauri::command]
async fn shacraft_logout(app: AppHandle) -> Result<(), String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || shacraft_account::logout(&data_dir))
.await.map_err(|error| format!("Account task failed: {error}"))?
.map_err(|error| error.to_string())
}
#[tauri::command]
async fn shacraft_start_link(app: AppHandle, nickname: String) -> Result<shacraft_account::LinkStart, String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || shacraft_account::start_link(&data_dir, "aoc", &nickname))
.await.map_err(|error| format!("Link task failed: {error}"))?
.map_err(|error| error.to_string())
}
#[tauri::command]
async fn shacraft_link_status(app: AppHandle, challenge_id: i64) -> Result<shacraft_account::LinkStatus, String> {
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
tauri::async_runtime::spawn_blocking(move || shacraft_account::link_status(&data_dir, challenge_id))
.await.map_err(|error| format!("Link task failed: {error}"))?
.map_err(|error| error.to_string())
}
// ---------------------------------------------------------------------
// Microsoft account login
// ---------------------------------------------------------------------
@@ -248,23 +291,12 @@ async fn logout(app: AppHandle) -> Result<(), String> {
.map_err(|error| error.to_string())
}
/// Resolves the identity to launch as, based on the persisted `account_mode`.
/// In `Microsoft` mode this requires a real signed-in session (see
/// `msa::login_with_refresh_token`) and returns an error if there is none;
/// in `Offline` mode it uses the local nickname from settings, so no
/// Microsoft account is needed at all. Offline is never silently used in
/// place of a missing Microsoft session.
fn resolve_identity(client: &Client, data_dir: &Path) -> Result<session::PlayerIdentity, String> {
let settings = settings::load(data_dir).map_err(|error| error.to_string())?;
match settings.account_mode {
settings::AccountMode::Offline => Ok(session::PlayerIdentity::Offline { name: settings.nickname }),
settings::AccountMode::Microsoft => {
let refresh_token = msa::load_refresh_token(data_dir).ok_or("Not signed in with a Microsoft account")?;
let result = msa::login_with_refresh_token(client, &refresh_token).map_err(|error| error.to_string())?;
let _ = msa::save_refresh_token(data_dir, &result.refresh_token);
Ok(session::PlayerIdentity::Microsoft(result))
}
}
/// Resolves the identity from the server-side ShaCraft account link. Local
/// settings are deliberately not trusted for a nickname, so editing an old
/// settings file cannot change the identity used by this launcher.
fn resolve_identity(_client: &Client, data_dir: &Path) -> Result<session::PlayerIdentity, String> {
let name = shacraft_account::aeronautics_nickname(data_dir).map_err(|error| error.to_string())?;
Ok(session::PlayerIdentity::Offline { name })
}
// ---------------------------------------------------------------------
@@ -416,6 +448,11 @@ pub fn run() {
get_server_status,
load_settings,
save_settings,
shacraft_authenticate,
get_shacraft_account,
shacraft_logout,
shacraft_start_link,
shacraft_link_status,
start_microsoft_login,
get_account,
logout,
+195 -40
View File
@@ -27,7 +27,8 @@ use std::{
};
use url::Url;
const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
const VERSION_MANIFEST_URL: &str =
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
const MOJANG_HOSTS: [&str; 4] = [
"piston-meta.mojang.com",
"piston-data.mojang.com",
@@ -41,7 +42,10 @@ const MOJANG_HOSTS: [&str; 4] = [
const ASSET_WORKERS: usize = 48;
pub fn is_allowed_host(url: &str) -> bool {
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))).unwrap_or(false)
Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host)))
.unwrap_or(false)
}
/// Library entries in a merged loader profile may point at the loader's
@@ -59,6 +63,7 @@ pub enum MojangError {
ChecksumMismatch(String),
DisallowedHost(String),
MissingField(String),
ConflictingLibrary(String),
Download(DownloadError),
Io(io::Error),
}
@@ -70,8 +75,14 @@ impl fmt::Display for MojangError {
Self::HttpStatus(status) => write!(formatter, "Mojang returned {status}"),
Self::InvalidJson(error) => write!(formatter, "invalid Mojang JSON: {error}"),
Self::ChecksumMismatch(context) => write!(formatter, "checksum mismatch for {context}"),
Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised Mojang host: {url}"),
Self::DisallowedHost(url) => {
write!(formatter, "URL is not a recognised Mojang host: {url}")
}
Self::MissingField(field) => write!(formatter, "version JSON is missing {field}"),
Self::ConflictingLibrary(path) => write!(
formatter,
"merged version contains conflicting library entries for {path}"
),
Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
}
@@ -110,7 +121,10 @@ pub fn fetch_version_manifest(client: &Client) -> Result<VersionManifest, Mojang
fetch_json(client, VERSION_MANIFEST_URL, None)
}
pub fn find_version<'a>(manifest: &'a VersionManifest, id: &str) -> Option<&'a VersionManifestEntry> {
pub fn find_version<'a>(
manifest: &'a VersionManifest,
id: &str,
) -> Option<&'a VersionManifestEntry> {
manifest.versions.iter().find(|entry| entry.id == id)
}
@@ -145,7 +159,10 @@ pub struct Arguments {
#[serde(untagged)]
pub enum ArgumentValue {
Plain(String),
Conditional { rules: Vec<Rule>, value: StringOrList },
Conditional {
rules: Vec<Rule>,
value: StringOrList,
},
}
#[derive(Debug, Deserialize, Clone)]
@@ -231,7 +248,11 @@ fn current_os_name() -> &'static str {
}
fn arch_matches(expected: &str) -> bool {
let normalized = if expected == "arm64" { "aarch64" } else { expected };
let normalized = if expected == "arm64" {
"aarch64"
} else {
expected
};
normalized == std::env::consts::ARCH
}
@@ -250,7 +271,9 @@ fn os_matches(os: &RuleOs) -> bool {
}
fn features_match(required: &HashMap<String, bool>, active: &HashMap<String, bool>) -> bool {
required.iter().all(|(key, value)| active.get(key).copied().unwrap_or(false) == *value)
required
.iter()
.all(|(key, value)| active.get(key).copied().unwrap_or(false) == *value)
}
/// Evaluates a Mojang-style rule list: no rules means always allowed;
@@ -265,7 +288,10 @@ pub fn rule_allows(rules: &[Rule], active_features: &HashMap<String, bool>) -> b
let mut allowed = false;
for rule in rules {
let os_ok = rule.os.as_ref().is_none_or(os_matches);
let features_ok = rule.features.as_ref().is_none_or(|required| features_match(required, active_features));
let features_ok = rule
.features
.as_ref()
.is_none_or(|required| features_match(required, active_features));
if os_ok && features_ok {
allowed = rule.action == RuleAction::Allow;
}
@@ -275,7 +301,10 @@ pub fn rule_allows(rules: &[Rule], active_features: &HashMap<String, bool>) -> b
/// Flattens an argument list into plain strings, dropping conditional
/// entries whose rules don't match this platform/feature set.
pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap<String, bool>) -> Vec<String> {
pub fn resolve_arguments(
arguments: &[ArgumentValue],
active_features: &HashMap<String, bool>,
) -> Vec<String> {
let mut resolved = Vec::new();
for argument in arguments {
match argument {
@@ -293,11 +322,18 @@ pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap<
resolved
}
pub fn fetch_version_json(client: &Client, entry: &VersionManifestEntry) -> Result<VersionJson, MojangError> {
pub fn fetch_version_json(
client: &Client,
entry: &VersionManifestEntry,
) -> Result<VersionJson, MojangError> {
fetch_json(client, &entry.url, Some(&entry.sha1))
}
fn fetch_json<T: DeserializeOwned>(client: &Client, url: &str, expected_sha1: Option<&str>) -> Result<T, MojangError> {
fn fetch_json<T: DeserializeOwned>(
client: &Client,
url: &str,
expected_sha1: Option<&str>,
) -> Result<T, MojangError> {
if !is_allowed_host(url) {
return Err(MojangError::DisallowedHost(url.to_string()));
}
@@ -348,8 +384,14 @@ pub struct MergedVersion {
/// the parent's, and its libraries are appended after the parent's.
/// `assetIndex`/`downloads.client` always come from the parent, since
/// modloader profiles don't redeclare them.
pub fn merge_versions(parent: &VersionJson, child: Option<&VersionJson>) -> Result<MergedVersion, MojangError> {
let asset_index = parent.asset_index.clone().ok_or_else(|| MojangError::MissingField("assetIndex".into()))?;
pub fn merge_versions(
parent: &VersionJson,
child: Option<&VersionJson>,
) -> Result<MergedVersion, MojangError> {
let asset_index = parent
.asset_index
.clone()
.ok_or_else(|| MojangError::MissingField("assetIndex".into()))?;
let client = parent
.downloads
.as_ref()
@@ -401,34 +443,69 @@ pub fn natives_directory(game_dir: &Path, version_id: &str) -> PathBuf {
}
pub fn client_jar_path(game_dir: &Path, version_id: &str) -> PathBuf {
game_dir.join("versions").join(version_id).join(format!("{version_id}.jar"))
game_dir
.join("versions")
.join(version_id)
.join(format!("{version_id}.jar"))
}
pub fn ensure_client_jar(client: &Client, game_dir: &Path, version_id: &str, download_ref: &DownloadRef) -> Result<PathBuf, MojangError> {
pub fn ensure_client_jar(
client: &Client,
game_dir: &Path,
version_id: &str,
download_ref: &DownloadRef,
) -> Result<PathBuf, MojangError> {
if !is_allowed_host(&download_ref.url) {
return Err(MojangError::DisallowedHost(download_ref.url.clone()));
}
let target = client_jar_path(game_dir, version_id);
let checksum = Checksum::Sha1(download_ref.sha1.clone());
if !download::is_current(&target, Some(download_ref.size), &checksum)? {
download::download_verified(client, &download_ref.url, &target, Some(download_ref.size), &checksum, |_, _| {})?;
download::download_verified(
client,
&download_ref.url,
&target,
Some(download_ref.size),
&checksum,
|_, _| {},
)?;
}
Ok(target)
}
/// Downloads every rule-allowed library with a `downloads.artifact`,
/// returning the resulting jar paths in the same order as `libraries`.
pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library], on_progress: &ProgressCallback) -> Result<Vec<PathBuf>, MojangError> {
pub fn ensure_libraries(
client: &Client,
game_dir: &Path,
libraries: &[Library],
on_progress: &ProgressCallback,
) -> Result<Vec<PathBuf>, MojangError> {
let mut paths = Vec::new();
let mut tasks = Vec::new();
let mut seen: HashMap<PathBuf, (String, u64, String)> = HashMap::new();
for library in libraries {
if !rule_allows(&library.rules, &HashMap::new()) {
continue;
}
let Some(artifact) = library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()) else {
let Some(artifact) = library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
else {
continue;
};
let target = game_dir.join("libraries").join(&artifact.path);
let identity = (artifact.url.clone(), artifact.size, artifact.sha1.clone());
if let Some(existing) = seen.get(&target) {
if existing != &identity {
return Err(MojangError::ConflictingLibrary(
target.display().to_string(),
));
}
continue;
}
seen.insert(target.clone(), identity);
paths.push(target.clone());
tasks.push(DownloadTask {
url: artifact.url.clone(),
@@ -452,20 +529,39 @@ pub struct AssetObject {
pub size: u64,
}
pub fn ensure_asset_index(client: &Client, game_dir: &Path, asset_index: &AssetIndexRef) -> Result<AssetIndex, MojangError> {
pub fn ensure_asset_index(
client: &Client,
game_dir: &Path,
asset_index: &AssetIndexRef,
) -> Result<AssetIndex, MojangError> {
if !is_allowed_host(&asset_index.url) {
return Err(MojangError::DisallowedHost(asset_index.url.clone()));
}
let target = game_dir.join("assets").join("indexes").join(format!("{}.json", asset_index.id));
let target = game_dir
.join("assets")
.join("indexes")
.join(format!("{}.json", asset_index.id));
let checksum = Checksum::Sha1(asset_index.sha1.clone());
if !download::is_current(&target, Some(asset_index.size), &checksum)? {
download::download_verified(client, &asset_index.url, &target, Some(asset_index.size), &checksum, |_, _| {})?;
download::download_verified(
client,
&asset_index.url,
&target,
Some(asset_index.size),
&checksum,
|_, _| {},
)?;
}
let bytes = fs::read(&target)?;
serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson)
}
pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_progress: &ProgressCallback) -> Result<(), MojangError> {
pub fn ensure_assets(
client: &Client,
game_dir: &Path,
index: &AssetIndex,
on_progress: &ProgressCallback,
) -> Result<(), MojangError> {
let objects_dir = game_dir.join("assets").join("objects");
let tasks = index
.objects
@@ -473,7 +569,10 @@ pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_pr
.map(|object| {
let prefix = &object.hash[0..2];
DownloadTask {
url: format!("https://resources.download.minecraft.net/{prefix}/{}", object.hash),
url: format!(
"https://resources.download.minecraft.net/{prefix}/{}",
object.hash
),
target: objects_dir.join(prefix).join(&object.hash),
size: object.size,
checksum: Checksum::Sha1(object.hash.clone()),
@@ -500,7 +599,14 @@ const MAX_DOWNLOAD_ATTEMPTS: u32 = 5;
fn download_with_retries(client: &Client, task: &DownloadTask) -> Result<u64, DownloadError> {
let mut last_error = None;
for attempt in 1..=MAX_DOWNLOAD_ATTEMPTS {
match download::download_verified(client, &task.url, &task.target, Some(task.size), &task.checksum, |_, _| {}) {
match download::download_verified(
client,
&task.url,
&task.target,
Some(task.size),
&task.checksum,
|_, _| {},
) {
Ok(bytes) => return Ok(bytes),
Err(error) => {
last_error = Some(error);
@@ -540,12 +646,16 @@ fn download_many(
if first_error.lock().unwrap().is_some() {
break;
}
let Some(task) = queue.lock().unwrap().pop() else { break };
let Some(task) = queue.lock().unwrap().pop() else {
break;
};
if !is_allowed(&task.url) {
*first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url));
continue;
}
let already_current = download::is_current(&task.target, Some(task.size), &task.checksum).unwrap_or(false);
let already_current =
download::is_current(&task.target, Some(task.size), &task.checksum)
.unwrap_or(false);
if !already_current {
if let Err(error) = download_with_retries(client, &task) {
*first_error.lock().unwrap() = Some(MojangError::Download(error));
@@ -571,7 +681,10 @@ mod tests {
fn rule(action: RuleAction, os_name: Option<&str>) -> Rule {
Rule {
action,
os: os_name.map(|name| RuleOs { name: Some(name.into()), arch: None }),
os: os_name.map(|name| RuleOs {
name: Some(name.into()),
arch: None,
}),
features: None,
}
}
@@ -589,7 +702,11 @@ mod tests {
#[test]
fn non_matching_os_rule_disallows() {
let other = if current_os_name() == "windows" { "linux" } else { "windows" };
let other = if current_os_name() == "windows" {
"linux"
} else {
"windows"
};
let rules = vec![rule(RuleAction::Allow, Some(other))];
assert!(!rule_allows(&rules, &HashMap::new()));
}
@@ -598,7 +715,11 @@ mod tests {
fn unsupported_feature_is_excluded_by_default() {
let mut features = HashMap::new();
features.insert("is_demo_user".to_string(), true);
let rules = vec![Rule { action: RuleAction::Allow, os: None, features: Some(features) }];
let rules = vec![Rule {
action: RuleAction::Allow,
os: None,
features: Some(features),
}];
// We never activate optional features, so a rule requiring one
// must not match even though there's no OS constraint.
assert!(!rule_allows(&rules, &HashMap::new()));
@@ -619,7 +740,10 @@ mod tests {
},
];
let resolved = resolve_arguments(&args, &HashMap::new());
assert_eq!(resolved, vec!["--username", "${auth_player_name}", "--this-os-only"]);
assert_eq!(
resolved,
vec!["--username", "${auth_player_name}", "--this-os-only"]
);
}
#[test]
@@ -649,18 +773,38 @@ mod tests {
let merged = merge_versions(&parent, Some(&child)).unwrap();
assert_eq!(merged.id, "neoforge-21.1.248");
assert_eq!(merged.client_jar_version_id, "1.21.1");
assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher");
assert_eq!(resolve_arguments(&merged.game_arguments, &HashMap::new()), vec!["--parentGame", "--childGame"]);
assert_eq!(resolve_arguments(&merged.jvm_arguments, &HashMap::new()), vec!["--parentJvm", "--childJvm"]);
assert_eq!(merged.libraries.iter().map(|library| library.name.as_str()).collect::<Vec<_>>(), vec!["parent:lib:1", "child:lib:1"]);
assert_eq!(
merged.main_class,
"cpw.mods.bootstraplauncher.BootstrapLauncher"
);
assert_eq!(
resolve_arguments(&merged.game_arguments, &HashMap::new()),
vec!["--parentGame", "--childGame"]
);
assert_eq!(
resolve_arguments(&merged.jvm_arguments, &HashMap::new()),
vec!["--parentJvm", "--childJvm"]
);
assert_eq!(
merged
.libraries
.iter()
.map(|library| library.name.as_str())
.collect::<Vec<_>>(),
vec!["parent:lib:1", "child:lib:1"]
);
assert_eq!(merged.asset_index.id, "17");
}
#[test]
fn disallowed_host_is_rejected() {
assert!(!is_allowed_host("https://example.com/evil.jar"));
assert!(is_allowed_host("https://piston-data.mojang.com/v1/objects/x/client.jar"));
assert!(is_allowed_library_host("https://maven.neoforged.net/releases/net/neoforged/example.jar"));
assert!(is_allowed_host(
"https://piston-data.mojang.com/v1/objects/x/client.jar"
));
assert!(is_allowed_library_host(
"https://maven.neoforged.net/releases/net/neoforged/example.jar"
));
assert!(!is_allowed_library_host("https://example.com/evil.jar"));
}
@@ -677,7 +821,8 @@ mod tests {
let version = fetch_version_json(&client, entry).unwrap();
assert_eq!(version.main_class, "net.minecraft.client.main.Main");
let game_dir = std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id()));
let game_dir =
std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id()));
let merged = merge_versions(&version, None).unwrap();
let client_jar = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
@@ -689,11 +834,20 @@ mod tests {
let mut small_libraries: Vec<Library> = merged
.libraries
.iter()
.filter(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()).is_some_and(|artifact| artifact.size < 200_000))
.filter(|library| {
library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.is_some_and(|artifact| artifact.size < 200_000)
})
.take(5)
.cloned()
.collect();
assert!(!small_libraries.is_empty(), "expected at least one small library to sanity-check downloads with");
assert!(
!small_libraries.is_empty(),
"expected at least one small library to sanity-check downloads with"
);
small_libraries.truncate(5);
let progress: ProgressCallback = Arc::new(|_, _| {});
let paths = ensure_libraries(&client, &game_dir, &small_libraries, &progress).unwrap();
@@ -703,7 +857,8 @@ mod tests {
// Re-running against already-downloaded files must be a no-op (the
// `is_current` fast path), not re-download or fail.
let client_jar_again = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
let client_jar_again =
ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
assert_eq!(client_jar, client_jar_again);
fs::remove_dir_all(&game_dir).ok();
+121 -30
View File
@@ -41,7 +41,8 @@ const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
const XBOX_USER_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
const XSTS_AUTHORIZE_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
const MINECRAFT_LOGIN_URL: &str = "https://api.minecraftservices.com/authentication/login_with_xbox";
const MINECRAFT_LOGIN_URL: &str =
"https://api.minecraftservices.com/authentication/login_with_xbox";
const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
const ACCOUNT_FILE: &str = "account.json";
@@ -111,7 +112,10 @@ pub fn start_device_code(client: &Client) -> Result<DeviceCodeStart, MsaError> {
}
let response = client
.post(DEVICE_CODE_URL)
.form(&[("client_id", MSA_CLIENT_ID), ("scope", "XboxLive.signin offline_access")])
.form(&[
("client_id", MSA_CLIENT_ID),
("scope", "XboxLive.signin offline_access"),
])
.send()
.map_err(MsaError::Network)?;
if !response.status().is_success() {
@@ -144,7 +148,10 @@ struct TokenResponse {
/// decline. This is the slow step in the whole login flow — the caller
/// should already have shown `verification_uri`/`user_code` to the user
/// before calling this (see `start_device_code`).
pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<MicrosoftTokens, MsaError> {
pub fn poll_device_code(
client: &Client,
start: &DeviceCodeStart,
) -> Result<MicrosoftTokens, MsaError> {
let deadline = Instant::now() + Duration::from_secs(start.expires_in_seconds);
let mut interval = Duration::from_secs(start.interval_seconds);
@@ -167,10 +174,16 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<Micr
let body: TokenResponse = response.json().map_err(MsaError::Network)?;
if status.is_success() {
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else {
return Err(MsaError::UnexpectedResponse("token response missing access_token/refresh_token".into()));
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token)
else {
return Err(MsaError::UnexpectedResponse(
"token response missing access_token/refresh_token".into(),
));
};
return Ok(MicrosoftTokens { access_token, refresh_token });
return Ok(MicrosoftTokens {
access_token,
refresh_token,
});
}
match body.error.as_deref() {
@@ -181,12 +194,19 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<Micr
}
Some("authorization_declined") => return Err(MsaError::AuthorizationDeclined),
Some("expired_token") => return Err(MsaError::AuthorizationExpired),
other => return Err(MsaError::UnexpectedResponse(other.unwrap_or("unknown device code error").into())),
other => {
return Err(MsaError::UnexpectedResponse(
other.unwrap_or("unknown device code error").into(),
))
}
}
}
}
pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result<MicrosoftTokens, MsaError> {
pub fn refresh_microsoft_tokens(
client: &Client,
refresh_token: &str,
) -> Result<MicrosoftTokens, MsaError> {
if !is_configured() {
return Err(MsaError::NotConfigured);
}
@@ -205,9 +225,14 @@ pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result<
}
let body: TokenResponse = response.json().map_err(MsaError::Network)?;
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else {
return Err(MsaError::UnexpectedResponse("refresh response missing access_token/refresh_token".into()));
return Err(MsaError::UnexpectedResponse(
"refresh response missing access_token/refresh_token".into(),
));
};
Ok(MicrosoftTokens { access_token, refresh_token })
Ok(MicrosoftTokens {
access_token,
refresh_token,
})
}
// ---------------------------------------------------------------------
@@ -274,7 +299,10 @@ struct XboxUserHash {
xid: Option<String>,
}
fn xbox_live_user_token(client: &Client, microsoft_access_token: &str) -> Result<(String, String), MsaError> {
fn xbox_live_user_token(
client: &Client,
microsoft_access_token: &str,
) -> Result<(String, String), MsaError> {
let request = XboxUserAuthRequest {
properties: XboxUserAuthProperties {
auth_method: "RPS",
@@ -284,22 +312,42 @@ fn xbox_live_user_token(client: &Client, microsoft_access_token: &str) -> Result
relying_party: "http://auth.xboxlive.com",
token_type: "JWT",
};
let response = client.post(XBOX_USER_AUTH_URL).json(&request).send().map_err(MsaError::Network)?;
let response = client
.post(XBOX_USER_AUTH_URL)
.json(&request)
.send()
.map_err(MsaError::Network)?;
if !response.status().is_success() {
return Err(MsaError::HttpStatus(response.status()));
}
let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?;
let uhs = body.display_claims.xui.into_iter().next().map(|claim| claim.uhs).ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
let uhs = body
.display_claims
.xui
.into_iter()
.next()
.map(|claim| claim.uhs)
.ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
Ok((body.token, uhs))
}
fn xsts_authorize(client: &Client, xbox_live_token: &str) -> Result<(String, String, Option<String>), MsaError> {
fn xsts_authorize(
client: &Client,
xbox_live_token: &str,
) -> Result<(String, String, Option<String>), MsaError> {
let request = XstsRequest {
properties: XstsProperties { sandbox_id: "RETAIL", user_tokens: [xbox_live_token] },
properties: XstsProperties {
sandbox_id: "RETAIL",
user_tokens: [xbox_live_token],
},
relying_party: "rp://api.minecraftservices.com/",
token_type: "JWT",
};
let response = client.post(XSTS_AUTHORIZE_URL).json(&request).send().map_err(MsaError::Network)?;
let response = client
.post(XSTS_AUTHORIZE_URL)
.json(&request)
.send()
.map_err(MsaError::Network)?;
let status = response.status();
if status.as_u16() == 401 {
// XErr 2148916233 means the account has no Xbox profile at all
@@ -312,7 +360,12 @@ fn xsts_authorize(client: &Client, xbox_live_token: &str) -> Result<(String, Str
return Err(MsaError::HttpStatus(status));
}
let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?;
let claim = body.display_claims.xui.into_iter().next().ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
let claim = body
.display_claims
.xui
.into_iter()
.next()
.ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
Ok((body.token, claim.uhs, claim.xid))
}
@@ -328,8 +381,14 @@ struct MinecraftLoginResponse {
}
fn minecraft_login(client: &Client, user_hash: &str, xsts_token: &str) -> Result<String, MsaError> {
let request = MinecraftLoginRequest { identity_token: format!("XBL3.0 x={user_hash};{xsts_token}") };
let response = client.post(MINECRAFT_LOGIN_URL).json(&request).send().map_err(MsaError::Network)?;
let request = MinecraftLoginRequest {
identity_token: format!("XBL3.0 x={user_hash};{xsts_token}"),
};
let response = client
.post(MINECRAFT_LOGIN_URL)
.json(&request)
.send()
.map_err(MsaError::Network)?;
if !response.status().is_success() {
return Err(MsaError::HttpStatus(response.status()));
}
@@ -347,7 +406,10 @@ pub struct MinecraftProfile {
/// Confirms game ownership. A 404 here means the account has no Java
/// Edition profile — i.e. doesn't own the game — and nothing should
/// install or launch.
fn fetch_minecraft_profile(client: &Client, minecraft_access_token: &str) -> Result<MinecraftProfile, MsaError> {
fn fetch_minecraft_profile(
client: &Client,
minecraft_access_token: &str,
) -> Result<MinecraftProfile, MsaError> {
let response = client
.get(MINECRAFT_PROFILE_URL)
.bearer_auth(minecraft_access_token)
@@ -376,15 +438,26 @@ fn complete_login(client: &Client, tokens: MicrosoftTokens) -> Result<LoginResul
let (xsts_token, user_hash, xuid) = xsts_authorize(client, &xbox_live_token)?;
let minecraft_access_token = minecraft_login(client, &user_hash, &xsts_token)?;
let profile = fetch_minecraft_profile(client, &minecraft_access_token)?;
Ok(LoginResult { minecraft_access_token, profile, refresh_token: tokens.refresh_token, xuid })
Ok(LoginResult {
minecraft_access_token,
profile,
refresh_token: tokens.refresh_token,
xuid,
})
}
pub fn login_with_device_code(client: &Client, start: &DeviceCodeStart) -> Result<LoginResult, MsaError> {
pub fn login_with_device_code(
client: &Client,
start: &DeviceCodeStart,
) -> Result<LoginResult, MsaError> {
let tokens = poll_device_code(client, start)?;
complete_login(client, tokens)
}
pub fn login_with_refresh_token(client: &Client, refresh_token: &str) -> Result<LoginResult, MsaError> {
pub fn login_with_refresh_token(
client: &Client,
refresh_token: &str,
) -> Result<LoginResult, MsaError> {
let tokens = refresh_microsoft_tokens(client, refresh_token)?;
complete_login(client, tokens)
}
@@ -401,8 +474,15 @@ struct StoredAccount {
pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()> {
fs::create_dir_all(data_dir)?;
let saved_at_unix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix }).expect("StoredAccount is serializable");
let saved_at_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let contents = serde_json::to_vec_pretty(&StoredAccount {
refresh_token: refresh_token.to_string(),
saved_at_unix,
})
.expect("StoredAccount is serializable");
let target = data_dir.join(ACCOUNT_FILE);
let temporary = data_dir.join(".account.json.shacraft.part");
@@ -412,7 +492,7 @@ pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
}
fs::rename(temporary, target)
crate::download::replace_file(&temporary, &target)
}
pub fn load_refresh_token(data_dir: &Path) -> Option<String> {
@@ -438,7 +518,10 @@ mod tests {
let dir = std::env::temp_dir().join(format!("shacraft-msa-test-{}", std::process::id()));
assert!(load_refresh_token(&dir).is_none());
save_refresh_token(&dir, "super-secret-refresh-token").unwrap();
assert_eq!(load_refresh_token(&dir).as_deref(), Some("super-secret-refresh-token"));
assert_eq!(
load_refresh_token(&dir).as_deref(),
Some("super-secret-refresh-token")
);
clear_account(&dir).unwrap();
assert!(load_refresh_token(&dir).is_none());
fs::remove_dir_all(&dir).ok();
@@ -448,9 +531,14 @@ mod tests {
#[test]
fn stored_account_file_is_not_world_or_group_readable() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id()));
let dir =
std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id()));
save_refresh_token(&dir, "secret").unwrap();
let mode = fs::metadata(dir.join(ACCOUNT_FILE)).unwrap().permissions().mode() & 0o777;
let mode = fs::metadata(dir.join(ACCOUNT_FILE))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
fs::remove_dir_all(&dir).ok();
}
@@ -459,7 +547,10 @@ mod tests {
fn refuses_to_run_with_placeholder_client_id() {
assert!(!is_configured());
let client = Client::builder().build().unwrap();
assert!(matches!(start_device_code(&client), Err(MsaError::NotConfigured)));
assert!(matches!(
start_device_code(&client),
Err(MsaError::NotConfigured)
));
}
/// Live smoke test: requests a real device code from Microsoft and
+235 -39
View File
@@ -57,21 +57,34 @@ pub enum NeoForgeError {
Download(DownloadError),
Io(io::Error),
InvalidJson(serde_json::Error),
InstallerFailed { exit_code: Option<i32>, output_tail: String },
InstallerFailed {
exit_code: Option<i32>,
output_tail: String,
},
}
impl fmt::Display for NeoForgeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised NeoForge host: {url}"),
Self::DisallowedHost(url) => {
write!(formatter, "URL is not a recognised NeoForge host: {url}")
}
Self::Network(error) => write!(formatter, "network error: {error}"),
Self::HttpStatus(status) => write!(formatter, "maven.neoforged.net returned {status}"),
Self::InvalidChecksum(text) => write!(formatter, "unexpected checksum response: {text}"),
Self::InvalidChecksum(text) => {
write!(formatter, "unexpected checksum response: {text}")
}
Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"),
Self::InstallerFailed { exit_code, output_tail } => {
write!(formatter, "NeoForge installer failed (exit {exit_code:?}):\n{output_tail}")
Self::InstallerFailed {
exit_code,
output_tail,
} => {
write!(
formatter,
"NeoForge installer failed (exit {exit_code:?}):\n{output_tail}"
)
}
}
}
@@ -89,7 +102,10 @@ impl From<io::Error> for NeoForgeError {
}
pub(crate) fn is_allowed_host(url: &str) -> bool {
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)).unwrap_or(false)
Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST))
.unwrap_or(false)
}
fn installer_jar_url(loader_version: &str) -> String {
@@ -99,18 +115,29 @@ fn installer_jar_url(loader_version: &str) -> String {
/// Downloads (or reuses a cached, still-valid) NeoForge installer jar,
/// verified against the `.sha256` sidecar Maven publishes next to every
/// artifact.
pub fn ensure_installer(client: &Client, cache_dir: &Path, loader_version: &str) -> Result<PathBuf, NeoForgeError> {
pub fn ensure_installer(
client: &Client,
cache_dir: &Path,
loader_version: &str,
) -> Result<PathBuf, NeoForgeError> {
let jar_url = installer_jar_url(loader_version);
let checksum_url = format!("{jar_url}.sha256");
if !is_allowed_host(&jar_url) {
return Err(NeoForgeError::DisallowedHost(jar_url));
}
let response = client.get(&checksum_url).send().map_err(NeoForgeError::Network)?;
let response = client
.get(&checksum_url)
.send()
.map_err(NeoForgeError::Network)?;
if !response.status().is_success() {
return Err(NeoForgeError::HttpStatus(response.status()));
}
let sha256 = response.text().map_err(NeoForgeError::Network)?.trim().to_ascii_lowercase();
let sha256 = response
.text()
.map_err(NeoForgeError::Network)?
.trim()
.to_ascii_lowercase();
if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(NeoForgeError::InvalidChecksum(sha256));
}
@@ -139,6 +166,23 @@ pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> Pat
.join(format!("neoforge-{loader_version}.json"))
}
fn patched_client_path(game_dir: &Path, loader_version: &str) -> PathBuf {
game_dir
.join("libraries/net/neoforged/neoforge")
.join(loader_version)
.join(format!("neoforge-{loader_version}-client.jar"))
}
fn is_nonempty_file(path: &Path) -> bool {
path.metadata()
.is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
}
fn installation_complete(game_dir: &Path, loader_version: &str) -> bool {
is_nonempty_file(&installed_version_json_path(game_dir, loader_version))
&& is_nonempty_file(&patched_client_path(game_dir, loader_version))
}
/// The installer jar bundles its own `install_profile.json`, which lists
/// exactly which libraries it will download and which processors it will
/// run to patch the client — the same manifest the installer itself reads.
@@ -161,7 +205,14 @@ fn read_install_profile_counts(installer_path: &Path) -> Option<(u64, u64)> {
/// installer logging a couple of extra non-library downloads) never exceeds
/// or exceeds `total` by much. `total_libraries` caps the download half so
/// those extra lines cannot crowd out the processor half of the bar.
fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_done: &AtomicU64, total_libraries: u64, total: u64, on_progress: &ProgressCallback) {
fn observe_installer_line(
line: &str,
downloads_done: &AtomicU64,
processors_done: &AtomicU64,
total_libraries: u64,
total: u64,
on_progress: &ProgressCallback,
) {
let trimmed = line.trim_start();
if trimmed.starts_with("Download completed") {
downloads_done.fetch_add(1, Ordering::Relaxed);
@@ -173,12 +224,19 @@ fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_don
} else {
return;
}
let current = downloads_done.load(Ordering::Relaxed).min(total_libraries) + processors_done.load(Ordering::Relaxed);
let current = downloads_done.load(Ordering::Relaxed).min(total_libraries)
+ processors_done.load(Ordering::Relaxed);
on_progress(current.min(total), total);
}
fn truncate_tail(text: &str) -> String {
text.chars().rev().take(4000).collect::<String>().chars().rev().collect()
text.chars()
.rev()
.take(4000)
.collect::<String>()
.chars()
.rev()
.collect()
}
/// Runs the installer with piped output, reporting live progress as its own
@@ -219,7 +277,14 @@ fn run_installer_with_progress(
let on_progress = Arc::clone(on_progress);
thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
observe_installer_line(&line, &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line(
&line,
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
let mut log = combined_log.lock().unwrap();
log.push_str(&line);
log.push('\n');
@@ -243,7 +308,10 @@ fn run_installer_with_progress(
let tail = truncate_tail(&combined_log.lock().unwrap());
if !status.success() {
return Err(NeoForgeError::InstallerFailed { exit_code: status.code(), output_tail: tail });
return Err(NeoForgeError::InstallerFailed {
exit_code: status.code(),
output_tail: tail,
});
}
Ok((status.code(), tail))
}
@@ -258,19 +326,48 @@ fn run_installer_with_progress(
/// progress (installer-confirmed library downloads plus patch-processor
/// steps, read from the installer's own `install_profile.json`) while it
/// runs; it fires once with `(1, 1)` when already installed.
pub fn ensure_client_installed(client: &Client, java_executable: &Path, game_dir: &Path, cache_dir: &Path, loader_version: &str, on_progress: &ProgressCallback) -> Result<VersionJson, NeoForgeError> {
pub fn ensure_client_installed(
client: &Client,
java_executable: &Path,
game_dir: &Path,
cache_dir: &Path,
loader_version: &str,
on_progress: &ProgressCallback,
) -> Result<VersionJson, NeoForgeError> {
let version_json_path = installed_version_json_path(game_dir, loader_version);
if !version_json_path.exists() {
if !installation_complete(game_dir, loader_version) {
ensure_launcher_profiles_stub(game_dir)?;
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
let (total_libraries, total_processors) = read_install_profile_counts(&installer_path).unwrap_or((0, 0));
// A leftover version JSON makes some installer versions treat the
// profile as already installed even when the patched client was
// deleted or quarantined. Remove only that generated marker so the
// official installer is forced to rebuild the incomplete profile.
match fs::remove_file(&version_json_path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(NeoForgeError::Io(error)),
}
let (total_libraries, total_processors) =
read_install_profile_counts(&installer_path).unwrap_or((0, 0));
let total = (total_libraries + total_processors).max(1);
on_progress(0, total);
let (exit_code, tail) = run_installer_with_progress(java_executable, &installer_path, game_dir, cache_dir, total_libraries, total, on_progress)?;
if !version_json_path.exists() {
return Err(NeoForgeError::InstallerFailed { exit_code, output_tail: tail });
let (exit_code, tail) = run_installer_with_progress(
java_executable,
&installer_path,
game_dir,
cache_dir,
total_libraries,
total,
on_progress,
)?;
if !installation_complete(game_dir, loader_version) {
return Err(NeoForgeError::InstallerFailed {
exit_code,
output_tail: tail,
});
}
on_progress(total, total);
} else {
@@ -296,12 +393,15 @@ mod tests {
#[test]
fn rejects_non_neoforge_hosts() {
assert!(!is_allowed_host("https://example.com/evil.jar"));
assert!(is_allowed_host("https://maven.neoforged.net/releases/x.jar"));
assert!(is_allowed_host(
"https://maven.neoforged.net/releases/x.jar"
));
}
#[test]
fn launcher_profiles_stub_is_idempotent() {
let dir = std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id()));
let dir =
std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id()));
ensure_launcher_profiles_stub(&dir).unwrap();
let first = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap();
fs::write(dir.join("launcher_profiles.json"), "custom-content").unwrap();
@@ -312,6 +412,25 @@ mod tests {
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn incomplete_install_is_not_accepted() {
let dir = std::env::temp_dir().join(format!(
"shacraft-neoforge-completeness-test-{}",
std::process::id()
));
let version = "21.1.248";
let json = installed_version_json_path(&dir, version);
fs::create_dir_all(json.parent().unwrap()).unwrap();
fs::write(&json, b"{}").unwrap();
assert!(!installation_complete(&dir, version));
let client = patched_client_path(&dir, version);
fs::create_dir_all(client.parent().unwrap()).unwrap();
fs::write(&client, b"patched").unwrap();
assert!(installation_complete(&dir, version));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn observe_installer_line_counts_downloads_and_processor_headers() {
let downloads_done = AtomicU64::new(0);
@@ -326,12 +445,47 @@ mod tests {
// A "Downloading library from ..." start line reports nothing by
// itself; only its "Download completed" confirmation counts.
observe_installer_line("Downloading library from https://example/a.jar", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line("Processor: net.neoforged.installertools:jarsplitter", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line(
"Downloading library from https://example/a.jar",
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
observe_installer_line(
"Download completed: Checksum validated.",
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
observe_installer_line(
"Download completed: Checksum validated.",
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
observe_installer_line(
"Processor: net.neoforged.installertools:jarsplitter",
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
// A processor's sub-step lines (three colons) must not double-count.
observe_installer_line("Processor: net.neoforged.installertools:jarsplitter: Loading patch files", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line(
"Processor: net.neoforged.installertools:jarsplitter: Loading patch files",
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
assert_eq!(*calls.lock().unwrap(), vec![(1, 3), (2, 3), (3, 3)]);
}
@@ -350,7 +504,8 @@ mod tests {
use crate::{java, mojang};
let client = Client::builder().build().unwrap();
let root = std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
let root =
std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
let game_dir = root.join("game");
let cache_dir = root.join("cache");
fs::create_dir_all(&cache_dir).unwrap();
@@ -360,7 +515,8 @@ mod tests {
let vanilla = mojang::fetch_version_json(&client, entry).unwrap();
let no_progress: ProgressCallback = Arc::new(|_, _| {});
let java_install = java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap();
let java_install =
java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap();
// The installer fetches and patches vanilla itself; we don't
// pre-download it. It only needs a Java runtime and an empty dir.
@@ -369,25 +525,65 @@ mod tests {
let progress_calls = Arc::clone(&progress_calls);
Arc::new(move |current, total| progress_calls.lock().unwrap().push((current, total)))
};
let neoforge_version = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &progress).unwrap();
let neoforge_version = ensure_client_installed(
&client,
Path::new(&java_install.executable),
&game_dir,
&cache_dir,
"21.1.248",
&progress,
)
.unwrap();
let merged = mojang::merge_versions(&vanilla, Some(&neoforge_version)).unwrap();
assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher");
assert!(merged.libraries.len() > 100, "expected vanilla (97) + neoforge (47) libraries, got {}", merged.libraries.len());
assert_eq!(
merged.main_class,
"cpw.mods.bootstraplauncher.BootstrapLauncher"
);
assert!(
merged.libraries.len() > 100,
"expected vanilla (97) + neoforge (47) libraries, got {}",
merged.libraries.len()
);
let patched_client = game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar");
assert!(patched_client.exists(), "FancyModLoader needs this at runtime even though it is not on the generic classpath");
let patched_client =
game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar");
assert!(
patched_client.exists(),
"FancyModLoader needs this at runtime even though it is not on the generic classpath"
);
let calls = progress_calls.lock().unwrap();
assert!(calls.len() > 5, "expected many incremental progress calls, got {}", calls.len());
assert!(
calls.len() > 5,
"expected many incremental progress calls, got {}",
calls.len()
);
let (last_current, last_total) = *calls.last().unwrap();
assert_eq!(last_current, last_total, "progress must reach 100% on success");
assert!(calls.windows(2).all(|pair| pair[0].0 <= pair[1].0), "reported progress must never go backwards");
assert_eq!(
last_current, last_total,
"progress must reach 100% on success"
);
assert!(
calls.windows(2).all(|pair| pair[0].0 <= pair[1].0),
"reported progress must never go backwards"
);
drop(calls);
// Re-running must skip straight to reading the cached version JSON
// rather than invoking the installer again.
let neoforge_again = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &no_progress).unwrap();
assert_eq!(neoforge_again.libraries.len(), neoforge_version.libraries.len());
let neoforge_again = ensure_client_installed(
&client,
Path::new(&java_install.executable),
&game_dir,
&cache_dir,
"21.1.248",
&no_progress,
)
.unwrap();
assert_eq!(
neoforge_again.libraries.len(),
neoforge_version.libraries.len()
);
fs::remove_dir_all(&root).ok();
}
+61 -17
View File
@@ -1,14 +1,17 @@
use crate::manifest::{self, Manifest};
use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use reqwest::header::ACCEPT_ENCODING;
use reqwest::{blocking::Client, redirect::Policy};
use serde::{Deserialize, Serialize};
use std::{fmt, time::Duration};
use std::{fmt, thread, time::Duration};
const AERONAUTICS_MANIFEST: &str =
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
const MAX_MANIFEST_SIZE: u64 = 2 * 1024 * 1024;
const MANIFEST_ATTEMPTS: u32 = 3;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -47,7 +50,9 @@ impl fmt::Display for RemoteError {
Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"),
Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"),
Self::InvalidSignature => formatter.write_str("ShaCraft manifest signature is invalid"),
Self::InvalidManifest(error) => write!(formatter, "ShaCraft manifest is invalid: {error}"),
Self::InvalidManifest(error) => {
write!(formatter, "ShaCraft manifest is invalid: {error}")
}
}
}
}
@@ -62,26 +67,63 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
.redirect(Policy::none())
.build()
.map_err(RemoteError::Network)?;
let response = client.get(url).send().map_err(RemoteError::Network)?;
if !response.status().is_success() {
return Err(RemoteError::Status(response.status()));
let mut last_network_error = None;
let mut source = None;
for attempt in 1..=MANIFEST_ATTEMPTS {
match client.get(url).header(ACCEPT_ENCODING, "identity").send() {
Ok(response) => {
if !response.status().is_success() {
return Err(RemoteError::Status(response.status()));
}
if response
.content_length()
.is_some_and(|size| size > MAX_MANIFEST_SIZE)
{
return Err(RemoteError::TooLarge);
}
match response.bytes() {
Ok(bytes) if bytes.len() as u64 <= MAX_MANIFEST_SIZE => {
source = Some(bytes);
break;
}
Ok(_) => return Err(RemoteError::TooLarge),
Err(error) => last_network_error = Some(error),
}
}
Err(error) => last_network_error = Some(error),
}
if attempt < MANIFEST_ATTEMPTS {
thread::sleep(Duration::from_millis(250 * attempt as u64));
}
}
if response.content_length().is_some_and(|size| size > 2 * 1024 * 1024) {
return Err(RemoteError::TooLarge);
}
let source = response.text().map_err(RemoteError::Network)?;
let envelope = serde_json::from_str::<SignedManifest>(&source)
let source = source.ok_or_else(|| {
RemoteError::Network(last_network_error.expect("a network attempt failed"))
})?;
let envelope = serde_json::from_slice::<SignedManifest>(&source)
.map_err(|_| RemoteError::InvalidSignature)?;
if envelope.schema_version != 1 || envelope.key_id != "2026-09-06" {
return Err(RemoteError::InvalidSignature);
}
let payload = STANDARD.decode(envelope.payload).map_err(|_| RemoteError::InvalidSignature)?;
let signature_bytes = STANDARD.decode(envelope.signature).map_err(|_| RemoteError::InvalidSignature)?;
let public_key_bytes = STANDARD.decode(MANIFEST_PUBLIC_KEY).expect("embedded public key must be valid");
let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().expect("embedded public key must be 32 bytes"))
let payload = STANDARD
.decode(envelope.payload)
.map_err(|_| RemoteError::InvalidSignature)?;
let signature_bytes = STANDARD
.decode(envelope.signature)
.map_err(|_| RemoteError::InvalidSignature)?;
let public_key_bytes = STANDARD
.decode(MANIFEST_PUBLIC_KEY)
.expect("embedded public key must be valid");
let signature = Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?;
public_key.verify(&payload, &signature).map_err(|_| RemoteError::InvalidSignature)?;
let public_key = VerifyingKey::from_bytes(
&public_key_bytes
.try_into()
.expect("embedded public key must be 32 bytes"),
)
.expect("embedded public key must be valid");
let signature =
Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?;
public_key
.verify(&payload, &signature)
.map_err(|_| RemoteError::InvalidSignature)?;
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)
}
@@ -100,5 +142,7 @@ pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError
if !response.status().is_success() {
return Err(RemoteError::Status(response.status()));
}
response.json::<ServerStatus>().map_err(RemoteError::Network)
response
.json::<ServerStatus>()
.map_err(RemoteError::Network)
}
+69 -14
View File
@@ -1,3 +1,4 @@
use crate::download;
use serde::{Deserialize, Serialize};
use std::{fmt, fs, io, path::Path};
@@ -38,7 +39,11 @@ fn default_nickname() -> String {
impl Default for LauncherSettings {
fn default() -> Self {
Self { memory_mb: DEFAULT_MEMORY_MB, nickname: DEFAULT_NICKNAME.into(), account_mode: AccountMode::Offline }
Self {
memory_mb: DEFAULT_MEMORY_MB,
nickname: DEFAULT_NICKNAME.into(),
account_mode: AccountMode::Offline,
}
}
}
@@ -55,8 +60,13 @@ impl fmt::Display for SettingsError {
match self {
Self::Io(error) => write!(formatter, "Cannot access launcher settings: {error}"),
Self::InvalidJson(error) => write!(formatter, "Cannot read launcher settings: {error}"),
Self::InvalidMemory => write!(formatter, "Memory allocation must be between 3 and 12 GiB"),
Self::InvalidNickname => write!(formatter, "Nickname must be 3-16 ASCII letters, numbers, or underscores"),
Self::InvalidMemory => {
write!(formatter, "Memory allocation must be between 3 and 12 GiB")
}
Self::InvalidNickname => write!(
formatter,
"Nickname must be 3-16 ASCII letters, numbers, or underscores"
),
}
}
}
@@ -65,7 +75,9 @@ pub fn load(data_dir: &Path) -> Result<LauncherSettings, SettingsError> {
let path = data_dir.join(SETTINGS_FILE);
let source = match fs::read_to_string(path) {
Ok(source) => source,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(LauncherSettings::default()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(LauncherSettings::default())
}
Err(error) => return Err(SettingsError::Io(error)),
};
let settings = serde_json::from_str(&source).map_err(SettingsError::InvalidJson)?;
@@ -73,7 +85,10 @@ pub fn load(data_dir: &Path) -> Result<LauncherSettings, SettingsError> {
Ok(settings)
}
pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result<LauncherSettings, SettingsError> {
pub fn save(
data_dir: &Path,
settings: LauncherSettings,
) -> Result<LauncherSettings, SettingsError> {
validate(&settings)?;
fs::create_dir_all(data_dir).map_err(SettingsError::Io)?;
@@ -81,15 +96,22 @@ pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result<LauncherSetti
let temporary = data_dir.join(".settings.json.shacraft.part");
let contents = serde_json::to_vec_pretty(&settings).expect("LauncherSettings is serializable");
fs::write(&temporary, contents).map_err(SettingsError::Io)?;
fs::rename(temporary, target).map_err(SettingsError::Io)?;
download::replace_file(&temporary, &target).map_err(SettingsError::Io)?;
Ok(settings)
}
fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb) || settings.memory_mb % 1024 != 0 {
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb)
|| settings.memory_mb % 1024 != 0
{
return Err(SettingsError::InvalidMemory);
}
if !(3..=16).contains(&settings.nickname.len()) || !settings.nickname.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
if !(3..=16).contains(&settings.nickname.len())
|| !settings
.nickname
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
{
return Err(SettingsError::InvalidNickname);
}
Ok(())
@@ -98,13 +120,19 @@ fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
#[cfg(test)]
mod tests {
use super::{load, save, AccountMode, LauncherSettings};
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
use std::{
fs, process,
time::{SystemTime, UNIX_EPOCH},
};
fn temporary_directory() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"shacraft-settings-test-{}-{}",
process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
@@ -116,9 +144,20 @@ mod tests {
assert_eq!(default.nickname, "Emil");
assert_eq!(default.account_mode, AccountMode::Offline);
let saved = save(&directory, LauncherSettings { memory_mb: 8 * 1024, nickname: "Emil".into(), account_mode: AccountMode::Microsoft }).unwrap();
let saved = save(
&directory,
LauncherSettings {
memory_mb: 8 * 1024,
nickname: "Emil".into(),
account_mode: AccountMode::Microsoft,
},
)
.unwrap();
assert_eq!(saved.memory_mb, 8 * 1024);
assert_eq!(load(&directory).unwrap().account_mode, AccountMode::Microsoft);
assert_eq!(
load(&directory).unwrap().account_mode,
AccountMode::Microsoft
);
fs::remove_dir_all(directory).unwrap();
}
@@ -126,8 +165,24 @@ mod tests {
#[test]
fn rejects_unsafe_memory_values() {
let directory = temporary_directory();
assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into(), account_mode: AccountMode::Offline }).is_err());
assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into(), account_mode: AccountMode::Offline }).is_err());
assert!(save(
&directory,
LauncherSettings {
memory_mb: 512,
nickname: "Emil".into(),
account_mode: AccountMode::Offline
}
)
.is_err());
assert!(save(
&directory,
LauncherSettings {
memory_mb: 6 * 1024,
nickname: "невалидный".into(),
account_mode: AccountMode::Offline
}
)
.is_err());
}
#[test]
+284
View File
@@ -0,0 +1,284 @@
//! ShaCraft local-account client.
//!
//! The API origin is fixed in the binary. Passwords are sent only over HTTPS
//! and are never persisted; only the random, revocable session token is kept.
use reqwest::blocking::{Client, Response};
use reqwest::redirect::Policy;
use serde::{Deserialize, Serialize};
use std::{fmt, fs, io, path::Path, time::Duration};
const API_ORIGIN: &str = "https://shacraft.ru";
const SESSION_FILE: &str = "shacraft-session";
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AccountLink {
pub server_id: String,
pub mc_username: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Account {
pub username: String,
pub links: Vec<AccountLink>,
}
#[derive(Deserialize)]
struct AuthResponse {
session_token: String,
account: Account,
recovery_codes: Vec<String>,
}
#[derive(Serialize)]
struct Credentials<'a> {
username: &'a str,
password: &'a str,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginResult {
pub account: Account,
pub recovery_codes: Vec<String>,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct LinkStart {
pub challenge_id: i64,
pub expires_in_seconds: u64,
pub registered_on_server: bool,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct LinkStatus {
pub status: String,
pub detail: Option<String>,
}
#[derive(Debug)]
pub enum AccountError {
Network(reqwest::Error),
Api(String),
Io(io::Error),
InvalidSession,
NoLinkedNickname,
}
impl fmt::Display for AccountError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(error) => write!(formatter, "Нет связи с аккаунтами ShaCraft: {error}"),
Self::Api(message) => formatter.write_str(message),
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"),
Self::NoLinkedNickname => {
formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics")
}
}
}
}
fn client() -> Result<Client, AccountError> {
Client::builder()
.timeout(Duration::from_secs(20))
.redirect(Policy::none())
.build()
.map_err(AccountError::Network)
}
fn api_error(response: Response) -> AccountError {
#[derive(Deserialize)]
struct ErrorBody {
detail: Option<String>,
}
let status = response.status();
let detail = response
.json::<ErrorBody>()
.ok()
.and_then(|body| body.detail);
AccountError::Api(detail.unwrap_or_else(|| format!("ShaCraft API: HTTP {status}")))
}
fn session_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(SESSION_FILE)
}
fn save_session(data_dir: &Path, token: &str) -> Result<(), AccountError> {
fs::create_dir_all(data_dir).map_err(AccountError::Io)?;
let path = session_path(data_dir);
let temporary = data_dir.join(".shacraft-session.part");
fs::write(&temporary, token.as_bytes()).map_err(AccountError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))
.map_err(AccountError::Io)?;
}
crate::download::replace_file(&temporary, &path).map_err(AccountError::Io)
}
fn load_session(data_dir: &Path) -> Result<String, AccountError> {
let token = fs::read_to_string(session_path(data_dir)).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
AccountError::InvalidSession
} else {
AccountError::Io(error)
}
})?;
let token = token.trim();
if token.len() < 32 || token.bytes().any(|byte| byte.is_ascii_whitespace()) {
return Err(AccountError::InvalidSession);
}
Ok(token.to_owned())
}
pub fn authenticate(
data_dir: &Path,
username: &str,
password: &str,
register: bool,
) -> Result<LoginResult, AccountError> {
let endpoint = if register {
"/api/launcher/auth/register"
} else {
"/api/launcher/auth/login"
};
let response = client()?
.post(format!("{API_ORIGIN}{endpoint}"))
.json(&Credentials { username, password })
.send()
.map_err(AccountError::Network)?;
if !response.status().is_success() {
return Err(api_error(response));
}
let payload = response
.json::<AuthResponse>()
.map_err(AccountError::Network)?;
save_session(data_dir, &payload.session_token)?;
Ok(LoginResult {
account: payload.account,
recovery_codes: payload.recovery_codes,
})
}
pub fn get_account(data_dir: &Path) -> Result<Account, AccountError> {
let token = load_session(data_dir)?;
let response = client()?
.get(format!("{API_ORIGIN}/api/launcher/account"))
.bearer_auth(token)
.send()
.map_err(AccountError::Network)?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
let _ = fs::remove_file(session_path(data_dir));
return Err(AccountError::InvalidSession);
}
if !response.status().is_success() {
return Err(api_error(response));
}
response.json::<Account>().map_err(AccountError::Network)
}
pub fn logout(data_dir: &Path) -> Result<(), AccountError> {
if let Ok(token) = load_session(data_dir) {
let _ = client()?
.post(format!("{API_ORIGIN}/api/launcher/auth/logout"))
.bearer_auth(token)
.json(&serde_json::json!({}))
.send();
}
match fs::remove_file(session_path(data_dir)) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(AccountError::Io(error)),
}
}
pub fn start_link(
data_dir: &Path,
server_id: &str,
nickname: &str,
) -> Result<LinkStart, AccountError> {
let token = load_session(data_dir)?;
let response = client()?
.post(format!("{API_ORIGIN}/api/account/link/start"))
.bearer_auth(token)
.json(&serde_json::json!({"server_id": server_id, "mc_username": nickname}))
.send()
.map_err(AccountError::Network)?;
if !response.status().is_success() {
return Err(api_error(response));
}
response.json::<LinkStart>().map_err(AccountError::Network)
}
pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, AccountError> {
let token = load_session(data_dir)?;
let response = client()?
.get(format!(
"{API_ORIGIN}/api/account/link/status/{challenge_id}"
))
.bearer_auth(token)
.send()
.map_err(AccountError::Network)?;
if !response.status().is_success() {
return Err(api_error(response));
}
response.json::<LinkStatus>().map_err(AccountError::Network)
}
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
get_account(data_dir)?
.links
.into_iter()
.find(|link| link.server_id == "aoc")
.map(|link| link.mc_username)
.ok_or(AccountError::NoLinkedNickname)
}
#[cfg(test)]
mod tests {
use super::{load_session, save_session, session_path};
use std::{
fs, process,
time::{SystemTime, UNIX_EPOCH},
};
fn temporary_directory() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"shacraft-account-test-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn session_round_trips_without_password_storage() {
let directory = temporary_directory();
let token = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
save_session(&directory, token).unwrap();
assert_eq!(load_session(&directory).unwrap(), token);
assert_eq!(fs::read_to_string(session_path(&directory)).unwrap(), token);
fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn session_is_private_on_unix() {
use std::os::unix::fs::PermissionsExt;
let directory = temporary_directory();
save_session(&directory, "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG").unwrap();
assert_eq!(
fs::metadata(session_path(&directory))
.unwrap()
.permissions()
.mode()
& 0o077,
0
);
fs::remove_dir_all(directory).unwrap();
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ShaCraft Launcher",
"version": "0.1.0",
"version": "0.1.1",
"identifier": "ru.shacraft.launcher",
"build": {
"beforeDevCommand": "npm run dev",
+121 -93
View File
@@ -63,21 +63,14 @@ type SyncResult = {
downloadedBytes: number
}
type MinecraftProfile = {
id: string
name: string
type ShaCraftAccount = {
username: string
links: { server_id: string; mc_username: string }[]
}
type DeviceCodePayload = {
verificationUri: string
userCode: string
expiresInSeconds: number
}
type LoginResultPayload = {
ok: boolean
profile?: MinecraftProfile
error?: string
type ShaCraftLoginResult = {
account: ShaCraftAccount
recoveryCodes: string[]
}
type ServerStatus = {
@@ -140,8 +133,13 @@ function App() {
const [syncError, setSyncError] = useState<string | null>(null)
// undefined = still checking for a saved session; null = signed out.
const [account, setAccount] = useState<MinecraftProfile | null | undefined>(undefined)
const [loginCode, setLoginCode] = useState<DeviceCodePayload | null>(null)
const [account, setAccount] = useState<ShaCraftAccount | null | undefined>(undefined)
const [accountUsername, setAccountUsername] = useState('')
const [accountPassword, setAccountPassword] = useState('')
const [registering, setRegistering] = useState(false)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const [linkNickname, setLinkNickname] = useState('')
const [linkMessage, setLinkMessage] = useState<string | null>(null)
const [loginError, setLoginError] = useState<string | null>(null)
const [loggingIn, setLoggingIn] = useState(false)
const [installing, setInstalling] = useState(false)
@@ -149,7 +147,7 @@ function App() {
const [installProgress, setInstallProgress] = useState<InstallProgressPayload | null>(null)
const [launchError, setLaunchError] = useState<string | null>(null)
const [serverStatus, setServerStatus] = useState<ServerStatus | null>(null)
const [microsoftLoginAvailable, setMicrosoftLoginAvailable] = useState(false)
const linkedNickname = account?.links.find((link) => link.server_id === 'aoc')?.mc_username
useEffect(() => {
if (progress === null) return
@@ -177,16 +175,13 @@ function App() {
invoke<JavaInstallation | null>('detect_java')
.then(setJava)
.catch(() => setJava(null))
invoke<boolean>('microsoft_login_available')
.then(setMicrosoftLoginAvailable)
.catch(() => setMicrosoftLoginAvailable(false))
invoke<ProfileInspection>('inspect_remote_profile', { profileId: 'aeronautics' })
.then((inspection) => {
setProfile(inspection)
setReady(inspection.upToDate)
})
.catch(() => undefined)
invoke<MinecraftProfile | null>('get_account')
invoke<ShaCraftAccount | null>('get_shacraft_account')
.then(setAccount)
.catch(() => setAccount(null))
}, [])
@@ -214,17 +209,6 @@ function App() {
useEffect(() => {
if (!isTauri()) return
const unlisten = [
listen<DeviceCodePayload>('msa-login-code', (event) => setLoginCode(event.payload)),
listen<LoginResultPayload>('msa-login-result', (event) => {
setLoggingIn(false)
setLoginCode(null)
if (event.payload.ok && event.payload.profile) {
setAccount(event.payload.profile)
setLoginError(null)
} else {
setLoginError(event.payload.error ?? 'Не удалось войти через Microsoft')
}
}),
listen<InstallProgressPayload>('game-install-progress', (event) => setInstallProgress(event.payload)),
listen<GameExitedPayload>('game-exited', (event) => {
setInstalling(false)
@@ -252,17 +236,6 @@ function App() {
saveSettings(memoryGb)
}
const saveNickname = () => {
if (/^[A-Za-z0-9_]{3,16}$/.test(nickname)) {
saveSettings(ram, nickname)
}
}
const setMode = (mode: 'microsoft' | 'offline') => {
setAccountMode(mode)
saveSettings(ram, nickname, mode)
}
const repair = async () => {
if (isTauri()) {
setSyncError(null)
@@ -284,36 +257,79 @@ function App() {
}
const startLogin = async () => {
if (!isTauri() || !microsoftLoginAvailable) {
setLoginError('Вход через Microsoft пока не настроен для этой версии лаунчера')
if (!isTauri()) return
setLoginError(null)
if (!/^[A-Za-z0-9_]{3,32}$/.test(accountUsername) || accountPassword.length < 3) {
setLoginError('Логин: 3–32 символа; пароль: минимум 3 символа')
return
}
setLoginError(null)
setLoggingIn(true)
try {
await invoke('start_microsoft_login')
const result = await invoke<ShaCraftLoginResult>('shacraft_authenticate', {
username: accountUsername,
password: accountPassword,
register: registering,
})
setAccount(result.account)
setAccountPassword('')
setRecoveryCodes(result.recoveryCodes)
setLoginError(null)
} catch (error) {
setLoginError(errorMessage(error, registering ? 'Не удалось зарегистрироваться' : 'Не удалось войти'))
} finally {
setLoggingIn(false)
setLoginError(errorMessage(error, 'Не удалось начать вход через Microsoft'))
}
}
const logout = async () => {
if (!isTauri()) return
await invoke('logout').catch(() => undefined)
await invoke('shacraft_logout').catch(() => undefined)
setAccount(null)
}
const startNicknameLink = async () => {
if (!/^[A-Za-z0-9_]{3,16}$/.test(linkNickname)) {
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
return
}
setLinkMessage('Создаём проверку…')
try {
const started = await invoke<{ challenge_id: number; registered_on_server: boolean }>('shacraft_start_link', { nickname: linkNickname })
setLinkMessage(started.registered_on_server
? 'Зайдите на Aeronautics с этим ником и выполните /login.'
: 'Зайдите на Aeronautics с этим ником и выполните /register.')
const timer = window.setInterval(async () => {
try {
const result = await invoke<{ status: string; detail?: string }>('shacraft_link_status', { challengeId: started.challenge_id })
if (result.status === 'verified') {
window.clearInterval(timer)
const refreshed = await invoke<ShaCraftAccount>('get_shacraft_account')
setAccount(refreshed)
setLinkMessage('Ник подтверждён.')
} else if (result.status === 'expired' || result.status === 'conflict') {
window.clearInterval(timer)
setLinkMessage(result.detail ?? 'Проверка завершилась. Попробуйте ещё раз.')
}
} catch (error) {
window.clearInterval(timer)
setLinkMessage(errorMessage(error, 'Не удалось проверить ник'))
}
}, 3000)
} catch (error) {
setLinkMessage(errorMessage(error, 'Не удалось начать привязку'))
}
}
const playOrLogin = async () => {
if (!isTauri()) return
if (accountMode === 'microsoft' && !microsoftLoginAvailable) {
setLaunchError('Вход Microsoft пока недоступен. Выберите Offline-аккаунт в настройках.')
if (!account) {
setSettingsOpen(true)
setLaunchError('Войдите в аккаунт ShaCraft, чтобы играть.')
return
}
// In offline mode we can launch without any Microsoft session. In
// Microsoft mode a signed-in account is still required first.
if (accountMode === 'microsoft' && (account === null || account === undefined)) {
await startLogin()
if (!linkedNickname) {
setSettingsOpen(true)
setLaunchError('Привяжите игровой ник к Aeronautics, чтобы играть.')
return
}
setLaunchError(null)
@@ -343,9 +359,9 @@ function App() {
}
const playLabel = () => {
if (accountMode === 'microsoft' && !microsoftLoginAvailable) return 'Microsoft недоступен'
if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…'
if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft'
if (account === undefined) return 'Загрузка…'
if (account === null) return 'Войти в ShaCraft'
if (!linkedNickname) return 'Привязать ник'
if (gameRunning) return 'Игра запущена'
if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}` : 'Подготовка…'
if (syncing || progress !== null) return 'Обновление'
@@ -412,10 +428,10 @@ function App() {
</div>
<button className="account-chip" onClick={() => setSettingsOpen(true)}>
<span className="avatar">{accountMode === 'offline' ? nickname.slice(0, 2).toUpperCase() : (account ? account.name.slice(0, 2).toUpperCase() : '?')}</span>
<span className="avatar">{linkedNickname ? linkedNickname.slice(0, 2).toUpperCase() : (account ? account.username.slice(0, 2).toUpperCase() : '?')}</span>
<span>
<strong>{accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)}</strong>
<small>{accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')}</small>
<strong>{account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : (linkedNickname ?? account.username)}</strong>
<small>{account ? `ShaCraft · ${account.username}` : 'Войдите, чтобы играть'}</small>
</span>
<ChevronRight size={16} />
</button>
@@ -471,7 +487,7 @@ function App() {
<>
<span className="state-icon"><ShieldCheck size={19} /></span>
<span>
<strong>{accountMode === 'microsoft' && !microsoftLoginAvailable ? 'Microsoft пока недоступен' : accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'}</strong>
<strong>{account === null ? 'Нужен вход ShaCraft' : !linkedNickname ? 'Нужно привязать ник' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'}</strong>
<small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов сборки` : 'Проверяем локальные файлы')}</small>
</span>
</>
@@ -490,7 +506,7 @@ function App() {
</button>
<button
className="play-button"
disabled={progress !== null || syncing || installing || gameRunning || (accountMode === 'microsoft' && (account === undefined || !microsoftLoginAvailable)) || loggingIn}
disabled={progress !== null || syncing || installing || gameRunning || account === undefined || loggingIn}
onClick={playOrLogin}
>
<Play size={21} fill="currentColor" />
@@ -500,13 +516,13 @@ function App() {
</main>
</div>
<div className={`drawer-backdrop ${loginCode ? 'visible' : ''}`} />
{loginCode && (
<div className={`drawer-backdrop ${recoveryCodes.length ? 'visible' : ''}`} />
{recoveryCodes.length > 0 && (
<div className="login-modal" role="dialog" aria-modal="true">
<h2>Вход через Microsoft</h2>
<p>Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.</p>
<div className="login-code">{loginCode.userCode}</div>
<p className="login-url">{loginCode.verificationUri}</p>
<h2>Коды восстановления</h2>
<p>Сохраните их сейчас. Каждый код можно использовать один раз для восстановления пароля.</p>
<div className="login-code" style={{ whiteSpace: 'pre-line', fontSize: '15px' }}>{recoveryCodes.join('\n')}</div>
<button className="setting-row" onClick={() => setRecoveryCodes([])}><span>Я сохранил коды</span></button>
</div>
)}
@@ -523,34 +539,46 @@ function App() {
</label>
<div className="setting-row static">
<span><Users />Аккаунт</span>
<small>{accountMode === 'offline' ? 'Offline' : (microsoftLoginAvailable ? (account ? account.name : 'Не авторизован') : 'Временно недоступен')}</small>
<small>{account ? account.username : 'Не авторизован'}</small>
</div>
{accountMode === 'offline' && (
<label className="text-setting">
<span><strong>Игровой ник</strong><small>Offline-профиль</small></span>
<input value={nickname} maxLength={16} onChange={(event) => setNickname(event.target.value)} onBlur={saveNickname} placeholder="Player" />
<small>Латинские буквы, цифры и _ · от 3 до 16 символов</small>
</label>
{!account && (
<>
<label className="text-setting">
<span><strong>Логин ShaCraft</strong><small>332 символа</small></span>
<input value={accountUsername} maxLength={32} autoComplete="username" onChange={(event) => setAccountUsername(event.target.value)} placeholder="Логин" />
</label>
<label className="text-setting">
<span><strong>Пароль</strong><small>Минимум 3 символа</small></span>
<input type="password" value={accountPassword} maxLength={128} autoComplete={registering ? 'new-password' : 'current-password'} onChange={(event) => setAccountPassword(event.target.value)} placeholder="Пароль" />
</label>
{loginError && <div className="drawer-note">{loginError}</div>}
<button className="setting-row" onClick={startLogin} disabled={loggingIn}>
<span>{loggingIn ? 'Подождите…' : registering ? 'Создать аккаунт' : 'Войти'}</span>
</button>
<button className="setting-row" onClick={() => { setRegistering(!registering); setLoginError(null) }}>
<span>{registering ? 'Уже есть аккаунт' : 'Нет аккаунта — регистрация'}</span>
</button>
</>
)}
<div className="setting-row">
<span>Тип аккаунта</span>
<select
value={accountMode}
onChange={(e) => setMode(e.target.value as 'microsoft' | 'offline')}
style={{ background: 'transparent', border: 0, color: 'inherit', textAlign: 'right' }}
>
<option value="offline">Offline</option>
<option value="microsoft" disabled={!microsoftLoginAvailable}>Microsoft (скоро)</option>
</select>
</div>
{accountMode === 'microsoft' && account && (
{account && !linkedNickname && (
<>
<label className="text-setting">
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
<input value={linkNickname} maxLength={16} onChange={(event) => setLinkNickname(event.target.value)} placeholder="Player" />
<small>Ник нельзя будет подменить локальной настройкой</small>
</label>
<button className="setting-row" onClick={startNicknameLink}><span>Привязать ник</span></button>
{linkMessage && <div className="drawer-note">{linkMessage}</div>}
</>
)}
{account && linkedNickname && (
<div className="setting-row static">
<span>Игровой ник</span><small>{linkedNickname}</small>
</div>
)}
{account && (
<button className="setting-row" onClick={logout}>
<span><LogOut />Выйти из Microsoft</span>
</button>
)}
{accountMode === 'microsoft' && !account && microsoftLoginAvailable && (
<button className="setting-row" onClick={startLogin}>
<span><LogOut />Войти через Microsoft</span>
<span><LogOut />Выйти из ShaCraft</span>
</button>
)}
<div className="setting-row static">