mirror of
https://git.ely.my.id/Tenshi/Hi3-Patcher
synced 2026-11-04 21:46:48 +01:00
330 lines
12 KiB
Rust
330 lines
12 KiB
Rust
//! Non-interactive command-line interface, intended for scripting and for
|
|
//! driving the patcher from another application.
|
|
//!
|
|
//! Contract: the process exit code is authoritative — `0` success, `1` a
|
|
//! patch/runtime error, `2` a usage error. The whole command line is
|
|
//! validated before any patch work starts. With `--json`, the final line of
|
|
//! stdout is a single JSON object (`{"ok":true,"command":...,"output":...}`
|
|
//! or `{"ok":false,"error":...}`), usage errors included; human progress is
|
|
//! printed before it.
|
|
use crate::patch::{
|
|
ace,
|
|
dispatch::{Target, android, windows},
|
|
hoyokprotect, protection, rsa,
|
|
};
|
|
use anyhow::Result;
|
|
use std::{collections::HashMap, path::PathBuf, process::ExitCode};
|
|
|
|
const USAGE: &str = "\
|
|
Honkai Impact 3rd patcher
|
|
|
|
Usage:
|
|
Hi3-Patcher Launch the interactive menu
|
|
Hi3-Patcher <command> [options]
|
|
|
|
Commands:
|
|
dispatch Redirect dispatch & SDK URLs to a server
|
|
--platform <windows|android> --input <path>
|
|
--server <host[:port]> [--https]
|
|
rsa Replace the passport RSA public key
|
|
--platform <windows|android> --input <path> [--key <base64>]
|
|
ace Remove ACE from a Windows client (<= 8.9); requires a capture
|
|
--input <BH3.exe|folder> [--yes]
|
|
hoyok Disable HoYoKProtect in a Windows client (9.0+)
|
|
--input <BH3.exe|folder>
|
|
protection Remove Android protection and re-sign the APK
|
|
--input <app.apk> [--variant <ano|tss|auto>]
|
|
|
|
Options:
|
|
--json Emit a single JSON result object as the last stdout line
|
|
-h, --help, -V, --version
|
|
|
|
Windows --input accepts the game folder or its BH3.exe.
|
|
rsa --key defaults to the PemukulPaku public key when omitted.";
|
|
|
|
/// Options each command accepts: `(value options, switches)`. Every command
|
|
/// also accepts `--json`.
|
|
fn accepted(command: &str) -> Option<(&'static [&'static str], &'static [&'static str])> {
|
|
Some(match command {
|
|
"dispatch" => (&["platform", "input", "server"], &["https"]),
|
|
"rsa" => (&["platform", "input", "key"], &[]),
|
|
"ace" => (&["input"], &["yes"]),
|
|
"hoyok" => (&["input"], &[]),
|
|
"protection" => (&["input", "variant"], &[]),
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
struct Parsed {
|
|
values: HashMap<String, String>,
|
|
switches: Vec<String>,
|
|
}
|
|
|
|
/// Split `--name value`, `--name=value` and `--switch` tokens, rejecting
|
|
/// anything the command does not accept. Errors are usage errors.
|
|
fn parse(command: &str, rest: &[String]) -> Result<Parsed, String> {
|
|
let (options, flags) =
|
|
accepted(command).ok_or_else(|| format!("unknown command: {command}"))?;
|
|
let mut values = HashMap::new();
|
|
let mut switches: Vec<String> = Vec::new();
|
|
let mut it = rest.iter();
|
|
while let Some(token) = it.next() {
|
|
let Some(name) = token.strip_prefix("--") else {
|
|
return Err(format!("unexpected argument: {token}"));
|
|
};
|
|
let (name, inline) = match name.split_once('=') {
|
|
Some((name, value)) => (name, Some(value)),
|
|
None => (name, None),
|
|
};
|
|
if name == "json" || flags.contains(&name) {
|
|
if inline.is_some() {
|
|
return Err(format!("--{name} does not take a value"));
|
|
}
|
|
if switches.iter().any(|s| s == name) {
|
|
return Err(format!("--{name} given more than once"));
|
|
}
|
|
switches.push(name.to_owned());
|
|
} else if options.contains(&name) {
|
|
let value = match inline {
|
|
Some(value) => value.to_owned(),
|
|
None => it
|
|
.next()
|
|
.filter(|value| !value.starts_with("--"))
|
|
.ok_or_else(|| format!("--{name} needs a value"))?
|
|
.clone(),
|
|
};
|
|
if values.insert(name.to_owned(), value).is_some() {
|
|
return Err(format!("--{name} given more than once"));
|
|
}
|
|
} else {
|
|
return Err(format!("{command} does not accept --{name}"));
|
|
}
|
|
}
|
|
Ok(Parsed { values, switches })
|
|
}
|
|
|
|
impl Parsed {
|
|
fn required(&self, name: &str) -> Result<&str, String> {
|
|
match self.values.get(name).map(String::as_str) {
|
|
None | Some("") => Err(format!("--{name} is required")),
|
|
Some(value) => Ok(value),
|
|
}
|
|
}
|
|
fn has(&self, name: &str) -> bool {
|
|
self.switches.iter().any(|s| s == name)
|
|
}
|
|
}
|
|
|
|
enum Platform {
|
|
Windows,
|
|
Android,
|
|
}
|
|
|
|
enum Variant {
|
|
Auto,
|
|
Ano,
|
|
Tss,
|
|
}
|
|
|
|
/// A fully validated command, ready to run.
|
|
enum Job {
|
|
Dispatch { platform: Platform, input: PathBuf, target: Target },
|
|
Rsa { platform: Platform, input: PathBuf, key: String },
|
|
Ace { input: PathBuf, yes: bool },
|
|
Hoyok { input: PathBuf },
|
|
Protection { input: PathBuf, variant: Variant },
|
|
}
|
|
|
|
fn parse_platform(args: &Parsed) -> Result<Platform, String> {
|
|
match args.required("platform")? {
|
|
"windows" | "win" => Ok(Platform::Windows),
|
|
"android" => Ok(Platform::Android),
|
|
other => Err(format!("unknown platform: {other}")),
|
|
}
|
|
}
|
|
|
|
/// Resolve `--input`; a Windows input may be the game folder or its BH3.exe.
|
|
fn input_path(args: &Parsed, platform: &Platform) -> Result<PathBuf, String> {
|
|
let path = PathBuf::from(args.required("input")?);
|
|
Ok(match platform {
|
|
Platform::Windows if path.is_dir() => path.join("BH3.exe"),
|
|
_ => path,
|
|
})
|
|
}
|
|
|
|
/// Check a parsed command line per command. Errors are usage errors.
|
|
fn job(command: &str, args: &Parsed) -> Result<Job, String> {
|
|
Ok(match command {
|
|
"dispatch" => {
|
|
let platform = parse_platform(args)?;
|
|
let input = input_path(args, &platform)?;
|
|
let target = Target::new(args.required("server")?, args.has("https"))
|
|
.map_err(|error| format!("--server: {error:#}"))?;
|
|
Job::Dispatch { platform, input, target }
|
|
}
|
|
"rsa" => {
|
|
let platform = parse_platform(args)?;
|
|
let input = input_path(args, &platform)?;
|
|
let key = args.values.get("key").cloned().unwrap_or_default();
|
|
rsa::public_body(&key).map_err(|error| format!("--key: {error:#}"))?;
|
|
Job::Rsa { platform, input, key }
|
|
}
|
|
"ace" => Job::Ace {
|
|
input: input_path(args, &Platform::Windows)?,
|
|
yes: args.has("yes"),
|
|
},
|
|
"hoyok" => Job::Hoyok {
|
|
input: input_path(args, &Platform::Windows)?,
|
|
},
|
|
"protection" => Job::Protection {
|
|
input: input_path(args, &Platform::Android)?,
|
|
variant: match args.values.get("variant").map(String::as_str) {
|
|
None | Some("auto") => Variant::Auto,
|
|
Some("ano") => Variant::Ano,
|
|
Some("tss") => Variant::Tss,
|
|
Some(other) => return Err(format!("unknown variant: {other}")),
|
|
},
|
|
},
|
|
other => return Err(format!("unknown command: {other}")),
|
|
})
|
|
}
|
|
|
|
/// Run a validated job. Returns the output path for Android patches.
|
|
fn execute(job: Job) -> Result<Option<PathBuf>> {
|
|
Ok(match job {
|
|
Job::Dispatch { platform: Platform::Windows, input, target } => {
|
|
windows::patch(&input, &target)?;
|
|
None
|
|
}
|
|
Job::Dispatch { platform: Platform::Android, input, target } => {
|
|
Some(android::patch(&input, &target)?)
|
|
}
|
|
Job::Rsa { platform: Platform::Windows, input, key } => {
|
|
rsa::patch_windows(&input, &key)?;
|
|
None
|
|
}
|
|
Job::Rsa { platform: Platform::Android, input, key } => {
|
|
Some(rsa::patch_android(&input, &key)?)
|
|
}
|
|
Job::Ace { input, yes } => {
|
|
ace::patch(&input, |_| Ok(yes))?;
|
|
None
|
|
}
|
|
Job::Hoyok { input } => {
|
|
hoyokprotect::patch(&input)?;
|
|
None
|
|
}
|
|
Job::Protection { input, variant } => Some(match variant {
|
|
Variant::Auto => protection::auto(&input)?,
|
|
Variant::Ano => protection::anosdk(&input)?,
|
|
Variant::Tss => protection::mobile_ace_tss(&input)?,
|
|
}),
|
|
})
|
|
}
|
|
|
|
fn report(command: &str, result: Result<Option<PathBuf>>, json: bool) -> ExitCode {
|
|
match result {
|
|
Ok(output) => {
|
|
let out = output.as_ref().map(|p| p.display().to_string());
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({"ok": true, "command": command, "output": out})
|
|
);
|
|
} else if let Some(path) = out {
|
|
println!("Output: {path}");
|
|
}
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(error) => {
|
|
let message = format!("{error:#}");
|
|
if json {
|
|
println!("{}", serde_json::json!({"ok": false, "error": message}));
|
|
} else {
|
|
eprintln!("error: {message}");
|
|
}
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
fn usage_error(message: &str, json: bool) -> ExitCode {
|
|
if json {
|
|
println!("{}", serde_json::json!({"ok": false, "error": message}));
|
|
} else {
|
|
eprintln!("error: {message}\n\n{USAGE}");
|
|
}
|
|
ExitCode::from(2)
|
|
}
|
|
|
|
/// Entry point for a non-empty argument list (the command and its options).
|
|
pub fn run(argv: &[String]) -> ExitCode {
|
|
let command = argv[0].as_str();
|
|
let rest = &argv[1..];
|
|
if matches!(command, "-h" | "--help" | "help")
|
|
|| rest.iter().any(|a| a == "-h" || a == "--help")
|
|
{
|
|
println!("{USAGE}");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
if matches!(command, "-V" | "--version" | "version") {
|
|
println!("Hi3-Patcher {}", env!("CARGO_PKG_VERSION"));
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
let json = rest.iter().any(|a| a == "--json");
|
|
match parse(command, rest).and_then(|args| job(command, &args)) {
|
|
Ok(job) => report(command, execute(job), json),
|
|
Err(message) => usage_error(&message, json),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Validate a command line the way `run` does, without executing it.
|
|
fn check(line: &[&str]) -> Result<Job, String> {
|
|
let argv: Vec<String> = line.iter().map(|s| s.to_string()).collect();
|
|
parse(&argv[0], &argv[1..]).and_then(|args| job(&argv[0], &args))
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_valid_command_lines() {
|
|
let cases: &[&[&str]] = &[
|
|
&["dispatch", "--platform", "windows", "--input", "x", "--server", "1.1.1.1"],
|
|
&["dispatch", "--platform=android", "--input=a.apk", "--server=h:3000", "--https", "--json"],
|
|
&["rsa", "--platform", "win", "--input", "x"],
|
|
&["rsa", "--platform", "windows", "--input", "x", "--key", ""],
|
|
&["ace", "--input", "x", "--yes"],
|
|
&["hoyok", "--input", "x"],
|
|
&["protection", "--input", "a.apk", "--variant", "tss"],
|
|
];
|
|
for line in cases {
|
|
assert!(check(line).is_ok(), "rejected: {line:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_usage_errors() {
|
|
let cases: &[&[&str]] = &[
|
|
&["bogus"],
|
|
&["dispatch", "--platform", "windows", "--input", "x"],
|
|
&["dispatch", "--platform", "plan9", "--input", "x", "--server", "h"],
|
|
&["dispatch", "--platform", "windows", "--input", "x", "--server", "h/p"],
|
|
&["dispatch", "stray"],
|
|
&["dispatch", "--input"],
|
|
&["dispatch", "--input", "--json"],
|
|
&["dispatch", "--platform", "windows", "--input", "x", "--server", "h", "--sever", "h"],
|
|
&["rsa", "--platform", "windows", "--input", "x", "--key", "abc"],
|
|
&["ace", "--input", "x", "--https"],
|
|
&["ace", "--input", "a", "--input", "b"],
|
|
&["ace", "--input", "x", "--yes=1"],
|
|
&["hoyok", "--input", ""],
|
|
&["protection", "--input", "a.apk", "--variant", "x"],
|
|
];
|
|
for line in cases {
|
|
assert!(check(line).is_err(), "accepted: {line:?}");
|
|
}
|
|
}
|
|
}
|