diff --git a/.github/workflows/injector.yml b/.github/workflows/injector.yml
new file mode 100644
index 0000000..863081a
--- /dev/null
+++ b/.github/workflows/injector.yml
@@ -0,0 +1,86 @@
+name: Injector
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - "injector/**"
+ - "src/**"
+ - ".github/workflows/injector.yml"
+
+jobs:
+ payload:
+ name: Payload jar
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ - name: Build remapped jar
+ run: |
+ chmod +x ./gradlew
+ ./gradlew remapJar --console=plain
+ - name: Upload jar
+ uses: actions/upload-artifact@v4
+ with:
+ name: payload-jar # also the Fabric mod: drop prestige-*.jar into mods/
+ path: |
+ build/libs/prestige-*.jar
+ !build/libs/*-dev.jar
+ !build/libs/*-sources.jar
+ if-no-files-found: error
+
+ injector:
+ name: Injector (${{ matrix.target }})
+ needs: payload
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ target: x86_64-pc-windows-msvc
+ name: windows-x86_64
+ files: |
+ injector/target/release/PrestigeInjector.exe
+ injector/target/release/prestige_native.dll
+ - os: ubuntu-latest
+ target: x86_64-unknown-linux-gnu
+ name: linux-x86_64
+ files: |
+ injector/target/release/PrestigeInjector
+ injector/target/release/libprestige_native.so
+ - os: macos-13
+ target: x86_64-apple-darwin
+ name: macos-x86_64
+ files: |
+ injector/target/release/PrestigeInjector
+ injector/target/release/libprestige_native.dylib
+ - os: macos-14
+ target: aarch64-apple-darwin
+ name: macos-aarch64
+ files: |
+ injector/target/release/PrestigeInjector
+ injector/target/release/libprestige_native.dylib
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+ - name: Download payload jar
+ uses: actions/download-artifact@v4
+ with:
+ name: payload-jar
+ path: build/libs
+ - name: Build
+ run: cargo build --release --manifest-path injector/Cargo.toml
+ - name: Test
+ run: cargo test --manifest-path injector/Cargo.toml
+ - name: Upload injector
+ uses: actions/upload-artifact@v4
+ with:
+ name: injector-${{ matrix.name }}
+ path: ${{ matrix.files }}
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index cad6e94..db63b88 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,4 @@ openvape.zip
/dist/
/injector/target/
+/tools
diff --git a/README.md b/README.md
index 7d04938..9e27125 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ A semi deobfuscated, IntelliJ-ready, runnable open sourced version of the Presti
Cracked by Skid Solutions.
-Join the Discord: https://discord.gg/skidsolutions
+Join the Discord: https://discord.gg/x2FJyGrDNg
## Why?
zPrestige couldnt give a fuck it seems about his client. AI slop all over, no time nor thought it seems goes into testing if things actually work. banned in 2 games with the most popular CAS config on mcpvp with countless modules flagging grim on actest.
diff --git a/build-injector.ps1 b/build-injector.ps1
index a9f4f89..3c251cd 100644
--- a/build-injector.ps1
+++ b/build-injector.ps1
@@ -10,4 +10,5 @@ cargo build --release --manifest-path injector\Cargo.toml
if ($LASTEXITCODE -ne 0) { throw 'cargo build failed' }
New-Item -ItemType Directory -Force dist | Out-Null
Copy-Item injector\target\release\PrestigeInjector.exe, injector\target\release\prestige_native.dll dist\ -Force
-Write-Host 'Done: dist\PrestigeInjector.exe + dist\prestige_native.dll'
+Get-ChildItem build\libs\prestige-*.jar | Where-Object { $_.Name -notmatch '-(dev|sources)\.jar$' } | Copy-Item -Destination dist\ -Force
+Write-Host 'Done: dist\PrestigeInjector.exe + dist\prestige_native.dll + dist\prestige-*.jar (Fabric mod)'
diff --git a/build-injector.sh b/build-injector.sh
new file mode 100644
index 0000000..cd99bdf
--- /dev/null
+++ b/build-injector.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+# Builds the Prestige payload jar (remapped for the production Fabric runtime), then the Rust injector and loader
+# library, and collects PrestigeInjector + libprestige_native.{so,dylib} in dist/.
+# Optional: set JAVA_HOME to pick the JDK used by Gradle.
+set -eu
+
+cd "$(dirname "$0")"
+
+case "$(uname -s)" in
+ Linux) LIB=libprestige_native.so ;;
+ Darwin) LIB=libprestige_native.dylib ;;
+ *) echo "unsupported OS: $(uname -s) (use build-injector.ps1 on Windows)" >&2; exit 1 ;;
+esac
+
+if [ -n "${JAVA_HOME:-}" ]; then
+ export JAVA_HOME
+ PATH="$JAVA_HOME/bin:$PATH"
+ export PATH
+fi
+
+./gradlew remapJar --console=plain
+cargo build --release --manifest-path injector/Cargo.toml
+
+mkdir -p dist
+cp -f "injector/target/release/PrestigeInjector" "injector/target/release/$LIB" dist/
+chmod +x dist/PrestigeInjector
+
+# the same remapped jar is the Fabric mod (drop it into mods/)
+for jar in build/libs/prestige-*.jar; do
+ case "$jar" in *-dev.jar|*-sources.jar) continue ;; esac
+ cp -f "$jar" dist/
+done
+
+echo "Done: dist/PrestigeInjector + dist/$LIB + dist/prestige-*.jar (Fabric mod)"
diff --git a/build-release.bat b/build-release.bat
new file mode 100644
index 0000000..8372f19
--- /dev/null
+++ b/build-release.bat
@@ -0,0 +1,86 @@
+@echo off
+rem Builds release zips for every platform into dist\release\ (run on Windows):
+rem prestige--windows-x86_64.zip PrestigeInjector.exe + prestige_native.dll + mod jar
+rem prestige--linux-x86_64.zip PrestigeInjector + libprestige_native.so + mod jar (glibc 2.17+, any distro)
+rem prestige--macos-universal.zip PrestigeInjector + libprestige_native.dylib + mod jar (Intel + Apple Silicon)
+rem prestige-.jar the Fabric mod on its own
+rem SHA256SUMS.txt
+rem Linux and macOS are cross-compiled with cargo-zigbuild + zig; both are installed on first run if missing.
+rem Requires: Rust (rustup), Python 3, JDK 21.
+setlocal EnableExtensions
+cd /d "%~dp0"
+set "SELF=%~f0"
+
+set "OUT=%CD%\dist\release"
+if exist "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot" set "JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
+
+for /f "tokens=1,* delims==" %%a in ('findstr /b /c:"mod_version=" gradle.properties') do set "VERSION=%%b"
+if not defined VERSION set "VERSION=dev"
+echo === Prestige %VERSION% release build
+
+rem ---- tools -------------------------------------------------------------
+where cargo >nul 2>&1 || (echo cargo not found: install Rust from https://rustup.rs & goto :fail)
+where python >nul 2>&1 || (echo python not found: install Python 3 from https://python.org & goto :fail)
+python -m ziglang version >nul 2>&1 || (
+ echo === Installing zig ^(pip ziglang^)
+ python -m pip install --user ziglang || goto :fail
+)
+where cargo-zigbuild >nul 2>&1 || (
+ echo === Installing cargo-zigbuild
+ cargo install --locked cargo-zigbuild || goto :fail
+)
+rustup target add x86_64-pc-windows-msvc x86_64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin || goto :fail
+
+rem zigbuild's linker wrappers break on paths with spaces, so keep its cache on the 8.3 short path
+if not exist "%LOCALAPPDATA%\cargo-zigbuild" mkdir "%LOCALAPPDATA%\cargo-zigbuild"
+for %%I in ("%LOCALAPPDATA%\cargo-zigbuild") do set "CARGO_ZIGBUILD_CACHE_DIR=%%~sI"
+
+rem ---- payload jar -------------------------------------------------------
+echo === Building payload jar
+call "%CD%\gradlew.bat" remapJar --console=plain || goto :fail
+set "JAR="
+for /f "delims=" %%j in ('dir /b /o-d "build\libs\prestige-*.jar" ^| findstr /v /i /c:"-dev.jar" /c:"-sources.jar"') do if not defined JAR set "JAR=%CD%\build\libs\%%j"
+if not defined JAR (echo no remapped jar in build\libs & goto :fail)
+rem every target embeds this exact jar
+set "PRESTIGE_PAYLOAD=%JAR%"
+
+if exist "%OUT%" rmdir /s /q "%OUT%"
+mkdir "%OUT%" || goto :fail
+
+rem ---- native builds -----------------------------------------------------
+echo === Windows x86_64
+cargo build --release --manifest-path injector\Cargo.toml --target x86_64-pc-windows-msvc || goto :fail
+call :package windows-x86_64 x86_64-pc-windows-msvc PrestigeInjector.exe prestige_native.dll || goto :fail
+
+echo === Linux x86_64 (glibc 2.17+)
+cargo zigbuild --release --manifest-path injector\Cargo.toml --target x86_64-unknown-linux-gnu.2.17 || goto :fail
+call :package linux-x86_64 x86_64-unknown-linux-gnu PrestigeInjector libprestige_native.so || goto :fail
+
+echo === macOS universal (x86_64 + arm64)
+cargo zigbuild --release --manifest-path injector\Cargo.toml --target universal2-apple-darwin || goto :fail
+call :package macos-universal universal2-apple-darwin PrestigeInjector libprestige_native.dylib || goto :fail
+
+copy /y "%JAR%" "%OUT%\" >nul || goto :fail
+python scripts\package_release.py checksums "%OUT%" || goto :fail
+
+echo.
+echo === Done: %OUT%
+dir /b "%OUT%"
+call :maybe_pause
+exit /b 0
+
+rem %1 platform name, %2 cargo target dir, %3 injector file, %4 library file
+:package
+python scripts\package_release.py zip "%OUT%" "%VERSION%" %1 "injector\target\%~2\release\%~3" "injector\target\%~2\release\%~4" "%JAR%"
+exit /b %ERRORLEVEL%
+
+:fail
+echo.
+echo *** Release build FAILED
+call :maybe_pause
+exit /b 1
+
+rem keep the window open when started by double-click
+:maybe_pause
+echo %cmdcmdline% | find /i "%SELF%" >nul && pause
+exit /b 0
diff --git a/build.gradle b/build.gradle
index 4087fc2..ba7f31e 100644
--- a/build.gradle
+++ b/build.gradle
@@ -52,11 +52,14 @@ dependencies {
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
// Dear ImGui. The binding contains the Java classes, lwjgl3 the GL3/GLFW backend and
- // natives-windows the imgui-java64.dll (extracted from the jar at runtime by imgui-java).
+ // natives-* the imgui-java64 libraries (extracted from the jar at runtime by imgui-java).
+ // Linux natives are x86_64 only; the macOS dylib is universal (x86_64 + arm64).
shade "io.github.spair:imgui-java-binding:${project.imgui_version}"
// imgui-java-lwjgl3 pulls LWJGL 3.4.1 transitively, which replaces Minecraft's 3.3.3 jars and breaks world saving
shade("io.github.spair:imgui-java-lwjgl3:${project.imgui_version}") { exclude group: 'org.lwjgl' }
shade "io.github.spair:imgui-java-natives-windows:${project.imgui_version}"
+ shade "io.github.spair:imgui-java-natives-linux:${project.imgui_version}"
+ shade "io.github.spair:imgui-java-natives-macos:${project.imgui_version}"
// ClassTransform (net.lenni0451, customised build); engine/EnginePlugin uses it to apply the client's @CTransformer hooks.
shade files('libs/classtransform-bundled.jar')
diff --git a/injector/README.md b/injector/README.md
index 80f5829..c85ba77 100644
--- a/injector/README.md
+++ b/injector/README.md
@@ -1,15 +1,34 @@
# Prestige injector (Rust)
-`PrestigeInjector.exe` loads `prestige_native.dll` into a running Minecraft **1.21.11 Fabric** JVM (64-bit, Fabric Loader 0.19.x,
-Fabric API installed). It uses the same overall method as the openvape bundle, written from scratch in Rust with no external crates.
+`PrestigeInjector` loads the native loader library (`prestige_native.dll` / `libprestige_native.so` / `libprestige_native.dylib`)
+into a running Minecraft **1.21.11 Fabric** JVM (64-bit, Fabric Loader 0.19.x, Fabric API installed). It uses the same overall
+method as the openvape bundle, written from scratch in Rust with no external crates.
+
+## Supported platforms
+
+| OS | Architectures | Injector binary | Loader library |
+|---------|--------------------|-----------------------|-----------------------------|
+| Windows | x86_64 | `PrestigeInjector.exe`| `prestige_native.dll` |
+| Linux | x86_64 | `PrestigeInjector` | `libprestige_native.so` |
+| macOS | x86_64, arm64 | `PrestigeInjector` | `libprestige_native.dylib` |
+
+The injector and library must be built for the same architecture as the target JVM.
## How it works
-1. **Injector** lists visible `java.exe`/`javaw.exe` windows (refreshed every 750 ms), then runs `LoadLibraryW` in the chosen
- process through `CreateRemoteThread` and confirms the module is mapped.
-2. **`prestige_native.dll`** (`DllMain` only spawns a worker thread):
- - waits for `jvm.dll`, `JNI_GetCreatedJavaVMs`, attaches, acquires the JVMTI redefine/retransform capabilities;
- - writes the embedded payload jar (the remapped `prestige-*.jar`) to `%TEMP%\PrestigeLoader`;
+1. **Injector** lists the running Fabric client JVMs, then loads the library into the chosen process.
+ - **Windows:** lists visible `java.exe`/`javaw.exe` windows (refreshed every 750 ms), then runs `LoadLibraryW` in the chosen
+ process through `CreateRemoteThread` and confirms the module is mapped.
+ - **Linux / macOS:** uses the HotSpot attach protocol (the same mechanism as `jcmd JVMTI.agent_load`). The injector
+ creates `.attach_pid` and sends `SIGQUIT` to the JVM, connects to its attach socket (`/tmp/.java_pid`, under
+ `$TMPDIR` on macOS) and asks it to load the library. The JVM `dlopen`s `libprestige_native` and calls its
+ `Agent_OnAttach` entry point. No ptrace, no root, no per-architecture shellcode.
+ Processes are found via `/proc` (Linux) or `ps` (macOS); there are no window titles, so the picker shows the pid and
+ instance name.
+2. **The native library** only spawns a worker thread from its entry point (`DllMain` on Windows, `Agent_OnAttach` on
+ unix, which also hands over the `JavaVM*`). The worker:
+ - waits for the JVM (Windows: `jvm.dll`, `JNI_GetCreatedJavaVMs`), attaches, acquires the JVMTI redefine/retransform capabilities;
+ - writes the embedded payload jar (the remapped `prestige-*.jar`) to the temp directory under `PrestigeLoader`;
- `FabricLauncherBase.getLauncher().addToClassPath(jar)` so payload and game classes share the Knot class loader;
- registers `NativeBridge.classBytes` / `NativeBridge.redefine` and calls `Bootstrap.start()`.
3. **`Bootstrap`** (Java) waits for the client window, then for every ClassTransform hook target gets the current class bytes
@@ -19,14 +38,59 @@ Fabric API installed). It uses the same overall method as the openvape bundle, w
helper classes, call sites become `invokestatic`, and private member access goes through `Access` MethodHandles.
Finally the Prestige and ClickGUI entrypoints run on the client thread.
-Log: `prestige-native.log` beside the DLL, plus `[Prestige-Inject]` lines in the game log.
-
## Build
+The payload jar is embedded with `include_bytes!`, so each target needs its own build. Both scripts below build for the host OS only and run
+`gradlew remapJar`, `cargo build --release`, and copy the results to `dist/`.
+
```powershell
-.\build-injector.ps1 # gradlew remapJar, cargo build --release, copies to dist\
+.\build-injector.ps1 # Windows: dist\PrestigeInjector.exe + dist\prestige_native.dll
```
+```sh
+./build-injector.sh # Linux/macOS: dist/PrestigeInjector + dist/libprestige_native.{so,dylib}
+```
+
+To build release zips for every platform from Windows, run `build-release.bat`. It cross-compiles Linux (x86_64,
+glibc 2.17+, so one build covers every mainstream distro) and macOS (universal x86_64 + arm64) with `cargo-zigbuild`, installing
+zig and cargo-zigbuild on first run, and writes `dist\release\prestige--{windows-x86_64,linux-x86_64,macos-universal}.zip`,
+the bare mod jar and `SHA256SUMS.txt`.
+
+Set `JAVA_HOME` beforehand to choose the JDK for Gradle (`build-injector.sh` honours it). The GitHub Actions workflow
+`.github/workflows/injector.yml` builds and tests all four targets (Windows x86_64, Linux x86_64, macOS x86_64, macOS arm64)
+and uploads them as artifacts.
+
## Use
-Start a Fabric 1.21.11 instance, then run `dist\PrestigeInjector.exe` (or `PrestigeInjector.exe [dll]`).
+Start a Fabric 1.21.11 instance, then run:
+
+```sh
+PrestigeInjector # interactive: pick the game from the list
+PrestigeInjector [lib path] # inject into a specific pid, optionally with an explicit library path
+```
+
+On Windows this is `PrestigeInjector.exe`. By default the library is looked up beside the injector.
+
+## Install as a mod instead
+
+The payload jar is also a normal Fabric mod. Put `prestige-*.jar` (from `dist/` or the CI `payload-jar` artifact) in the
+`mods/` folder of a Fabric Loader 0.19+ / Fabric API / Minecraft 1.21.11 instance; no injector is needed, on any OS.
+Do not use both: if the mod is already loaded, the injector's bootstrap logs "already loaded as a Fabric mod" and skips.
+
+## Requirements (Linux / macOS)
+
+- Run the injector as the **same user** as the game.
+- The attach mechanism must be enabled: the game must not be started with `-XX:+DisableAttachMechanism`.
+- The JVM architecture must match the injector build. On Apple Silicon use an arm64 JVM, or use the x86_64 build for JVMs
+ running under Rosetta.
+- Sandboxed launchers (Flatpak, Snap) hide the attach socket and are not supported.
+
+## Known limits
+
+- On Linux/macOS the library stays loaded after self-destruct: hooks are removed and natives unregistered, but the library is
+ not `dlclose`d (unmapping code the thread is still returning into would crash the JVM). On Windows the library is freed.
+- Linux and macOS support is compile-checked in CI; test on a real host before relying on it.
+
+## Log
+
+`prestige-native.log` is written beside the loader library, plus `[Prestige-Inject]` lines in the game log.
diff --git a/injector/crates/injector/src/main.rs b/injector/crates/injector/src/main.rs
index 2d8745a..8d0805f 100644
--- a/injector/crates/injector/src/main.rs
+++ b/injector/crates/injector/src/main.rs
@@ -1,10 +1,11 @@
-//! PrestigeInjector: lists the running Minecraft (javaw/java) processes and loads prestige_native.dll into the selected one
-//! with the classic remote LoadLibraryW thread. The DLL does everything else once it is inside the JVM; the injector tails
-//! the DLL's log so every stage (transforming, injecting, initialising) is visible.
+//! PrestigeInjector: lists the running Minecraft (javaw/java) processes and loads the native library
+//! (prestige_native.dll / libprestige_native.so / libprestige_native.dylib) into the selected one: a remote LoadLibraryW
+//! thread on Windows, the HotSpot attach protocol on Linux and macOS. The library does everything else once it is inside
+//! the JVM; the injector tails its log so every stage (transforming, injecting, initialising) is visible.
//!
-//! PrestigeInjector.exe interactive process picker (Up/Down, Enter, Esc)
-//! PrestigeInjector.exe [dll path] non-interactive
-mod win;
+//! PrestigeInjector interactive process picker (Up/Down, Enter, Esc)
+//! PrestigeInjector [lib path] non-interactive
+mod platform;
use std::fs;
use std::io::{self, Read, Seek, SeekFrom, Write};
@@ -12,9 +13,9 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::{Duration, Instant};
-const DLL_NAME: &str = "prestige_native.dll";
-const WATERMARK: &str = "Cracked by Skid Solutions";
-const INVITE_URL: &str = "https://discord.gg/M56rd2wwC5";
+/// The one credit line, shown once at the top of the screen.
+const CREDIT: &str = "Cracked by Skid Solutions - https://discord.gg/x2FJyGrDNg";
+const INVITE_URL: &str = "https://discord.gg/x2FJyGrDNg";
/// How long to follow the DLL's progress after it has been loaded.
const FOLLOW_TIMEOUT: Duration = Duration::from_secs(180);
@@ -28,16 +29,18 @@ const RED: &str = "\x1b[91m";
fn default_dll() -> PathBuf {
std::env::current_exe()
.ok()
- .and_then(|exe| exe.parent().map(|dir| dir.join(DLL_NAME)))
- .unwrap_or_else(|| PathBuf::from(DLL_NAME))
+ .and_then(|exe| exe.parent().map(|dir| dir.join(platform::LIB_NAME)))
+ .unwrap_or_else(|| PathBuf::from(platform::LIB_NAME))
}
-fn banner() {
- println!("{CYAN} ___ ___ ___ ___ _____ ___ ___ ___ ");
- println!(" | _ \\ _ \\ __/ __|_ _|_ _/ __| __|");
- println!(" | _/ / _|\\__ \\ | | | | (_ | _| ");
- println!(" |_| |_|_\\___|___/ |_| |___\\___|___|{RESET} injector");
- println!("{YELLOW} {WATERMARK}{RESET}\n");
+fn banner() -> String {
+ let mut out = String::new();
+ out.push_str(&format!("{CYAN} ___ ___ ___ ___ _____ ___ ___ ___ \n"));
+ out.push_str(" | _ \\ _ \\ __/ __|_ _|_ _/ __| __|\n");
+ out.push_str(" | _/ / _|\\__ \\ | | | | (_ | _| \n");
+ out.push_str(&format!(" |_| |_|_\\___|___/ |_| |___\\___|___|{RESET} injector\n"));
+ out.push_str(&format!("{YELLOW} {CREDIT}{RESET}\n\n"));
+ out
}
fn step(label: &str, message: &str) {
@@ -58,21 +61,26 @@ fn clock() -> String {
}
fn main() -> ExitCode {
- win::enable_ansi();
+ let code = real_main();
+ platform::restore_terminal();
+ code
+}
+
+fn real_main() -> ExitCode {
+ platform::enable_ansi();
let _ = clock();
let args: Vec = std::env::args().skip(1).collect();
let interactive = args.is_empty();
let (pid, dll): (Option, PathBuf) = match args.as_slice() {
[] => {
- banner();
- invite_prompt();
+ open_invite_once();
(pick_process(), default_dll())
}
[pid] => (parse_pid(pid), default_dll()),
[pid, dll] => (parse_pid(pid), PathBuf::from(dll)),
_ => {
- eprintln!("usage: PrestigeInjector.exe [ [dll path]]");
+ eprintln!("usage: PrestigeInjector [ [lib path]]");
return ExitCode::from(2);
}
};
@@ -81,7 +89,7 @@ fn main() -> ExitCode {
return if interactive { ExitCode::SUCCESS } else { ExitCode::from(2) };
};
if !interactive {
- banner();
+ print!("{}", banner());
}
let code = run(pid, &dll);
@@ -109,16 +117,18 @@ fn run(pid: u32, dll: &Path) -> ExitCode {
}
};
let dll_display = dll.display().to_string();
- step("loader", &dll_display.trim_start_matches(r"\\?\").to_string());
+ #[cfg(windows)]
+ let dll_display = dll_display.trim_start_matches(r"\\?\").to_string();
+ step("loader", &dll_display);
- // The DLL appends to prestige-native.log beside itself; remember where it ends so only this run's lines are shown.
+ // The library appends to prestige-native.log beside itself; remember where it ends so only this run's lines are shown.
let log_path = dll.with_file_name("prestige-native.log");
let log_start = fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
- step("checking", "64-bit JVM, process access, DLL not already loaded");
- step("injecting", "remote thread LoadLibraryW");
- match win::inject(pid, &dll) {
- Ok(()) => ok("injected", "prestige_native.dll is loaded in the game process"),
+ step("checking", "64-bit JVM, process access, library not already loaded");
+ step("injecting", "loading the native library into the JVM");
+ match platform::inject(pid, &dll) {
+ Ok(()) => ok("injected", &format!("{} is loaded in the game process", platform::LIB_NAME)),
Err(e) => {
fail("failed", &e);
return ExitCode::from(1);
@@ -130,7 +140,6 @@ fn run(pid: u32, dll: &Path) -> ExitCode {
Outcome::Done => {
println!();
ok("success", "successfully injected - Prestige is running in the game");
- println!("{YELLOW} {WATERMARK}{RESET} {DIM}{INVITE_URL}{RESET}");
ExitCode::SUCCESS
}
Outcome::Failed(line) => {
@@ -173,7 +182,9 @@ fn follow_log(path: &Path, start: u64) -> Outcome {
// lines look like "[unix seconds] message"
let message = line.split_once("] ").map(|(_, m)| m).unwrap_or(line);
let lower = message.to_ascii_lowercase();
- if lower.contains("failed") || lower.contains("not found") {
+ // Per-hook failures ("[java] hook failed for ...") are not fatal: Bootstrap keeps the hooks that applied.
+ let fatal = message.starts_with("FAILED:") || lower.contains("start failed") || lower.contains("client init failed") || lower.contains("could not hook");
+ if fatal {
fail("loader", message);
return Outcome::Failed(message.to_string());
}
@@ -189,51 +200,26 @@ fn follow_log(path: &Path, start: u64) -> Outcome {
}
fn config_path() -> Option {
- std::env::var_os("APPDATA").map(|dir| PathBuf::from(dir).join("PrestigeInjector").join("settings.txt"))
+ platform::config_dir().map(|dir| dir.join("PrestigeInjector").join("settings.txt"))
}
-/// First launch: open the Discord invite, then ask whether to open it again on later launches.
-fn invite_prompt() {
+/// Opens the Discord invite on the very first launch only (no prompt); afterwards only the credit line shows it.
+fn open_invite_once() {
let Some(path) = config_path() else { return };
- let saved = fs::read_to_string(&path).ok();
- let show = match saved.as_deref() {
- None => true, // first launch
- Some(text) => !text.lines().any(|l| l.trim() == "show_invite=false"),
- };
- if !show {
+ if path.exists() {
return;
}
- let first = saved.is_none();
- step("discord", &format!("opening {INVITE_URL}"));
- if !win::open_url(INVITE_URL) {
- fail("discord", &format!("could not open a browser, visit {INVITE_URL} manually"));
- }
- let again = ask_yes_no(if first {
- "Show the Discord invite again next time you start the injector? [Y/n] "
- } else {
- "Keep showing the Discord invite on launch? [Y/n] "
- });
+ let _ = platform::open_url(INVITE_URL);
if let Some(dir) = path.parent() {
let _ = fs::create_dir_all(dir);
}
- let _ = fs::write(&path, format!("show_invite={again}\n"));
- println!();
-}
-
-fn ask_yes_no(question: &str) -> bool {
- print!("{question}");
- let _ = io::stdout().flush();
- let mut line = String::new();
- if io::stdin().read_line(&mut line).is_err() {
- return true;
- }
- !matches!(line.trim().to_ascii_lowercase().as_str(), "n" | "no")
+ let _ = fs::write(&path, "invite_opened=true\n");
}
/// Interactive list of Minecraft JVM windows, refreshed every 750 ms. Returns the chosen pid.
fn pick_process() -> Option {
let mut selected = 0usize;
- let mut processes = win::java_windows();
+ let mut processes = platform::java_processes();
let mut last_refresh = Instant::now();
loop {
if selected >= processes.len() {
@@ -242,34 +228,34 @@ fn pick_process() -> Option {
draw(&processes, selected);
let deadline = Instant::now() + Duration::from_millis(50);
while Instant::now() < deadline {
- if let Some(key) = win::poll_key() {
+ if let Some(key) = platform::poll_key() {
match key {
- win::Key::Up => selected = selected.saturating_sub(1),
- win::Key::Down => selected = (selected + 1).min(processes.len().saturating_sub(1)),
- win::Key::Enter => {
+ platform::Key::Up => selected = selected.saturating_sub(1),
+ platform::Key::Down => selected = (selected + 1).min(processes.len().saturating_sub(1)),
+ platform::Key::Enter => {
if let Some(p) = processes.get(selected) {
return Some(p.pid);
}
}
- win::Key::Escape => return None,
+ platform::Key::Escape => return None,
}
draw(&processes, selected);
}
std::thread::sleep(Duration::from_millis(10));
}
if last_refresh.elapsed() >= Duration::from_millis(750) {
- processes = win::java_windows();
+ processes = platform::java_processes();
last_refresh = Instant::now();
}
}
}
-fn draw(processes: &[win::JavaWindow], selected: usize) {
+fn draw(processes: &[platform::JavaProcess], selected: usize) {
let mut out = String::from("\x1b[2J\x1b[H");
- out.push_str(&format!("{CYAN}Prestige injector{RESET} - Minecraft 1.21.11 (Fabric, 64-bit) {YELLOW}{WATERMARK}{RESET}\n"));
- out.push_str("Up/Down select, Enter inject, Esc quit\n\n");
+ out.push_str(&banner());
+ out.push_str("Minecraft 1.21.11 (Fabric, 64-bit) - Up/Down select, Enter inject, Esc quit\n\n");
if processes.is_empty() {
- out.push_str(" waiting for a javaw.exe / java.exe window...\n");
+ out.push_str(" waiting for a Minecraft JVM (java/javaw)...\n");
}
for (i, p) in processes.iter().enumerate() {
let marker = if i == selected { ">" } else { " " };
diff --git a/injector/crates/injector/src/platform/mod.rs b/injector/crates/injector/src/platform/mod.rs
new file mode 100644
index 0000000..fb7bd2c
--- /dev/null
+++ b/injector/crates/injector/src/platform/mod.rs
@@ -0,0 +1,61 @@
+//! Platform backends of the injector. Each backend exposes the same functions:
+//! enable_ansi, poll_key, java_processes, inject, open_url, restore_terminal.
+use std::path::PathBuf;
+
+/// A running Minecraft JVM shown in the picker.
+pub struct JavaProcess {
+ pub pid: u32,
+ pub title: String,
+ pub exe: String,
+}
+
+pub enum Key {
+ Up,
+ Down,
+ Enter,
+ Escape,
+}
+
+#[cfg(windows)]
+mod win;
+#[cfg(windows)]
+pub use win::*;
+
+#[cfg(unix)]
+mod unix_attach;
+#[cfg(unix)]
+mod unix_procs;
+#[cfg(unix)]
+mod unix_term;
+#[cfg(unix)]
+pub use unix_attach::inject;
+#[cfg(unix)]
+pub use unix_procs::java_processes;
+#[cfg(unix)]
+pub use unix_term::{enable_ansi, open_url, poll_key, restore_terminal};
+
+#[cfg(windows)]
+pub const LIB_NAME: &str = "prestige_native.dll";
+#[cfg(target_os = "macos")]
+pub const LIB_NAME: &str = "libprestige_native.dylib";
+#[cfg(all(unix, not(target_os = "macos")))]
+pub const LIB_NAME: &str = "libprestige_native.so";
+
+/// Per-user configuration directory: APPDATA, $XDG_CONFIG_HOME or ~/.config, ~/Library/Application Support.
+pub fn config_dir() -> Option {
+ #[cfg(windows)]
+ {
+ std::env::var_os("APPDATA").map(PathBuf::from)
+ }
+ #[cfg(target_os = "macos")]
+ {
+ std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library").join("Application Support"))
+ }
+ #[cfg(all(unix, not(target_os = "macos")))]
+ {
+ match std::env::var_os("XDG_CONFIG_HOME") {
+ Some(dir) if !dir.is_empty() => Some(PathBuf::from(dir)),
+ _ => std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")),
+ }
+ }
+}
diff --git a/injector/crates/injector/src/platform/unix_attach.rs b/injector/crates/injector/src/platform/unix_attach.rs
new file mode 100644
index 0000000..7541a00
--- /dev/null
+++ b/injector/crates/injector/src/platform/unix_attach.rs
@@ -0,0 +1,405 @@
+//! HotSpot attach-protocol injection for Linux and macOS (what `jcmd JVMTI.agent_load` does).
+//!
+//! 1. Create `.attach_pid` where the target JVM looks for it and send SIGQUIT.
+//! 2. Wait for the UNIX socket `.java_pid`.
+//! 3. Send `1\0load\0\0true\0\0` and read the status reply.
+//!
+//! The JVM then dlopens the library and calls `Agent_OnAttach`.
+
+#![allow(dead_code)]
+
+use std::fs;
+use std::io::{Read, Write};
+use std::os::unix::fs::MetadataExt;
+use std::os::unix::net::UnixStream;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::time::{Duration, Instant};
+
+extern "C" {
+ fn kill(pid: i32, sig: i32) -> i32;
+ fn geteuid() -> u32;
+}
+
+const SIGQUIT: i32 = 3;
+const SOCKET_WAIT: Duration = Duration::from_secs(10);
+const POLL_INTERVAL: Duration = Duration::from_millis(50);
+const REPLY_TIMEOUT: Duration = Duration::from_secs(30);
+
+const IS_MACOS: bool = cfg!(target_os = "macos");
+
+pub fn inject(pid: u32, lib: &Path) -> Result<(), String> {
+ let lib = fs::canonicalize(lib)
+ .map_err(|e| format!("library '{}' not found: {e}", lib.display()))?;
+ let lib_str = lib
+ .to_str()
+ .ok_or_else(|| "library path is not valid UTF-8".to_string())?;
+
+ preflight_owner(pid)?;
+ if let Some(msg) = arch_mismatch(pid) {
+ return Err(msg);
+ }
+
+ let sockets = socket_candidates(pid);
+ let existing = sockets.iter().find(|p| is_socket(p)).cloned();
+
+ let sock_path = match existing {
+ Some(p) => p,
+ None => trigger_attach(pid, &sockets)?,
+ };
+
+ send_load(&sock_path, lib_str)
+}
+
+// ---------------------------------------------------------------- preflight
+
+fn current_uid() -> u32 {
+ unsafe { geteuid() }
+}
+
+fn preflight_owner(pid: u32) -> Result<(), String> {
+ let uid = if IS_MACOS {
+ let out = Command::new("ps")
+ .args(["-o", "uid=", "-p", &pid.to_string()])
+ .output()
+ .map_err(|e| format!("failed to run ps: {e}"))?;
+ let text = String::from_utf8_lossy(&out.stdout);
+ let text = text.trim();
+ if text.is_empty() {
+ return Err(format!("process {pid} does not exist"));
+ }
+ text.parse::()
+ .map_err(|_| format!("could not parse owner of process {pid}: '{text}'"))?
+ } else {
+ match fs::metadata(format!("/proc/{pid}")) {
+ Ok(m) => m.uid(),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return Err(format!("process {pid} does not exist"))
+ }
+ Err(e) => return Err(format!("cannot inspect process {pid}: {e}")),
+ }
+ };
+ let me = current_uid();
+ if uid != me {
+ return Err(format!(
+ "process {pid} belongs to uid {uid} but the injector runs as uid {me}; \
+ the attach protocol only works for the same user (run the injector as the user that started the game)"
+ ));
+ }
+ Ok(())
+}
+
+fn host_arch() -> &'static str {
+ match std::env::consts::ARCH {
+ "aarch64" | "arm64" => "arm64",
+ other => other,
+ }
+}
+
+/// Some(message) only on a definite mismatch; None when matching or undeterminable.
+fn arch_mismatch(pid: u32) -> Option {
+ let target = target_arch(pid)?;
+ if target == host_arch() {
+ return None;
+ }
+ let hint = if IS_MACOS && target == "x86_64" && host_arch() == "arm64" {
+ " (Rosetta?)"
+ } else {
+ ""
+ };
+ Some(format!(
+ "target JVM is {target}{hint} but this injector is {}; use a matching JVM or a matching injector build",
+ host_arch()
+ ))
+}
+
+fn target_arch(pid: u32) -> Option {
+ if IS_MACOS {
+ let out = Command::new("ps")
+ .args(["-o", "comm=", "-p", &pid.to_string()])
+ .output()
+ .ok()?;
+ let comm = String::from_utf8_lossy(&out.stdout).trim().to_string();
+ if comm.is_empty() {
+ return None;
+ }
+ let head = read_head(Path::new(&comm), 64)?;
+ macho_arch(&head).map(str::to_string)
+ } else {
+ // Prefer the libjvm mapped into the process, fall back to the executable.
+ let mut candidates: Vec = Vec::new();
+ if let Ok(maps) = fs::read_to_string(format!("/proc/{pid}/maps")) {
+ for line in maps.lines() {
+ if line.contains("libjvm.so") {
+ if let Some(path) = line.split_whitespace().nth(5) {
+ candidates.push(PathBuf::from(path));
+ break;
+ }
+ }
+ }
+ }
+ candidates.push(PathBuf::from(format!("/proc/{pid}/exe")));
+ for c in candidates {
+ if let Some(head) = read_head(&c, 64) {
+ if let Some(a) = elf_arch(&head) {
+ return Some(a.to_string());
+ }
+ }
+ }
+ None
+ }
+}
+
+fn read_head(path: &Path, n: usize) -> Option> {
+ let mut f = fs::File::open(path).ok()?;
+ let mut buf = vec![0u8; n];
+ let mut got = 0;
+ while got < n {
+ match f.read(&mut buf[got..]) {
+ Ok(0) => break,
+ Ok(k) => got += k,
+ Err(_) => return None,
+ }
+ }
+ buf.truncate(got);
+ Some(buf)
+}
+
+/// Architecture name ("x86_64", "arm64", ...) from an ELF header, None if not ELF/unknown.
+fn elf_arch(h: &[u8]) -> Option<&'static str> {
+ if h.len() < 20 || &h[0..4] != b"\x7fELF" {
+ return None;
+ }
+ let machine = match h[5] {
+ 1 => u16::from_le_bytes([h[18], h[19]]),
+ 2 => u16::from_be_bytes([h[18], h[19]]),
+ _ => return None,
+ };
+ Some(match machine {
+ 3 => "x86",
+ 40 => "arm",
+ 62 => "x86_64",
+ 183 => "arm64",
+ 243 => "riscv",
+ 21 => "ppc64",
+ _ => return None,
+ })
+}
+
+/// Architecture from a thin 64-bit Mach-O header. Fat/universal binaries return None
+/// (the running slice cannot be determined from the file alone).
+fn macho_arch(h: &[u8]) -> Option<&'static str> {
+ if h.len() < 8 {
+ return None;
+ }
+ let magic = u32::from_le_bytes([h[0], h[1], h[2], h[3]]);
+ let cputype = match magic {
+ 0xfeed_facf => u32::from_le_bytes([h[4], h[5], h[6], h[7]]),
+ 0xcffa_edfe => u32::from_be_bytes([h[4], h[5], h[6], h[7]]),
+ _ => return None,
+ };
+ match cputype {
+ 0x0100_0007 => Some("x86_64"),
+ 0x0100_000c => Some("arm64"),
+ _ => None,
+ }
+}
+
+// ------------------------------------------------------------ attach trigger
+
+fn socket_candidates(pid: u32) -> Vec {
+ let name = format!(".java_pid{pid}");
+ let mut v = Vec::new();
+ if IS_MACOS {
+ if let Some(t) = std::env::var_os("TMPDIR") {
+ v.push(Path::new(&t).join(&name));
+ }
+ }
+ let tmp = Path::new("/tmp").join(&name);
+ if !v.contains(&tmp) {
+ v.push(tmp);
+ }
+ v
+}
+
+fn is_socket(p: &Path) -> bool {
+ use std::os::unix::fs::FileTypeExt;
+ fs::symlink_metadata(p)
+ .map(|m| m.file_type().is_socket())
+ .unwrap_or(false)
+}
+
+/// Removes the trigger files when dropped, on every path.
+struct TriggerFiles(Vec);
+
+impl Drop for TriggerFiles {
+ fn drop(&mut self) {
+ for p in &self.0 {
+ let _ = fs::remove_file(p);
+ }
+ }
+}
+
+fn trigger_candidates(pid: u32) -> Vec {
+ let name = format!(".attach_pid{pid}");
+ let mut v = Vec::new();
+ if !IS_MACOS {
+ if let Ok(cwd) = fs::read_link(format!("/proc/{pid}/cwd")) {
+ v.push(cwd.join(&name));
+ }
+ }
+ v.push(Path::new("/tmp").join(&name));
+ if IS_MACOS {
+ if let Some(t) = std::env::var_os("TMPDIR") {
+ let p = Path::new(&t).join(&name);
+ if !v.contains(&p) {
+ v.push(p);
+ }
+ }
+ }
+ v
+}
+
+fn trigger_attach(pid: u32, sockets: &[PathBuf]) -> Result {
+ let mut created = Vec::new();
+ for p in trigger_candidates(pid) {
+ if fs::File::create(&p).is_ok() {
+ created.push(p);
+ }
+ }
+ if created.is_empty() {
+ return Err(format!(
+ "could not create .attach_pid{pid} in the target's working directory or /tmp"
+ ));
+ }
+ let _guard = TriggerFiles(created);
+
+ if unsafe { kill(pid as i32, SIGQUIT) } != 0 {
+ return Err(format!(
+ "failed to send SIGQUIT to {pid}: {}",
+ std::io::Error::last_os_error()
+ ));
+ }
+
+ let start = Instant::now();
+ while start.elapsed() < SOCKET_WAIT {
+ if let Some(p) = sockets.iter().find(|p| is_socket(p)) {
+ return Ok(p.clone());
+ }
+ std::thread::sleep(POLL_INTERVAL);
+ }
+
+ if let Some(msg) = arch_mismatch(pid) {
+ return Err(format!("attach socket never appeared; {msg}"));
+ }
+ Err(format!(
+ "the JVM did not create its attach socket within {}s. Possible causes: the JVM was started with \
+ -XX:+DisableAttachMechanism or -Xrs, the game runs in a sandbox (Flatpak/Snap) with a private /tmp, \
+ or it belongs to a different user",
+ SOCKET_WAIT.as_secs()
+ ))
+}
+
+// ------------------------------------------------------------------ protocol
+
+/// `1\0load\0\0true\0\0`: version, command, then three NUL-terminated args
+/// (library path, "true" = absolute path, empty options).
+fn build_request(lib: &str) -> Vec {
+ let mut v = Vec::with_capacity(lib.len() + 16);
+ v.extend_from_slice(b"1\0load\0");
+ v.extend_from_slice(lib.as_bytes());
+ v.extend_from_slice(b"\0true\0\0");
+ v
+}
+
+/// First line is the integer status (0 = success); the rest is message text.
+fn parse_response(data: &[u8]) -> Result<(), String> {
+ let text = String::from_utf8_lossy(data);
+ let (first, rest) = match text.split_once('\n') {
+ Some((a, b)) => (a.trim(), b.trim()),
+ None => (text.trim(), ""),
+ };
+ if first.is_empty() {
+ return Err("JVM closed the attach connection without a reply".into());
+ }
+ match first.parse::() {
+ Ok(0) => Ok(()),
+ Ok(code) => {
+ if rest.is_empty() {
+ Err(format!("JVM rejected the load request (status {code})"))
+ } else {
+ Err(format!("JVM rejected the load request (status {code}): {rest}"))
+ }
+ }
+ Err(_) => Err(format!("unexpected reply from JVM: {}", text.trim())),
+ }
+}
+
+fn send_load(sock: &Path, lib: &str) -> Result<(), String> {
+ let mut s = UnixStream::connect(sock).map_err(|e| {
+ format!(
+ "could not connect to attach socket {}: {e} (stale socket, or the JVM runs as another user)",
+ sock.display()
+ )
+ })?;
+ let _ = s.set_read_timeout(Some(REPLY_TIMEOUT));
+ let _ = s.set_write_timeout(Some(REPLY_TIMEOUT));
+ s.write_all(&build_request(lib))
+ .map_err(|e| format!("failed to send attach request: {e}"))?;
+
+ let mut buf = Vec::new();
+ match s.read_to_end(&mut buf) {
+ Ok(_) => {}
+ // A partial reply is still parseable; a bare timeout with no data is not.
+ Err(e) if buf.is_empty() => return Err(format!("no reply from JVM: {e}")),
+ Err(_) => {}
+ }
+ parse_response(&buf)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn request_format() {
+ assert_eq!(
+ build_request("/opt/libprestige_native.so"),
+ b"1\0load\0/opt/libprestige_native.so\0true\0\0".to_vec()
+ );
+ }
+
+ #[test]
+ fn response_ok_and_errors() {
+ assert!(parse_response(b"0\n").is_ok());
+ assert!(parse_response(b"0").is_ok());
+ let e = parse_response(b"-1\nreturn code: 1\n").unwrap_err();
+ assert!(e.contains("-1") && e.contains("return code: 1"));
+ assert!(parse_response(b"").is_err());
+ assert!(parse_response(b"garbage").is_err());
+ }
+
+ #[test]
+ fn elf_headers() {
+ let mut h = vec![0u8; 64];
+ h[..4].copy_from_slice(b"\x7fELF");
+ h[5] = 1;
+ h[18..20].copy_from_slice(&62u16.to_le_bytes());
+ assert_eq!(elf_arch(&h), Some("x86_64"));
+ h[18..20].copy_from_slice(&183u16.to_le_bytes());
+ assert_eq!(elf_arch(&h), Some("arm64"));
+ assert_eq!(elf_arch(b"nope"), None);
+ }
+
+ #[test]
+ fn macho_headers() {
+ let mut h = vec![0xcf, 0xfa, 0xed, 0xfe];
+ h.extend_from_slice(&0x0100_000cu32.to_le_bytes());
+ assert_eq!(macho_arch(&h), Some("arm64"));
+ let mut h = vec![0xcf, 0xfa, 0xed, 0xfe];
+ h.extend_from_slice(&0x0100_0007u32.to_le_bytes());
+ assert_eq!(macho_arch(&h), Some("x86_64"));
+ // fat binary -> undeterminable
+ assert_eq!(macho_arch(&[0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 2]), None);
+ }
+}
diff --git a/injector/crates/injector/src/platform/unix_procs.rs b/injector/crates/injector/src/platform/unix_procs.rs
new file mode 100644
index 0000000..224584c
--- /dev/null
+++ b/injector/crates/injector/src/platform/unix_procs.rs
@@ -0,0 +1,158 @@
+//! Lists running Minecraft (Fabric) JVMs on Linux and macOS. std only.
+use super::JavaProcess;
+
+/// Last path component, accepting both separators.
+fn file_name(path: &str) -> &str {
+ path.trim_end_matches(['/', '\\']).rsplit(['/', '\\']).next().unwrap_or(path)
+}
+
+/// True when `argv0` names a java launcher and the arguments look like a Minecraft/Fabric JVM.
+pub(crate) fn is_minecraft_jvm(args: &[String]) -> bool {
+ let Some(argv0) = args.first() else { return false };
+ if !file_name(argv0).to_ascii_lowercase().contains("java") {
+ return false;
+ }
+ args.iter().any(|a| {
+ let a = a.to_ascii_lowercase();
+ a.contains("net.fabricmc.loader") || a.contains("knotclient") || a.contains("minecraft")
+ })
+}
+
+/// Friendly instance label: value of --version, else the last component of --gameDir, else "Minecraft".
+pub(crate) fn instance_label(args: &[String]) -> String {
+ fn value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
+ let prefix = format!("{flag}=");
+ let mut it = args.iter();
+ while let Some(a) = it.next() {
+ if a == flag {
+ return it.next().map(String::as_str).filter(|v| !v.is_empty());
+ }
+ if let Some(v) = a.strip_prefix(&prefix) {
+ if !v.is_empty() {
+ return Some(v);
+ }
+ }
+ }
+ None
+ }
+ if let Some(v) = value(args, "--version") {
+ return v.to_string();
+ }
+ if let Some(dir) = value(args, "--gameDir") {
+ let name = file_name(dir);
+ if !name.is_empty() {
+ return name.to_string();
+ }
+ }
+ "Minecraft".to_string()
+}
+
+fn build(pid: u32, args: &[String]) -> Option {
+ if !is_minecraft_jvm(args) {
+ return None;
+ }
+ Some(JavaProcess { pid, title: instance_label(args), exe: file_name(&args[0]).to_string() })
+}
+
+/// Parses one `ps -axo pid=,uid=,command=` line into (pid, uid, argv).
+/// ps joins argv with spaces, so argv[0] is taken as everything before the first " -" option.
+#[cfg_attr(target_os = "linux", allow(dead_code))]
+pub(crate) fn parse_ps_line(line: &str) -> Option<(u32, u32, Vec)> {
+ let line = line.trim_start();
+ let (pid, rest) = line.split_once(char::is_whitespace)?;
+ let rest = rest.trim_start();
+ let (uid, cmd) = rest.split_once(char::is_whitespace)?;
+ let cmd = cmd.trim();
+ let (argv0, tail) = match cmd.find(" -") {
+ Some(i) => (&cmd[..i], &cmd[i..]),
+ None => (cmd, ""),
+ };
+ let mut args = vec![argv0.to_string()];
+ args.extend(tail.split_whitespace().map(str::to_string));
+ Some((pid.parse().ok()?, uid.parse().ok()?, args))
+}
+
+#[cfg(target_os = "linux")]
+pub fn java_processes() -> Vec {
+ use std::os::unix::fs::MetadataExt;
+ let own_pid = std::process::id();
+ let own_uid = std::fs::metadata("/proc/self").map(|m| m.uid()).ok();
+ let mut out = Vec::new();
+ let Ok(dir) = std::fs::read_dir("/proc") else { return out };
+ for entry in dir.flatten() {
+ let Some(pid) = entry.file_name().to_str().and_then(|n| n.parse::().ok()) else { continue };
+ if pid == own_pid {
+ continue;
+ }
+ if let (Some(me), Ok(meta)) = (own_uid, entry.metadata()) {
+ if meta.uid() != me {
+ continue;
+ }
+ }
+ let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { continue };
+ let args: Vec = raw
+ .split(|&b| b == 0)
+ .filter(|s| !s.is_empty())
+ .map(|s| String::from_utf8_lossy(s).into_owned())
+ .collect();
+ if let Some(p) = build(pid, &args) {
+ out.push(p);
+ }
+ }
+ out.sort_by_key(|p| p.pid);
+ out
+}
+
+#[cfg(not(target_os = "linux"))]
+pub fn java_processes() -> Vec {
+ let own_pid = std::process::id();
+ let Ok(output) = std::process::Command::new("ps").args(["-axo", "pid=,uid=,command="]).output() else {
+ return Vec::new();
+ };
+ let text = String::from_utf8_lossy(&output.stdout);
+ let rows: Vec<_> = text.lines().filter_map(parse_ps_line).collect();
+ let own_uid = rows.iter().find(|r| r.0 == own_pid).map(|r| r.1);
+ let mut out: Vec = rows
+ .iter()
+ .filter(|(pid, uid, _)| *pid != own_pid && own_uid.map_or(true, |u| u == *uid))
+ .filter_map(|(pid, _, args)| build(*pid, args))
+ .collect();
+ out.sort_by_key(|p| p.pid);
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn v(s: &[&str]) -> Vec {
+ s.iter().map(|x| x.to_string()).collect()
+ }
+
+ #[test]
+ fn filters_minecraft_jvms() {
+ assert!(is_minecraft_jvm(&v(&["/usr/lib/jvm/java-21/bin/java", "-cp", "x", "net.fabricmc.loader.impl.launch.knot.KnotClient"])));
+ assert!(is_minecraft_jvm(&v(&["java", "-Dminecraft.launcher.brand=x", "Main"])));
+ assert!(!is_minecraft_jvm(&v(&["/usr/bin/java", "-jar", "gradle.jar"])));
+ assert!(!is_minecraft_jvm(&v(&["/usr/bin/bash", "-c", "minecraft"])));
+ assert!(!is_minecraft_jvm(&[]));
+ }
+
+ #[test]
+ fn labels() {
+ assert_eq!(instance_label(&v(&["java", "--version", "fabric-1.21.11"])), "fabric-1.21.11");
+ assert_eq!(instance_label(&v(&["java", "--version=1.21"])), "1.21");
+ assert_eq!(instance_label(&v(&["java", "--gameDir", "/home/u/.minecraft/instances/Pack/"])), "Pack");
+ assert_eq!(instance_label(&v(&["java", "KnotClient"])), "Minecraft");
+ }
+
+ #[test]
+ fn ps_lines() {
+ let (pid, uid, args) = parse_ps_line(" 123 501 /Users/a/Library/Application Support/x/bin/java -Xmx2G --version 1.21 net.fabricmc.loader").unwrap();
+ assert_eq!((pid, uid), (123, 501));
+ assert_eq!(args[0], "/Users/a/Library/Application Support/x/bin/java");
+ assert!(is_minecraft_jvm(&args));
+ assert_eq!(instance_label(&args), "1.21");
+ assert!(parse_ps_line("garbage").is_none());
+ }
+}
diff --git a/injector/crates/injector/src/platform/unix_term.rs b/injector/crates/injector/src/platform/unix_term.rs
new file mode 100644
index 0000000..e07154e
--- /dev/null
+++ b/injector/crates/injector/src/platform/unix_term.rs
@@ -0,0 +1,152 @@
+//! Terminal input and URL opening on Linux and macOS. std only (no termios structs).
+//!
+//! Raw mode is entered lazily on the first `poll_key` call via `stty -icanon -echo min 1 time 0`
+//! on /dev/tty (ISIG stays on, so Ctrl-C still raises SIGINT). A reader thread blocks on the tty
+//! and forwards bytes over a channel; `poll_key` parses them. The original settings (`stty -g`)
+//! are restored by `restore_terminal()` and, for Ctrl-C, by a SIGINT handler that only writes to
+//! a self-pipe (async-signal-safe); a helper thread then restores the terminal and exits 130.
+use super::Key;
+use std::fs::File;
+use std::io::Read;
+use std::process::{Command, Stdio};
+use std::sync::mpsc::{channel, Receiver, RecvTimeoutError};
+use std::sync::{Mutex, OnceLock};
+use std::time::Duration;
+
+extern "C" {
+ fn signal(signum: i32, handler: usize) -> usize;
+ fn pipe(fds: *mut i32) -> i32;
+ fn write(fd: i32, buf: *const u8, n: usize) -> isize;
+ fn read(fd: i32, buf: *mut u8, n: usize) -> isize;
+}
+const SIGINT: i32 = 2;
+
+static SAVED: Mutex