diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76b32c1f..d6a91a3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,14 +69,10 @@ jobs: if: matrix.arch == 'x86_64' run: | cargo test --package maa-cli --locked - - name: Test (maa-cli, no-default-features) - if: matrix.arch == 'x86_64' - run: | - cargo test --package maa-cli --no-default-features --locked - name: Install MaaCore if: matrix.arch == 'x86_64' env: - MAA_API_URL: https://github.com/MaaAssistantArknights/MaaRelease/raw/main/MaaAssistantArknights/api/version + MAA_CONFIG_DIR: ${{ github.workspace }}/config_examples run: | cargo run -- install beta -t0 - name: Show installation @@ -95,8 +91,8 @@ jobs: env: MAA_CONFIG_DIR: ${{ github.workspace }}/config_examples run: | - cargo run -- version - cargo run -- run daily --dry-run --batch + cargo run -- version -vv + cargo run -- run daily --dry-run --batch -vv - name: Run with MaaCore (relative path) if: matrix.arch == 'x86_64' timeout-minutes: 1 @@ -119,13 +115,47 @@ jobs: ls -l "$local_dir/bin" ls -l "$local_dir/lib" ls -l "$share_dir" - $bin_dir/$MAA_EXE version - $bin_dir/$MAA_EXE run daily --dry-run --batch + $bin_dir/$MAA_EXE version -vv + $bin_dir/$MAA_EXE run daily --dry-run --batch -vv - name: Cat MaaCore Log if: matrix.arch == 'x86_64' run: | cat "$(cargo run -- dir log)/asst.log" + features: + name: Build with no default features + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + features: + - --features core_installer + - --features cli_installer + - "" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Rust + uses: ./.github/actions/setup + with: + os: ${{ matrix.os }} + arch: x86_64 + - name: Setup Cache + uses: Swatinem/rust-cache@v2 + with: + key: feature-${{ matrix.feature }} + - name: Build (maa-cli) + run: | + cargo build --package maa-cli --no-default-features ${{ matrix.features }} --locked + - name: Test (maa-cli) + run: | + cargo test --package maa-cli --no-default-features ${{ matrix.features }} --locked + coverage: name: Coverage needs: build diff --git a/config_examples/cli.toml b/config_examples/cli.toml new file mode 100644 index 00000000..f5a1ff60 --- /dev/null +++ b/config_examples/cli.toml @@ -0,0 +1,15 @@ +[core] +channel = "Beta" +test_time = 0 +api_url = "https://github.com/MaaAssistantArknights/MaaRelease/raw/main/MaaAssistantArknights/api/version/" +[core.components] +library = true +resource = true + +[cli] +channel = "Alpha" +# the double v in @vversion is necessary instead of a typo +api_url = "https://cdn.jsdelivr.net/gh/MaaAssistantArknights/maa-cli@vversion/" +download_url = "https://github.com/MaaAssistantArknights/maa-cli/releases/download/" +[cli.components] +binary = false diff --git a/maa-cli/Cargo.toml b/maa-cli/Cargo.toml index 16edf01d..85ed46ec 100644 --- a/maa-cli/Cargo.toml +++ b/maa-cli/Cargo.toml @@ -9,8 +9,10 @@ repository.workspace = true license.workspace = true [features] -default = ["self"] -self = [] +default = ["cli_installer", "core_installer"] +core_installer = ["extract_helper"] +cli_installer = ["extract_helper"] +extract_helper = ["flate2", "tar", "zip"] [[bin]] name = "maa" @@ -18,32 +20,50 @@ path = "src/main.rs" [dependencies] maa-sys = { path = "../maa-sys", features = ["runtime"] } + directories = "5" +paste = "1" anyhow = "1" +signal-hook = "0.3.17" +dunce = "1.0.4" + clap = { version = "4.4", features = ["derive"] } -paste = "1" +clap_complete = { version = "4.4" } + +toml = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" -toml = "0.8" serde_yaml = "0.9.25" -indicatif = "0.17.7" -tokio = { version = "1.31", default-features = false, features = ["rt"] } -futures-util = "0.3.28" -flate2 = "1" -tar = "0.4.40" -zip = { version = "0.6.6", default-features = false, features = ["deflate"] } + +# Dependencies used to donwload files +indicatif = { version = "0.17.7" } +futures-util = { version = "0.3.28" } +sha2 = { version = "0.10.7" } +digest = { version = "0.10.7" } semver = { version = "1.0.19", features = ["serde"] } -sha2 = "0.10.7" -digest = "0.10.7" -signal-hook = "0.3.17" -clap_complete = { version = "4.4" } -dunce = "1.0.4" + +# Dependencies used to extract files +flate2 = { version = "1", optional = true } +tar = { version = "0.4.40", optional = true } [dependencies.chrono] version = "0.4.31" default-features = false features = ["std", "clock", "serde"] +# Dependencies used to extract files +[dependencies.zip] +version = "0.6.6" +optional = true +default-features = false +features = ["deflate"] + +# Dependencies used to download files +[dependencies.tokio] +version = "1.31" +default-features = false +features = ["rt"] + [dependencies.reqwest] version = "0.11" default-features = false diff --git a/maa-cli/src/config/cli.rs b/maa-cli/src/config/cli.rs deleted file mode 100644 index 758f2902..00000000 --- a/maa-cli/src/config/cli.rs +++ /dev/null @@ -1,151 +0,0 @@ -use crate::installer::maa_core::Channel; - -use serde::Deserialize; - -/// Configuration for the CLI -#[cfg_attr(test, derive(Debug, PartialEq))] -#[derive(Deserialize, Default)] -pub struct CLIConfig { - /// DEPRECATED: Remove in the next breaking change - #[serde(default)] - pub channel: Option, - /// MaaCore configuration - #[serde(default)] - pub core: CoreConfig, -} - -#[cfg_attr(test, derive(Debug, PartialEq))] -#[derive(Deserialize, Default)] -pub struct CoreConfig { - #[serde(default)] - channel: Channel, - #[serde(default)] - components: CoreComponents, -} - -#[cfg_attr(test, derive(Debug, PartialEq))] -#[derive(Deserialize)] -pub struct CoreComponents { - #[serde(default = "return_true")] - resource: bool, -} - -fn return_true() -> bool { - true -} - -impl Default for CoreComponents { - fn default() -> Self { - CoreComponents { - resource: return_true(), - } - } -} - -impl super::FromFile for CLIConfig {} - -impl CLIConfig { - pub fn channel(&self) -> Channel { - if let Some(channel) = self.channel { - println!( - "\x1b[33mWARNING\x1b[0m: \ - The `channel` field in the CLI configuration is deprecated \ - and will be removed in the next breaking change. \ - Please use the `core.channel` field instead." - ); - channel - } else { - println!( - "OK: Using `core.channel` field in the CLI configuration: {}", - self.core.channel - ); - self.core.channel - } - } - - pub fn resource(&self) -> bool { - self.core.components.resource - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn deserialize_example() { - let config: CLIConfig = toml::from_str( - r#" - [core] - channel = "beta" - [core.components] - resource = false - "#, - ) - .unwrap(); - assert_eq!( - config, - CLIConfig { - channel: None, - core: CoreConfig { - channel: Channel::Beta, - components: CoreComponents { resource: false } - } - } - ); - - let config: CLIConfig = toml::from_str( - r#" - [core] - channel = "beta" - "#, - ) - .unwrap(); - assert_eq!( - config, - CLIConfig { - channel: None, - core: CoreConfig { - channel: Channel::Beta, - components: CoreComponents { resource: true } - } - } - ); - } - - #[test] - fn deserialize_default() { - let config: CLIConfig = toml::from_str("").unwrap(); - assert_eq!( - config, - CLIConfig { - channel: None, - core: CoreConfig { - channel: Channel::Stable, - components: CoreComponents { resource: true } - } - } - ); - } - - #[test] - fn get_channel() { - let config = CLIConfig { - channel: Some(Channel::Beta), - core: CoreConfig { - channel: Channel::Stable, - components: CoreComponents { resource: true }, - }, - }; - assert_eq!(config.channel(), Channel::Beta); - - let config = CLIConfig { - channel: None, - core: CoreConfig { - channel: Channel::Stable, - components: CoreComponents { resource: true }, - }, - }; - assert_eq!(config.channel(), Channel::Stable); - } -} diff --git a/maa-cli/src/config/cli/maa_cli.rs b/maa-cli/src/config/cli/maa_cli.rs new file mode 100644 index 00000000..2b609551 --- /dev/null +++ b/maa-cli/src/config/cli/maa_cli.rs @@ -0,0 +1,234 @@ +use super::{normalize_url, return_true, Channel}; + +use std::env::var_os; + +use serde::Deserialize; + +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(Deserialize, Clone)] +pub struct Config { + #[serde(default)] + channel: Channel, + #[serde(default = "default_api_url")] + api_url: String, + #[serde(default = "default_download_url")] + download_url: String, + #[serde(default)] + components: CLIComponents, +} + +impl Default for Config { + fn default() -> Self { + Self { + channel: Default::default(), + api_url: default_api_url(), + download_url: default_download_url(), + components: Default::default(), + } + } +} + +impl Config { + pub fn channel(&self) -> Channel { + self.channel + } + + pub fn set_channel(&mut self, channel: Channel) -> &Self { + self.channel = channel; + self + } + + pub fn api_url(&self) -> String { + format!("{}{}.json", normalize_url(&self.api_url), self.channel()) + } + + pub fn set_api_url(&mut self, api_url: impl ToString) -> &Self { + self.api_url = api_url.to_string(); + self + } + + pub fn download_url(&self, tag: &str, name: &str) -> String { + format!("{}{}/{}", normalize_url(&self.download_url), tag, name) + } + + pub fn set_download_url(&mut self, download_url: impl ToString) -> &Self { + self.download_url = download_url.to_string(); + self + } + + pub fn components(&self) -> &CLIComponents { + &self.components + } +} + +fn default_api_url() -> String { + if let Some(url) = var_os("MAA_CLI_API") { + url.into_string().unwrap() + } else { + "https://github.com/MaaAssistantArknights/maa-cli/raw/version/".to_owned() + } +} + +fn default_download_url() -> String { + if let Some(url) = var_os("MAA_CLI_DOWNLOAD") { + url.into_string().unwrap() + } else { + "https://github.com/MaaAssistantArknights/maa-cli/releases/download/".to_owned() + } +} + +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(Deserialize, Default, Clone)] +pub struct CLIComponents { + #[serde(default = "return_true")] + pub binary: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + impl Config { + pub fn with_channel(mut self, channel: Channel) -> Self { + self.channel = channel; + self + } + + pub fn with_api_url(mut self, api_url: impl ToString) -> Self { + self.api_url = api_url.to_string(); + self + } + + pub fn with_download_url(mut self, download_url: impl ToString) -> Self { + self.download_url = download_url.to_string(); + self + } + } + + mod serde { + use super::*; + + use serde_test::{assert_de_tokens, Token}; + + #[test] + fn deserialize_cli_components() { + assert_de_tokens( + &CLIComponents { binary: true }, + &[Token::Map { len: Some(0) }, Token::MapEnd], + ); + assert_de_tokens( + &CLIComponents { binary: false }, + &[ + Token::Map { len: Some(1) }, + Token::Str("binary"), + Token::Bool(false), + Token::MapEnd, + ], + ); + } + + #[test] + fn deserialize_config() { + assert_de_tokens( + &Config { + channel: Channel::Alpha, + api_url: "https://foo.bar/api/".to_owned(), + download_url: "https://foo.bar/download/".to_owned(), + components: CLIComponents { binary: false }, + }, + &[ + Token::Map { len: Some(4) }, + Token::Str("channel"), + Channel::Alpha.as_token(), + Token::Str("api_url"), + Token::Str("https://foo.bar/api/"), + Token::Str("download_url"), + Token::Str("https://foo.bar/download/"), + Token::Str("components"), + Token::Map { len: Some(1) }, + Token::Str("binary"), + Token::Bool(false), + Token::MapEnd, + Token::MapEnd, + ], + ); + + assert_de_tokens( + &Config::default(), + &[Token::Map { len: Some(0) }, Token::MapEnd], + ); + } + } + + mod default { + use super::*; + + use std::env::{remove_var, set_var}; + + #[test] + fn api_url() { + assert_eq!( + default_api_url(), + "https://github.com/MaaAssistantArknights/maa-cli/raw/version/" + ); + + set_var("MAA_CLI_API", "https://foo.bar/cli/"); + assert_eq!(default_api_url(), "https://foo.bar/cli/"); + remove_var("MAA_CLI_API"); + } + + #[test] + fn download_url() { + assert_eq!( + default_download_url(), + "https://github.com/MaaAssistantArknights/maa-cli/releases/download/", + ); + + set_var("MAA_CLI_DOWNLOAD", "https://foo.bar/download/"); + assert_eq!(default_download_url(), "https://foo.bar/download/"); + remove_var("MAA_CLI_DOWNLOAD"); + } + } + + mod methods { + use super::*; + + #[test] + fn channel() { + assert_eq!(Config::default().channel(), Default::default()); + assert_eq!( + Config::default().set_channel(Channel::Alpha).channel(), + Channel::Alpha, + ); + } + + #[test] + fn api_url() { + assert_eq!( + Config::default().api_url(), + "https://github.com/MaaAssistantArknights/maa-cli/raw/version/stable.json", + ); + assert_eq!( + Config::default() + .with_channel(Channel::Alpha) + .with_api_url("https://foo.bar/cli/") + .api_url(), + "https://foo.bar/cli/alpha.json", + ); + } + + #[test] + fn download_url() { + assert_eq!( + Config::default().download_url("v0.3.12", "maa_cli.zip"), + "https://github.com/MaaAssistantArknights/maa-cli/releases/download/v0.3.12/maa_cli.zip", + ); + assert_eq!( + Config::default() + .with_download_url("https://foo.bar/download/") + .download_url("v0.3.12", "maa_cli.zip"), + "https://foo.bar/download/v0.3.12/maa_cli.zip", + ); + } + } +} diff --git a/maa-cli/src/config/cli/maa_core.rs b/maa-cli/src/config/cli/maa_core.rs new file mode 100644 index 00000000..3a07615f --- /dev/null +++ b/maa-cli/src/config/cli/maa_core.rs @@ -0,0 +1,271 @@ +use super::{normalize_url, return_true, Channel}; + +use std::env::var_os; + +use serde::Deserialize; + +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(Deserialize, Clone)] +pub struct Config { + #[serde(default)] + channel: Channel, + #[serde(default = "default_test_time")] + test_time: u64, + #[serde(default = "default_api_url")] + api_url: String, + #[serde(default)] + components: Components, +} + +impl Default for Config { + fn default() -> Self { + Config { + channel: Default::default(), + test_time: default_test_time(), + api_url: default_api_url(), + components: Default::default(), + } + } +} + +impl Config { + pub fn channel(&self) -> Channel { + self.channel + } + + pub fn set_channel(&mut self, channel: Channel) -> &Self { + self.channel = channel; + self + } + + pub fn test_time(&self) -> u64 { + self.test_time + } + + pub fn set_test_time(&mut self, test_time: u64) -> &Self { + self.test_time = test_time; + self + } + + pub fn api_url(&self) -> String { + format!("{}{}.json", normalize_url(&self.api_url), self.channel()) + } + + pub fn set_api_url(&mut self, api_url: impl ToString) -> &Self { + self.api_url = api_url.to_string(); + self + } + + pub fn components(&self) -> &Components { + &self.components + } + + pub fn set_components(&mut self, f: impl FnOnce(&mut Components)) -> &Self { + f(&mut self.components); + self + } +} + +fn default_test_time() -> u64 { + 3 +} + +fn default_api_url() -> String { + if let Some(url) = var_os("MAA_API_URL") { + url.to_str().unwrap().to_owned() + } else { + "https://ota.maa.plus/MaaAssistantArknights/api/version/".to_owned() + } +} + +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(Deserialize, Clone)] +pub struct Components { + #[serde(default = "return_true")] + pub library: bool, + #[serde(default = "return_true")] + pub resource: bool, +} + +impl Default for Components { + fn default() -> Self { + Components { + library: true, + resource: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl Config { + pub fn with_channel(mut self, channel: Channel) -> Self { + self.channel = channel; + self + } + + pub fn with_test_time(mut self, test_time: u64) -> Self { + self.test_time = test_time; + self + } + + pub fn with_api_url(mut self, api_url: impl ToString) -> Self { + self.api_url = api_url.to_string(); + self + } + } + + mod default { + use super::*; + + use std::env::{remove_var, set_var}; + + #[test] + fn api_url() { + assert_eq!( + default_api_url(), + "https://ota.maa.plus/MaaAssistantArknights/api/version/" + ); + + set_var("MAA_API_URL", "https://foo.bar/core/"); + assert_eq!(default_api_url(), "https://foo.bar/core/"); + remove_var("MAA_API_URL"); + } + } + + mod serde { + use super::*; + + use serde_test::{assert_de_tokens, Token}; + + #[test] + fn deserialize_components() { + assert_de_tokens( + &Components { + library: true, + resource: true, + }, + &[Token::Map { len: Some(0) }, Token::MapEnd], + ); + assert_de_tokens( + &Components { + library: false, + resource: false, + }, + &[ + Token::Map { len: Some(2) }, + Token::Str("library"), + Token::Bool(false), + Token::Str("resource"), + Token::Bool(false), + Token::MapEnd, + ], + ); + } + + #[test] + fn deserialize_config() { + assert_de_tokens( + &Config { + channel: Default::default(), + test_time: default_test_time(), + api_url: default_api_url(), + components: Components { + library: true, + resource: true, + }, + }, + &[Token::Map { len: Some(0) }, Token::MapEnd], + ); + + assert_de_tokens( + &Config { + channel: Channel::Beta, + test_time: 10, + api_url: "https://foo.bar/api/".to_owned(), + components: Components { + library: false, + resource: false, + }, + }, + &[ + Token::Map { len: Some(3) }, + Token::Str("channel"), + Channel::Beta.as_token(), + Token::Str("test_time"), + Token::I64(10), + Token::Str("api_url"), + Token::Str("https://foo.bar/api/"), + Token::Str("components"), + Token::Map { len: Some(2) }, + Token::Str("library"), + Token::Bool(false), + Token::Str("resource"), + Token::Bool(false), + Token::MapEnd, + Token::MapEnd, + ], + ); + } + } + + mod methods { + use super::*; + + #[test] + fn channel() { + assert_eq!(Config::default().channel(), Channel::Stable); + assert_eq!( + Config::default().set_channel(Channel::Beta).channel(), + Channel::Beta + ); + assert_eq!( + Config::default().set_channel(Channel::Alpha).channel(), + Channel::Alpha + ); + } + + #[test] + fn api_url() { + assert_eq!( + Config::default().api_url(), + "https://ota.maa.plus/MaaAssistantArknights/api/version/stable.json" + ); + assert_eq!( + Config::default().set_channel(Channel::Beta).api_url(), + "https://ota.maa.plus/MaaAssistantArknights/api/version/beta.json" + ); + assert_eq!( + Config::default().set_channel(Channel::Alpha).api_url(), + "https://ota.maa.plus/MaaAssistantArknights/api/version/alpha.json" + ); + assert_eq!( + Config::default() + .set_api_url("https://foo.bar/api/") + .api_url(), + "https://foo.bar/api/stable.json" + ); + } + + #[test] + fn components() { + assert!(matches!( + Config::default() + .set_components(|components| components.library = false) + .components(), + &Components { library: false, .. } + )); + assert!(matches!( + Config::default() + .set_components(|components| components.resource = false) + .components(), + &Components { + resource: false, + .. + } + )); + } + } +} diff --git a/maa-cli/src/config/cli/mod.rs b/maa-cli/src/config/cli/mod.rs new file mode 100644 index 00000000..1e156724 --- /dev/null +++ b/maa-cli/src/config/cli/mod.rs @@ -0,0 +1,242 @@ +use crate::warning; + +#[cfg(feature = "cli_installer")] +pub mod maa_cli; +#[cfg(feature = "core_installer")] +pub mod maa_core; + +use clap::ValueEnum; +use serde::Deserialize; + +/// Configuration for the CLI (cli.toml) +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(Deserialize, Default)] +pub struct InstallerConfig { + /// DEPRECATED: Remove in the next breaking change + #[cfg(feature = "core_installer")] + #[serde(default)] + pub channel: Option, + /// MaaCore configuration + #[cfg(feature = "core_installer")] + #[serde(default)] + core: maa_core::Config, + #[cfg(feature = "cli_installer")] + #[serde(default)] + cli: maa_cli::Config, + // TODO: Add `resource` field for separate resource updater +} + +impl InstallerConfig { + #[cfg(feature = "core_installer")] + pub fn core_config(mut self) -> maa_core::Config { + if let Some(channel) = self.channel.take() { + warning!("`channel` field in `cli.toml` is deprecated, use `maa_core.channel` instead"); + self.core.set_channel(channel); + } + self.core + } + + #[cfg(feature = "cli_installer")] + pub fn cli_config(self) -> maa_cli::Config { + self.cli + } +} + +impl super::FromFile for InstallerConfig {} + +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(ValueEnum, Clone, Copy, Default, Deserialize)] +pub enum Channel { + #[default] + #[serde(alias = "stable")] + Stable, + #[serde(alias = "beta")] + Beta, + #[serde(alias = "alpha")] + Alpha, +} + +impl std::fmt::Display for Channel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Channel::Stable => write!(f, "stable"), + Channel::Beta => write!(f, "beta"), + Channel::Alpha => write!(f, "alpha"), + } + } +} + +fn return_true() -> bool { + true +} + +fn normalize_url(url: &str) -> String { + if url.ends_with('/') { + url.to_owned() + } else { + format!("{}/", url) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json; + use serde_test::{assert_de_tokens, Token}; + use toml; + + // The serde_de_token cannot deserialize "beta" to Channel::Beta + // But it works in real implementation (serde_json::from_str) + // So we have to use this workaround + impl Channel { + pub fn as_token(self) -> Token { + Token::UnitVariant { + name: "Channel", + variant: match self { + Channel::Stable => "Stable", + Channel::Beta => "Beta", + Channel::Alpha => "Alpha", + }, + } + } + } + + #[test] + fn deserialize_channel() { + let channels: [Channel; 3] = + serde_json::from_str(r#"["stable", "beta", "alpha"]"#).unwrap(); + assert_eq!(channels, [Channel::Stable, Channel::Beta, Channel::Alpha],); + + assert_de_tokens(&Channel::Stable, &[Channel::Stable.as_token()]); + assert_de_tokens(&Channel::Beta, &[Channel::Beta.as_token()]); + assert_de_tokens(&Channel::Alpha, &[Channel::Alpha.as_token()]); + } + + #[test] + fn deserialize_installer_config() { + assert_de_tokens( + &InstallerConfig::default(), + &[Token::Map { len: Some(0) }, Token::MapEnd], + ); + + #[cfg(feature = "core_installer")] + assert_de_tokens( + &InstallerConfig { + channel: Some(Channel::Beta), + core: maa_core::Config::default().with_channel(Channel::Alpha), + ..Default::default() + }, + &[ + Token::Map { len: Some(3) }, + Token::Str("channel"), + Token::Some, + Channel::Beta.as_token(), + Token::Str("core"), + Token::Map { len: Some(1) }, + Token::Str("channel"), + Channel::Alpha.as_token(), + Token::MapEnd, + Token::MapEnd, + ], + ); + + #[cfg(feature = "cli_installer")] + assert_de_tokens( + &InstallerConfig { + cli: maa_cli::Config::default().with_channel(Channel::Alpha), + ..Default::default() + }, + &[ + Token::Map { len: Some(2) }, + Token::Str("cli"), + Token::Map { len: Some(1) }, + Token::Str("channel"), + Channel::Alpha.as_token(), + Token::MapEnd, + Token::MapEnd, + ], + ); + } + + #[test] + fn deserialize_example() { + let config: InstallerConfig = + toml::from_str(&std::fs::read_to_string("../config_examples/cli.toml").unwrap()) + .unwrap(); + + let expect = InstallerConfig { + #[cfg(feature = "core_installer")] + channel: None, + #[cfg(feature = "core_installer")] + core: maa_core::Config::default() + .with_channel(Channel::Beta) + .with_test_time(0) + .with_api_url( + "https://github.com/MaaAssistantArknights/MaaRelease/raw/main/MaaAssistantArknights/api/version/" + ), + #[cfg(feature = "cli_installer")] + cli: maa_cli::Config::default() + .with_channel(Channel::Alpha) + .with_api_url("https://cdn.jsdelivr.net/gh/MaaAssistantArknights/maa-cli@vversion/") + .with_download_url( + "https://github.com/MaaAssistantArknights/maa-cli/releases/download/", + ), + }; + + assert_eq!(config, expect); + } + + #[cfg(feature = "core_installer")] + #[test] + fn get_core_config() { + assert_eq!( + InstallerConfig::default().core_config(), + maa_core::Config::default() + ); + + assert_eq!( + &InstallerConfig { + channel: Some(Channel::Alpha), + ..Default::default() + } + .core_config(), + maa_core::Config::default().set_channel(Channel::Alpha) + ); + + assert_eq!( + &InstallerConfig { + core: { + let mut config = maa_core::Config::default(); + config.set_channel(Channel::Beta); + config + }, + ..Default::default() + } + .core_config(), + maa_core::Config::default().set_channel(Channel::Beta) + ); + } + + #[cfg(feature = "cli_installer")] + #[test] + fn get_cli_config() { + assert_eq!( + InstallerConfig { + cli: Default::default(), + ..Default::default() + } + .cli_config(), + maa_cli::Config::default(), + ); + + assert_eq!( + InstallerConfig { + cli: maa_cli::Config::default().with_channel(Channel::Alpha), + ..Default::default() + } + .cli_config(), + maa_cli::Config::default().with_channel(Channel::Alpha), + ); + } +} diff --git a/maa-cli/src/consts.rs b/maa-cli/src/consts.rs new file mode 100644 index 00000000..3f4f44e8 --- /dev/null +++ b/maa-cli/src/consts.rs @@ -0,0 +1,13 @@ +pub const MAA_CLI_EXE: &str = if cfg!(windows) { "maa.exe" } else { "maa" }; + +pub const MAA_CORE_LIB: &str = if cfg!(unix) { + if cfg!(target_os = "macos") { + "libMaaCore.dylib" + } else { + "libMaaCore.so" + } +} else { + "MaaCore.dll" +}; + +pub const MAA_CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/maa-cli/src/dirs.rs b/maa-cli/src/dirs.rs index 2cae726b..1906b08f 100644 --- a/maa-cli/src/dirs.rs +++ b/maa-cli/src/dirs.rs @@ -1,8 +1,13 @@ -use std::env::var_os; -use std::fs::{create_dir, remove_dir_all}; -use std::path::{Path, PathBuf}; +use crate::consts::MAA_CORE_LIB; + +use std::{ + env::{current_exe, var_os}, + fs::{create_dir, remove_dir_all}, + path::{Path, PathBuf}, +}; use directories::ProjectDirs; +use dunce::canonicalize; use paste::paste; macro_rules! matct_loc { @@ -66,9 +71,9 @@ impl Dirs { Self { data: data_dir.clone(), - library: data_dir.join("lib"), cache: get_cache_dir(&proj), config: get_config_dir(&proj), + library: data_dir.join("lib"), resource: data_dir.join("resource"), state: state_dir.clone(), log: state_dir.join("debug"), @@ -102,6 +107,74 @@ impl Dirs { pub fn log(&self) -> &Path { &self.log } + + pub fn find_library(&self) -> Option { + let lib_dir = self.library(); + if lib_dir.join(MAA_CORE_LIB).exists() { + return Some(lib_dir.to_path_buf()); + } + + current_exe_dir_find(|exe_dir| { + if exe_dir.join(MAA_CORE_LIB).exists() { + return Some(exe_dir.to_path_buf()); + } + if let Some(dir) = exe_dir.parent() { + let lib_dir = dir.join("lib"); + let lib_path = lib_dir.join(MAA_CORE_LIB); + if lib_path.exists() { + return Some(lib_dir); + } + } + + None + }) + } + + pub fn find_resource(&self) -> Option { + let resource_dir = self.resource(); + if resource_dir.exists() { + return Some(resource_dir.to_path_buf()); + } + + current_exe_dir_find(|exe_dir| { + let resource_dir = exe_dir.join("resource"); + if resource_dir.exists() { + return Some(resource_dir); + } + if let Some(dir) = exe_dir.parent() { + let share_dir = dir.join("share"); + if let Some(extra_share) = option_env!("MAA_EXTRA_SHARE_NAME") { + let resource_dir = share_dir.join(extra_share).join("resource"); + if resource_dir.exists() { + return Some(resource_dir); + } + } + let resource_dir = share_dir.join("maa").join("resource"); + if resource_dir.exists() { + return Some(resource_dir); + } + } + None + }) + } +} + +/// Find path starting from current executable directory +pub fn current_exe_dir_find(finder: F) -> Option +where + F: Fn(&Path) -> Option, +{ + let exe_path = current_exe().ok()?; + let exe_dir = exe_path.parent().unwrap(); + let canonicalized = canonicalize(exe_dir).ok()?; + if let Some(path) = finder(&canonicalized) { + return Some(path); + }; + if canonicalized != exe_dir { + finder(exe_dir) + } else { + None + } } pub trait Ensure: Sized { diff --git a/maa-cli/src/installer/download.rs b/maa-cli/src/installer/download.rs index d2e45397..763fd13b 100644 --- a/maa-cli/src/installer/download.rs +++ b/maa-cli/src/installer/download.rs @@ -1,3 +1,5 @@ +use crate::{debug, normal}; + use std::cmp::min; use std::fs::{remove_file, File}; use std::io::Write; @@ -151,7 +153,7 @@ pub async fn download<'a>( Ok(()) } -/// Try to download a file within a timeout. +/// Try to download a file with given url and timeout. /// /// # Arguments /// * `client` - A reqwest client. @@ -182,7 +184,6 @@ async fn try_download(client: &Client, url: &str, timeout: Duration) -> Result Result( client: &Client, - fallback: &str, mirrors: Vec, path: &Path, size: u64, t: u64, checker: Option>, ) -> Result<()> { + // The first mirror is the default download link. + let mut download_link = &mirrors[0]; + if t == 0 { - println!("Skip speed test, downloading from fallback mirror..."); - download(client, fallback, path, size, checker).await?; + normal!("Skip speed test, downloading from first link..."); + debug!("First link:", download_link); + download(client, download_link, path, size, checker).await?; return Ok(()); } - let duration = Duration::from_secs(t); - let mut fast_link = fallback; + let test_duration = Duration::from_secs(t); let mut largest: u64 = 0; - println!("Testing download speed..."); + normal!("Testing download speed..."); for link in mirrors.iter() { - if let Ok(downloaded) = try_download(client, link, duration).await { + debug!("Testing {}", link); + if let Ok(downloaded) = try_download(client, link, test_duration).await { if downloaded > largest { + debug!("Found faster link:", link); + debug!("Total bytes downloaded:", downloaded); + download_link = link; largest = downloaded; - fast_link = link; } } } - println!("Downloading from fastest mirror..."); - download(client, fast_link, path, size, checker).await?; + normal!("Downloading from fastest mirror..."); + debug!("Fastest link:", download_link); + download(client, download_link, path, size, checker).await?; Ok(()) } + +pub fn check_file_exists(path: &Path, size: u64) -> bool { + path.exists() && path.is_file() && path.metadata().is_ok_and(|metadata| metadata.len() == size) +} diff --git a/maa-cli/src/installer/extract.rs b/maa-cli/src/installer/extract.rs index 12abf3cd..48e69b2c 100644 --- a/maa-cli/src/installer/extract.rs +++ b/maa-cli/src/installer/extract.rs @@ -28,10 +28,23 @@ pub struct Archive { file_type: ArchiveType, } -impl TryFrom for Archive { - type Error = anyhow::Error; +impl Archive { + /// Create a new `Archive` from a file with specified archive type. + pub fn new(file: PathBuf, file_type: ArchiveType) -> Self { + Self { file, file_type } + } - fn try_from(file: PathBuf) -> std::result::Result { + /// Create a new `Archive` from a file with automatically detected archive type. + /// + /// The archive type is determined by the file extension. + /// + /// # Errors + /// + /// Returns an error if the file extension is not supported. + /// Currently only zip and tar.gz are supported. + /// Or returns an error if the file extension cannot be determined. + pub fn try_from(file: impl AsRef) -> Result { + let file = file.as_ref(); if let Some(extension) = file.extension() { let archive_type = match extension.to_str() { Some("zip") => ArchiveType::Zip, @@ -45,21 +58,11 @@ impl TryFrom for Archive { } _ => bail!("Unsupported archive type"), }; - Ok(Self::new(file, archive_type)) + Ok(Self::new(file.to_path_buf(), archive_type)) } else { Err(anyhow!("Failed to get file extension")) } } -} - -impl Archive { - /// Create a new `Archive` from a file with(optional) specified archive type. - /// - /// If the archive type is not specified, it will be automatically detected from the file extension. - /// Currently only zip and tar.gz are supported. - pub fn new(file: PathBuf, file_type: ArchiveType) -> Self { - Self { file, file_type } - } /// Extract the archive file with a mapper function. /// diff --git a/maa-cli/src/installer/maa_cli.rs b/maa-cli/src/installer/maa_cli.rs index 63cc369b..c8ac864d 100644 --- a/maa-cli/src/installer/maa_cli.rs +++ b/maa-cli/src/installer/maa_cli.rs @@ -1,13 +1,19 @@ use super::{ download::{download, Checker}, extract::Archive, + version_json::VersionJSON, }; -use crate::dirs::{Dirs, Ensure}; +use crate::{ + config::cli::maa_cli::Config, + consts::{MAA_CLI_EXE, MAA_CLI_VERSION}, + dirs::{Dirs, Ensure}, + normal, +}; use std::{ - env::{consts::EXE_SUFFIX, current_exe, var_os}, - path::Path, + env::{consts, current_exe}, + time::Duration, }; use anyhow::{bail, Context, Result}; @@ -16,39 +22,51 @@ use semver::Version; use serde::Deserialize; use tokio::runtime::Runtime; -const MAA_CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); - -pub fn name() -> String { - format!("maa{}", EXE_SUFFIX) -} - pub fn version() -> Result { Version::parse(MAA_CLI_VERSION).context("Failed to parse maa-cli version") } -pub fn update(dirs: &Dirs) -> Result<()> { - println!("Fetching maa-cli version info..."); - let version_json = get_metadata()?; - let asset = version_json.get_asset()?; +pub fn update(dirs: &Dirs, config: &Config) -> Result<()> { + normal!("Fetching maa-cli version info..."); + let version_json: VersionJSON
= reqwest::blocking::get(config.api_url()) + .context("Failed to fetch version info")? + .json() + .context("Failed to parse version info")?; let current_version = version()?; - let last_version = asset.version(); - - if current_version >= *last_version { - println!("Up to date: maa-cli v{}.", current_version); + if !version_json.can_update("maa-cli", ¤t_version)? { return Ok(()); } - println!( - "Found newer maa-cli version: v{} (current: v{}), downloading...", - last_version, current_version - ); - - let bin_name = name(); let bin_path = canonicalize(current_exe()?)?; - let cache_dir = dirs.cache().ensure()?; + let details = version_json.details(); + let asset = details.asset()?; + let asset_name = asset.name(); + let asset_size = asset.size(); + let asset_checksum = asset.checksum(); + let cache_path = dirs.cache().ensure()?.join(asset_name); + + if cache_path.exists() && cache_path.metadata()?.len() == asset_size { + normal!(format!("Found existing file: {}", cache_path.display())); + } else { + let url = config.download_url(details.tag(), asset_name); + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .build() + .context("Failed to create reqwest client")?; + Runtime::new() + .context("Failed to create tokio runtime")? + .block_on(download( + &client, + &url, + &cache_path, + asset_size, + Some(Checker::Sha256(asset_checksum)), + )) + .context("Failed to download maa-cli")?; + }; - asset.download(cache_dir)?.extract(|path| { - if path.ends_with(&bin_name) { + Archive::try_from(cache_path.as_path())?.extract(|path| { + if config.components().binary && path.ends_with(MAA_CLI_EXE) { Some(bin_path.clone()) } else { None @@ -58,122 +76,117 @@ pub fn update(dirs: &Dirs) -> Result<()> { Ok(()) } -fn get_metadata() -> Result { - let metadata_url = if let Some(url) = var_os("MAA_CLI_API") { - url.into_string().unwrap() - } else { - String::from("https://github.com/MaaAssistantArknights/maa-cli/raw/version/version.json") - }; - let metadata: VersionJSON = reqwest::blocking::get(metadata_url)?.json()?; - Ok(metadata) -} - #[derive(Deserialize)] -#[serde(rename_all = "kebab-case")] -/// The version.json file from the server. -/// -/// Example: -/// ```json -/// { -/// "maa-cli": { -/// "universal-apple-darwin": { -/// "version": "0.1.0", -/// "name": "maa_cli-v0.1.0-universal-apple-darwin.tar.gz", -/// "size": 123456, -/// "sha256sum": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" -/// }, -/// "x86_64-unknown-linux-gnu": { -/// ... -/// } -/// }, -/// "maa-run": { -/// "universal-apple-darwin": { -/// ... -/// }, -/// ... -/// } -/// } -/// ``` -struct VersionJSON { - pub maa_cli: Targets, +struct Details { + tag: String, + assets: Assets, } -impl VersionJSON { - pub fn get_asset(&self) -> Result<&Asset> { - let targets = &self.maa_cli; - - if cfg!(target_os = "macos") { - Ok(&targets.universal_macos) - } else if cfg!(target_os = "linux") - && cfg!(target_arch = "x86_64") - && cfg!(target_env = "gnu") - { - Ok(&targets.x64_linux_gnu) - } else { - bail!("Unsupported platform") - } +impl Details { + fn tag(&self) -> &str { + &self.tag + } + + fn asset(&self) -> Result<&Asset> { + self.assets.asset() } } #[derive(Deserialize)] -pub struct Targets { +struct Assets { #[serde(rename = "universal-apple-darwin")] - universal_macos: Asset, + universal_apple_darwin: Asset, #[serde(rename = "x86_64-unknown-linux-gnu")] - x64_linux_gnu: Asset, + x86_64_unknown_linux_gnu: Asset, + #[serde(rename = "aarch64-unknown-linux-gnu")] + aarch64_unknown_linux_gnu: Asset, + #[serde(rename = "x86_64-pc-windows-msvc")] + x86_64_pc_windows_msvc: Asset, +} + +impl Assets { + fn asset(&self) -> Result<&Asset> { + match consts::OS { + "macos" => Ok(&self.universal_apple_darwin), + "linux" => match consts::ARCH { + "x86_64" => Ok(&self.x86_64_unknown_linux_gnu), + "aarch64" => Ok(&self.aarch64_unknown_linux_gnu), + _ => bail!("Unsupported architecture: {}", consts::ARCH), + }, + "windows" if consts::ARCH == "x86_64" => Ok(&self.x86_64_pc_windows_msvc), + _ => bail!("Unsupported platform: {} {}", consts::OS, consts::ARCH), + } + } } #[derive(Deserialize)] -pub struct Asset { - version: Version, - tag: String, +struct Asset { name: String, size: u64, sha256sum: String, } impl Asset { - pub fn version(&self) -> &Version { - &self.version + pub fn name(&self) -> &str { + &self.name } - pub fn download(&self, dir: &Path) -> Result { - let path = dir.join(&self.name); - let size = self.size; + pub fn size(&self) -> u64 { + self.size + } - if path.exists() { - let file_size = path.metadata()?.len(); - if file_size == size { - println!("Found existing file: {}", path.display()); - return Archive::try_from(path); + pub fn checksum(&self) -> &str { + &self.sha256sum + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json; + + #[test] + fn deserialize_version_json() { + let json = r#" +{ + "version": "0.1.0", + "details": { + "tag": "v0.1.0", + "assets": { + "universal-apple-darwin": { + "name": "maa-cli.zip", + "size": 123456, + "sha256sum": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "x86_64-unknown-linux-gnu": { + "name": "maa-cli.tar.gz", + "size": 123456, + "sha256sum": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "aarch64-unknown-linux-gnu": { + "name": "maa-cli.tar.gz", + "size": 123456, + "sha256sum": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "x86_64-pc-windows-msvc": { + "name": "maa-cli.zip", + "size": 123456, + "sha256sum": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" } } - - let url = format_url(&self.tag, &self.name); - - let client = reqwest::Client::new(); - Runtime::new() - .context("Failed to create tokio runtime")? - .block_on(download( - &client, - &url, - &path, - size, - Some(Checker::Sha256(&self.sha256sum)), - )) - .context("Failed to download maa-cli")?; - - Archive::try_from(path) } } + "#; -fn format_url(tag: &str, name: &str) -> String { - if let Some(url) = var_os("MAA_CLI_DOWNLOAD") { - format!("{}/{}/{}", url.into_string().unwrap(), tag, name) - } else { - format!( - "https://github.com/MaaAssistantArknights/maa-cli/releases/download/{}/{}", - tag, name - ) + let version_json: VersionJSON
= serde_json::from_str(json).unwrap(); + let asset = version_json.details().asset().unwrap(); + + assert_eq!(asset.name(), "maa-cli.zip"); + assert_eq!(asset.size(), 123456); + assert_eq!( + asset.checksum(), + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + ); } } diff --git a/maa-cli/src/installer/maa_core.rs b/maa-cli/src/installer/maa_core.rs index aa9ba71a..f5af58aa 100644 --- a/maa-cli/src/installer/maa_core.rs +++ b/maa-cli/src/installer/maa_core.rs @@ -1,64 +1,64 @@ // This file is used to download and extract prebuilt packages of maa-core. -use super::{download::download_mirrors, extract::Archive}; +use super::{ + download::{check_file_exists, download_mirrors}, + extract::Archive, + version_json::VersionJSON, +}; use crate::{ + config::cli::maa_core::{Components, Config}, + consts::MAA_CORE_LIB, + debug, dirs::{Dirs, Ensure}, - run, + normal, run, }; use std::{ - env::{ - consts::{DLL_PREFIX, DLL_SUFFIX}, - current_exe, var_os, - }, - path::{Component, Path, PathBuf}, + env::consts::{ARCH, DLL_PREFIX, DLL_SUFFIX, OS}, + path::{self, Path, PathBuf}, time::Duration, }; use anyhow::{anyhow, bail, Context, Result}; -use clap::ValueEnum; -use dunce::canonicalize; use semver::Version; use serde::Deserialize; use tokio::runtime::Runtime; -pub struct MaaCore { - channel: Channel, -} - -pub const MAA_CORE_NAME: &str = if cfg!(target_os = "macos") { - "libMaaCore.dylib" -} else if cfg!(target_os = "windows") { - "MaaCore.dll" -} else { - "libMaaCore.so" -}; - fn extract_mapper( - path: &Path, + src: &Path, lib_dir: &Path, resource_dir: &Path, - resource: bool, + config: &Components, ) -> Option { - let mut components = path.components(); - for c in components.by_ref() { + debug!("Extracting file:", src.display()); + let mut path_components = src.components(); + for c in path_components.by_ref() { match c { - Component::Normal(c) => { - if resource && c == "resource" { + path::Component::Normal(c) => { + if config.resource && c == "resource" { // The components.as_path() is not working // because it return a path with / as separator on windows // I don't know why - let mut path = resource_dir.to_path_buf(); - for c in components.by_ref() { - path.push(c); + let mut dest = resource_dir.to_path_buf(); + for c in path_components.by_ref() { + dest.push(c); } - return Some(path); - } else if c + debug!( + "Extracting", + format!("{} => {}", src.display(), dest.display()) + ); + return Some(dest); + } else if config.library && c .to_str() // The DLL suffix may not the last part of the file name .is_some_and(|s| s.starts_with(DLL_PREFIX) && s.contains(DLL_SUFFIX)) { - return Some(lib_dir.join(c)); + let dest = lib_dir.join(src.file_name()?); + debug!( + "Extracting", + format!("{} => {}", src.display(), dest.display()) + ); + return Some(dest); } else { continue; } @@ -66,290 +66,331 @@ fn extract_mapper( _ => continue, } } + debug!("Ignore file:", src.display()); None } -impl MaaCore { - pub fn new(channel: Channel) -> Self { - Self { channel } - } +pub fn version(dirs: &Dirs) -> Result { + let ver_str = run::core_version(dirs)?.trim(); + Version::parse(&ver_str[1..]).context("Failed to parse version") +} + +pub fn install(dirs: &Dirs, force: bool, config: &Config) -> Result<()> { + let lib_dir = &dirs.library(); - pub fn version(&self, dirs: &Dirs) -> Result { - let ver_str = run::core_version(dirs)?.trim(); - Version::parse(&ver_str[1..]).context("Failed to parse version") + if lib_dir.join(MAA_CORE_LIB).exists() && !force { + bail!("MaaCore already exists, use `maa update` to update it or `maa install --force` to force reinstall") } - pub fn install(&self, dirs: &Dirs, force: bool, no_resource: bool, t: u64) -> Result<()> { - let lib_dir = &dirs.library().ensure()?; + normal!(format!( + "Fetching MaaCore version info (channel: {})...", + config.channel() + )); + let version_json = get_version_json(config)?; + let asset_version = version_json.version(); + let asset_name = name(asset_version)?; + let asset = version_json.details().asset(&asset_name)?; + + normal!(format!("Downloading MaaCore {}...", asset_version)); + let cache_dir = &dirs.cache().ensure()?; + let archive = download( + &cache_dir.join(asset_name), + asset.size(), + asset.download_links(), + config, + )?; + + normal!("Installing MaaCore..."); + let components = config.components(); + if components.library { + debug!("Cleaning library directory"); + lib_dir.ensure_clean()?; + } + let resource_dir = dirs.resource(); + if components.resource { + debug!("Cleaning resource directory"); + resource_dir.ensure_clean()?; + } + archive.extract(|path: &Path| extract_mapper(path, lib_dir, resource_dir, components))?; - if lib_dir.join(MAA_CORE_NAME).exists() && !force { - bail!("MaaCore already exists, use `maa update` to update it or `maa install --force` to force reinstall") - } + Ok(()) +} - println!( - "Fetching MaaCore version info (channel: {})...", - self.channel - ); - let version_json = get_version_json(self.channel)?; - let asset = version_json.asset()?; - println!("Downloading MaaCore {}...", version_json.version_str()); - let cache_dir = &dirs.cache().ensure()?; - let resource_dir = dirs.resource(); - if !no_resource { - resource_dir.ensure_clean()?; - } - let archive = asset.download(cache_dir, t)?; - archive.extract(|path: &Path| extract_mapper(path, lib_dir, resource_dir, !no_resource))?; +pub fn update(dirs: &Dirs, config: &Config) -> Result<()> { + let components = config.components(); - Ok(()) + // Check if any component is specified + if !(components.library || components.resource) { + bail!("No component specified, aborting"); } - pub fn update(&self, dirs: &Dirs, no_resource: bool, t: u64) -> Result<()> { - println!( - "Fetching MaaCore version info (channel: {})...", - self.channel - ); - let version_json = get_version_json(self.channel)?; - let current_version = self.version(dirs)?; - let last_version = version_json.version(); - if current_version >= last_version { - println!("Up to data: MaaCore v{}.", current_version); - return Ok(()); - } - - println!( - "Found newer MaaCore version: v{} (current: v{}), downloading...", - last_version, current_version - ); - - let cache_dir = &dirs.cache().ensure()?; - let asset = version_json.asset()?; - let archive = asset.download(cache_dir, t)?; - // Clean dirs before extracting, but not before downloading - // because the download may be interrupted - let lib_dir = find_lib_dir(dirs).context("MaaCore not found")?; - let resource_dir = find_resource(dirs).context("Resource dir not found")?; - if !no_resource { - resource_dir.ensure_clean()?; - } - archive - .extract(|path: &Path| extract_mapper(path, &lib_dir, &resource_dir, !no_resource))?; + // Check if MaaCore is installed and installed by maa + let lib_dir = dirs.library(); + let resource_dir = dirs.resource(); - Ok(()) + match (components.library, dirs.find_library()) { + (true, Some(dir)) if dir == lib_dir => bail!( + "MaaCore found at {} but not installed by maa, aborting", + dir.display() + ), + _ => {} } -} - -#[cfg_attr(test, derive(Debug, PartialEq))] -#[derive(ValueEnum, Clone, Copy, Default, Deserialize)] -#[serde(rename_all = "kebab-case")] // Rename to kebab-case to match CLI option -pub enum Channel { - #[default] - Stable, - Beta, - Alpha, -} -impl From<&Channel> for &str { - fn from(channel: &Channel) -> Self { - match channel { - Channel::Stable => "stable", - Channel::Beta => "beta", - Channel::Alpha => "alpha", - } + match (components.resource, dirs.find_resource()) { + (true, Some(dir)) if dir == resource_dir => bail!( + "MaaCore resource found at {} but not installed by maa, aborting", + dir.display() + ), + _ => {} } -} -impl std::fmt::Display for Channel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s: &str = self.into(); - write!(f, "{}", s) + normal!(format!( + "Fetching MaaCore version info (channel: {})...", + config.channel() + )); + let version_json = get_version_json(config)?; + let asset_version = version_json.version(); + let current_version = version(dirs)?; + if !version_json.can_update("MaaCore", ¤t_version)? { + return Ok(()); } -} + let asset_name = name(asset_version)?; + let asset = version_json.details().asset(&asset_name)?; + + normal!(format!("Downloading MaaCore {}...", asset_version)); + let cache_dir = &dirs.cache().ensure()?; + let asset_path = cache_dir.join(asset_name); + let archive = download(&asset_path, asset.size(), asset.download_links(), config)?; + + normal!("Installing MaaCore..."); + if components.library { + debug!("Cleaning library directory"); + lib_dir.ensure_clean()?; + } + if components.resource { + debug!("Cleaning resource directory"); + resource_dir.ensure_clean()?; + } + archive.extract(|path| extract_mapper(path, lib_dir, resource_dir, components))?; -fn get_version_json(channel: Channel) -> Result { - let api_url = if let Some(url) = var_os("MAA_API_URL") { - url.to_str().unwrap().to_owned() - } else { - "https://ota.maa.plus/MaaAssistantArknights/api/version".to_owned() - }; + Ok(()) +} - let url = format!("{}/{}.json", api_url, channel); - let version_json: VersionJSON = reqwest::blocking::get(url) - .context("Failed to get version json")? +fn get_version_json(config: &Config) -> Result> { + let url = config.api_url(); + let version_json = reqwest::blocking::get(&url) + .with_context(|| format!("Failed to fetch version info from {}", url))? .json() - .context("Failed to parse version json")?; - Ok(version_json) -} + .with_context(|| "Failed to parse version info")?; -#[cfg_attr(test, derive(Debug, PartialEq))] -#[derive(Deserialize)] -pub struct VersionJSON { - version: String, - details: VersionDetails, + Ok(version_json) } -impl VersionJSON { - pub fn version(&self) -> Version { - Version::parse(&self.version[1..]).unwrap() - } - - pub fn version_str(&self) -> &str { - &self.version +/// Get the name of the asset for the current platform +fn name(version: &Version) -> Result { + match OS { + "macos" => Ok(format!("MAA-v{}-macos-runtime-universal.zip", version)), + "linux" => match ARCH { + "x86_64" => Ok(format!("MAA-v{}-linux-x86_64.tar.gz", version)), + "aarch64" => Ok(format!("MAA-v{}-linux-aarch64.tar.gz", version)), + _ => Err(anyhow!("Unsupported architecture: {}", ARCH)), + }, + "windows" => match ARCH { + "x86_64" => Ok(format!("MAA-v{}-win-x64.zip", version)), + "aarch64" => Ok(format!("MAA-v{}-win-arm64.zip", version)), + _ => Err(anyhow!("Unsupported architecture: {}", ARCH)), + }, + _ => Err(anyhow!("Unsupported platform: {}", OS)), } +} - pub fn name(&self) -> Result { - let version = self.version(); - if cfg!(target_os = "macos") { - Ok(format!("MAA-v{}-macos-runtime-universal.zip", version)) - } else if cfg!(target_os = "linux") { - if cfg!(target_arch = "x86_64") { - Ok(format!("MAA-v{}-linux-x86_64.tar.gz", version)) - } else if cfg!(target_arch = "aarch64") { - Ok(format!("MAA-v{}-linux-aarch64.tar.gz", version)) - } else { - Err(anyhow!( - "Unsupported architecture: {}", - std::env::consts::ARCH - )) - } - } else if cfg!(target_os = "windows") { - if cfg!(target_arch = "x86_64") { - Ok(format!("MAA-v{}-win-x64.zip", version)) - } else if cfg!(target_arch = "aarch64") { - Ok(format!("MAA-v{}-win-arm64.zip", version)) - } else { - Err(anyhow!( - "Unsupported architecture: {}", - std::env::consts::ARCH - )) - } - } else { - Err(anyhow!("Unsupported platform")) - } - } +#[derive(Deserialize)] +pub struct Details { + assets: Vec, +} - pub fn asset(&self) -> Result<&Asset> { - let asset_name = self.name()?; - self.details - .assets +impl Details { + pub fn asset(&self, name: &str) -> Result<&Asset> { + self.assets .iter() - .find(|asset| asset.name == asset_name) + .find(|asset| name == asset.name()) .ok_or_else(|| anyhow!("Asset not found")) } } -#[cfg_attr(test, derive(Debug, PartialEq))] -#[derive(Deserialize)] -pub struct VersionDetails { - pub assets: Vec, -} - #[cfg_attr(test, derive(Debug, PartialEq))] #[derive(Deserialize)] pub struct Asset { - pub name: String, - pub size: u64, - pub browser_download_url: String, - pub mirrors: Vec, + name: String, + size: u64, + browser_download_url: String, + mirrors: Vec, } impl Asset { - pub fn download(&self, dir: &Path, t: u64) -> Result { - let path = dir.join(&self.name); - let size = self.size; - - if path.exists() { - let file_size = match path.metadata() { - Ok(metadata) => metadata.len(), - Err(_) => 0, - }; - if file_size == size { - println!("File {} already exists, skip download!", &self.name); - return Archive::try_from(path); - } - } + pub fn name(&self) -> &str { + &self.name + } + + pub fn size(&self) -> u64 { + self.size + } - let url = &self.browser_download_url; - let mut mirrors = self.mirrors.clone(); - mirrors.push(url.to_owned()); - - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(1)) - .build() - .context("Failed to build reqwest client")?; - Runtime::new() - .context("Failed to create tokio runtime")? - .block_on(download_mirrors( - &client, url, mirrors, &path, size, t, None, - ))?; - - Archive::try_from(path) + pub fn download_links(&self) -> Vec { + let mut links = self.mirrors.clone(); + links.insert(0, self.browser_download_url.clone()); + links } } -pub fn find_lib_dir(dirs: &Dirs) -> Option { - let lib_dir = dirs.library(); - if lib_dir.join(MAA_CORE_NAME).exists() { - return Some(lib_dir.to_path_buf()); +pub fn download(path: &Path, size: u64, links: Vec, config: &Config) -> Result { + if check_file_exists(path, size) { + normal!("Already downloaded, skip downloading"); + return Archive::try_from(path); } - current_exe_dir_find(|exe_dir| { - if exe_dir.join(MAA_CORE_NAME).exists() { - return Some(exe_dir.to_path_buf()); - } - if let Some(dir) = exe_dir.parent() { - let lib_dir = dir.join("lib"); - if lib_dir.join(MAA_CORE_NAME).exists() { - return Some(lib_dir); - } - } + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(3)) + .build() + .context("Failed to build reqwest client")?; + Runtime::new() + .context("Failed to create tokio runtime")? + .block_on(download_mirrors( + &client, + links, + path, + size, + config.test_time(), + None, + )) + .context("Failed to download asset")?; + + Archive::try_from(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json; - None - }) + #[test] + fn deserialize_version_json() { + // This is a stripped version of the real json + let json_str = r#" +{ + "version": "v4.26.1", + "details": { + "tag_name": "v4.26.1", + "name": "v4.26.1", + "draft": false, + "prerelease": false, + "created_at": "2023-11-02T16:27:04Z", + "published_at": "2023-11-02T16:50:51Z", + "assets": [ + { + "name": "MAA-v4.26.1-linux-aarch64.tar.gz", + "size": 152067668, + "browser_download_url": "https://github.com/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-linux-aarch64.tar.gz", + "mirrors": [ + "https://s3.maa-org.net:25240/maa-release/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-linux-aarch64.tar.gz", + "https://agent.imgg.dev/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-linux-aarch64.tar.gz", + "https://maa.r2.imgg.dev/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-linux-aarch64.tar.gz" + ] + }, + { + "name": "MAA-v4.26.1-linux-x86_64.tar.gz", + "size": 155241185, + "browser_download_url": "https://github.com/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-linux-x86_64.tar.gz", + "mirrors": [ + "https://s3.maa-org.net:25240/maa-release/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-linux-x86_64.tar.gz", + "https://agent.imgg.dev/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-linux-x86_64.tar.gz", + "https://maa.r2.imgg.dev/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-linux-x86_64.tar.gz" + ] + }, + { + "name": "MAA-v4.26.1-win-arm64.zip", + "size": 148806502, + "browser_download_url": "https://github.com/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-win-arm64.zip", + "mirrors": [ + "https://s3.maa-org.net:25240/maa-release/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-win-arm64.zip", + "https://agent.imgg.dev/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-win-arm64.zip", + "https://maa.r2.imgg.dev/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-win-arm64.zip" + ] + }, + { + "name": "MAA-v4.26.1-win-x64.zip", + "size": 150092421, + "browser_download_url": "https://github.com/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-win-x64.zip", + "mirrors": [ + "https://s3.maa-org.net:25240/maa-release/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-win-x64.zip", + "https://agent.imgg.dev/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-win-x64.zip", + "https://maa.r2.imgg.dev/MaaAssistantArknights/MaaAssistantArknights/releases/download/v4.26.1/MAA-v4.26.1-win-x64.zip" + ] + }, + { + "name": "MAA-v4.26.1-macos-runtime-universal.zip", + "size": 164012486, + "browser_download_url": "https://github.com/MaaAssistantArknights/MaaRelease/releases/download/v4.26.1/MAA-v4.26.1-macos-runtime-universal.zip", + "mirrors": [ + "https://s3.maa-org.net:25240/maa-release/MaaAssistantArknights/MaaRelease/releases/download/v4.26.1/MAA-v4.26.1-macos-runtime-universal.zip", + "https://agent.imgg.dev/MaaAssistantArknights/MaaRelease/releases/download/v4.26.1/MAA-v4.26.1-macos-runtime-universal.zip", + "https://maa.r2.imgg.dev/MaaAssistantArknights/MaaRelease/releases/download/v4.26.1/MAA-v4.26.1-macos-runtime-universal.zip" + ] + } + ], + "tarball_url": "https://api.github.com/repos/MaaAssistantArknights/MaaAssistantArknights/tarball/v4.26.1", + "zipball_url": "https://api.github.com/repos/MaaAssistantArknights/MaaAssistantArknights/zipball/v4.26.1" + } } + "#; -pub fn find_resource(dirs: &Dirs) -> Option { - let resource_dir = dirs.resource(); - if resource_dir.exists() { - return Some(resource_dir.to_path_buf()); - } + let version_json: VersionJSON
= + serde_json::from_str(json_str).expect("Failed to parse json"); - current_exe_dir_find(|exe_dir| { - let resource_dir = exe_dir.join("resource"); - if resource_dir.exists() { - return Some(resource_dir); - } - if let Some(dir) = exe_dir.parent() { - let share_dir = dir.join("share"); - if let Some(extra_share) = option_env!("MAA_EXTRA_SHARE_NAME") { - let resource_dir = share_dir.join(extra_share).join("resource"); - if resource_dir.exists() { - return Some(resource_dir); - } - } - let resource_dir = share_dir.join("maa").join("resource"); - if resource_dir.exists() { - return Some(resource_dir); + assert_eq!( + version_json.version(), + &Version::parse("4.26.1").expect("Failed to parse version") + ); + + let details = version_json.details(); + let asset_name = name(version_json.version()).unwrap(); + let asset = details.asset(&asset_name).unwrap(); + + // Test asset name, size and download links + match OS { + "macos" => { + assert_eq!(asset.name(), "MAA-v4.26.1-macos-runtime-universal.zip"); + assert_eq!(asset.size(), 164012486); + assert_eq!(asset.download_links().len(), 4); } + "linux" => match ARCH { + "x86_64" => { + assert_eq!(asset.name(), "MAA-v4.26.1-linux-x86_64.tar.gz"); + assert_eq!(asset.size(), 155241185); + assert_eq!(asset.download_links().len(), 4); + } + "aarch64" => { + assert_eq!(asset.name(), "MAA-v4.26.1-linux-aarch64.tar.gz"); + assert_eq!(asset.size(), 152067668); + assert_eq!(asset.download_links().len(), 4); + } + _ => (), + }, + "windows" => match ARCH { + "x86_64" => { + assert_eq!(asset.name(), "MAA-v4.26.1-win-x64.zip"); + assert_eq!(asset.size(), 150092421); + assert_eq!(asset.download_links().len(), 4); + } + "aarch64" => { + assert_eq!(asset.name(), "MAA-v4.26.1-win-arm64.zip"); + assert_eq!(asset.size(), 148806502); + assert_eq!(asset.download_links().len(), 4); + } + _ => (), + }, + _ => (), } - None - }) -} - -/// Find path starting from current executable directory -pub fn current_exe_dir_find(finder: F) -> Option -where - F: Fn(&Path) -> Option, -{ - let exe_path = current_exe().ok()?; - let exe_dir = exe_path.parent().unwrap(); - if let Some(path) = finder(exe_dir) { - return Some(path); - } - let canonicalized = canonicalize(exe_dir).ok()?; - if canonicalized == exe_dir { - None - } else { - finder(&canonicalized) } } diff --git a/maa-cli/src/installer/mod.rs b/maa-cli/src/installer/mod.rs index 6c843b54..c7ca885c 100644 --- a/maa-cli/src/installer/mod.rs +++ b/maa-cli/src/installer/mod.rs @@ -1,5 +1,12 @@ mod download; + +#[cfg(feature = "extract_helper")] mod extract; -#[cfg(feature = "self")] + +#[cfg(any(feature = "cli_installer", feature = "core_installer"))] +mod version_json; + +#[cfg(feature = "cli_installer")] pub mod maa_cli; +#[cfg(feature = "core_installer")] pub mod maa_core; diff --git a/maa-cli/src/installer/version_json.rs b/maa-cli/src/installer/version_json.rs new file mode 100644 index 00000000..e3ca1f10 --- /dev/null +++ b/maa-cli/src/installer/version_json.rs @@ -0,0 +1,60 @@ +use crate::normal; + +use semver::Version; +use serde::Deserialize; + +#[cfg_attr(test, derive(Debug, PartialEq))] +pub struct VersionJSON { + version: Version, + details: D, +} + +#[derive(Deserialize)] +struct VersionJSONHelper { + version: String, + details: D, +} + +impl<'de, A: Deserialize<'de>> Deserialize<'de> for VersionJSON { + fn deserialize(deserializer: D) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let helper = VersionJSONHelper::deserialize(deserializer)?; + let version = if helper.version.starts_with('v') { + Version::parse(&helper.version[1..]) + } else { + Version::parse(&helper.version) + } + .map_err(serde::de::Error::custom)?; + + Ok(VersionJSON { + version, + details: helper.details, + }) + } +} + +impl VersionJSON { + pub fn version(&self) -> &Version { + &self.version + } + + pub fn can_update(&self, name: &str, current_version: &Version) -> Result { + let version = self.version(); + if version > current_version { + normal!(format!( + "Found newer {} version: v{} (current: v{})", + name, version, current_version + )); + Ok(true) + } else { + normal!(format!("Up to date: {} v{}.", name, current_version)); + Ok(false) + } + } + + pub fn details(&self) -> &D { + &self.details + } +} diff --git a/maa-cli/src/main.rs b/maa-cli/src/main.rs index 2e513c28..72a298ea 100644 --- a/maa-cli/src/main.rs +++ b/maa-cli/src/main.rs @@ -1,17 +1,22 @@ mod config; +mod consts; mod dirs; mod installer; mod log; mod run; use crate::{ - config::{cli::CLIConfig, FindFile}, - installer::maa_core::{self, Channel, MaaCore}, + config::{ + cli::{self, Channel, InstallerConfig}, + FindFile, + }, log::{level, set_level}, }; -#[cfg(feature = "self")] +#[cfg(feature = "cli_installer")] use crate::installer::maa_cli; +#[cfg(feature = "core_installer")] +use crate::installer::maa_core; use anyhow::{Context, Result}; use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; @@ -40,34 +45,17 @@ struct CLI { #[derive(Subcommand)] enum SubCommand { - /// Install maa core and resources + /// Install maa maa_core and resources /// /// This command will install maa-core and resources /// by downloading prebuilt packages. /// Note: If the maa-core and resource are already installed, /// please update them by `maa-cli update`. /// Note: If you want to install maa-run, please use `maa-cli self install`. + #[cfg(feature = "core_installer")] Install { - /// Channel to download prebuilt package - /// - /// There are three channels of maa-core prebuilt packages, - /// stable, beta and alpha. - /// The default channel is stable, you can use this flag to change the channel. - /// If you want to use the latest features of maa-core, - /// you can use beta or alpha channel. - /// You can also configure the default channel - /// in the cli configure file `$MAA_CONFIG_DIR/cli.toml` with the key `core.channel`. - /// Note: the alpha channel is only available for windows. - channel: Option, - /// Time to test download speed - /// - /// There are several mirrors of maa-core prebuilt packages. - /// This command will test the download speed of these mirrors, - /// and choose the fastest one to download. - /// This flag is used to set the time in seconds to test download speed. - /// If test time is 0, speed test will be skipped. - #[arg(short, long, default_value_t = 3)] - test_time: u64, + #[command(flatten)] + common_args: CoreCommonerArgs, /// Force to install even if the maa and resource already exists /// /// If the maa-core and resource already exists, @@ -79,26 +67,8 @@ enum SubCommand { /// please use `maa-cli update` instead. #[arg(short, long)] force: bool, - /// Do not install resource - /// - /// By default, resources are shipped with maa-core, - /// and we will install them when installing maa-core. - /// If you do not want to install resource, - /// you can use this flag to disable it. - /// You can also configure the default value in the cli configure file - /// `$MAA_CONFIG_DIR/cli.toml` with the key `core.component.resource`; - /// set it to false to disable installing resource by default. - /// This is useful when you want to install maa-core only. - /// For my own, I will use this flag to install maa-core, - /// because I use the latest resource from github, - /// and this flag can avoid the resource being overwritten. - /// Note: if you use resources that too new or too old, - /// you may encounter some problems. - /// Use at your own risk. - #[arg(long)] - no_resource: bool, }, - /// Update maa core and resources + /// Update maa maa_core and resources /// /// This command will update maa-core and resources /// by downloading prebuilt packages. @@ -106,54 +76,17 @@ enum SubCommand { /// we will not update it. /// Note: If the maa-core and resource are not installed, /// please install them by `maa-cli install`. + #[cfg(feature = "core_installer")] Update { - /// Channel to download prebuilt package - /// - /// There are three channels of maa-core prebuilt packages, - /// stable, beta and alpha. - /// The default channel is stable, you can use this flag to change the channel. - /// If you want to use the latest features of maa-core, - /// you can use beta or alpha channel. - /// You can also configure the default channel - /// in the cli configure file `$MAA_CONFIG_DIR/cli.toml` with the key `core.channel`. - /// Note: the alpha channel is only available for windows. - /// Note: if the maa-core is not installed, please use `maa-cli install` instead. - /// And if the core is broken, please use `maa-cli install --force` to reinstall it. - channel: Option, - /// Do not update resource - /// - /// By default, resources are shipped with maa-core, - /// and we will update them when updating maa-core. - /// If you do not want to update resource, - /// you can use this flag to disable it. - /// You can also configure the default value in the cli configure file - /// `$MAA_CONFIG_DIR/cli.toml` with the key `core.component.resource`; - /// set it to false to disable updating resource by default. - /// This is useful when you want to update maa-core only. - /// For my own, I will use this flag to update maa-core, - /// because I use the latest resource from github, - /// and this flag can avoid the resource being overwritten. - /// Note: if you use resources that too new or too old, - /// you may encounter some problems. - /// Use at your own risk. - #[arg(long)] - no_resource: bool, - /// Time to test download speed - /// - /// There are several mirrors of maa-core prebuilt packages. - /// This command will test the download speed of these mirrors, - /// and choose the fastest one to download. - /// This flag is used to set the time in seconds to test download speed. - /// If test time is 0, speed test will be skipped. - #[arg(short, long, default_value_t = 3)] - test_time: u64, + #[command(flatten)] + common_args: CoreCommonerArgs, }, /// Manage maa-cli self and maa-run /// /// This command is used to manage maa-cli self and maa-run. /// Note: If you want to install or update maa-core and resource, /// please use `maa-cli install` or `maa-cli update` instead. - #[cfg(feature = "self")] + #[cfg(feature = "cli_installer")] #[command(subcommand, name = "self")] SelfCommand(SelfCommand), /// Print path of maa directories @@ -214,7 +147,92 @@ enum SelfCommand { /// /// This command will download prebuilt binary of maa-cli, /// and install them to it current directory. - Update, + Update { + /// Channel to download prebuilt CLI binary + /// + /// There are two channels of maa-cli prebuilt binary, + /// stable and alpha (which means nightly). + channel: Option, + /// Url of api to get version information + /// + /// This flag is used to set the URL of api to get version information. + /// Default to https://github.com/MaaAssistantArknights/maa-cli/raw/release/. + #[arg(long)] + api_url: Option, + /// Url of download to download prebuilt CLI binary + /// + /// This flag is used to set the URL of download to download prebuilt CLI binary. + /// Default to https://github.com/MaaAssistantArknights/maa-cli/releases/download/. + #[arg(long)] + download_url: Option, + }, +} + +#[cfg(feature = "core_installer")] +#[derive(Parser, Default)] +struct CoreCommonerArgs { + /// Channel to download prebuilt package + /// + /// There are three channels of maa-core prebuilt packages, + /// stable, beta and alpha. + /// The default channel is stable, you can use this flag to change the channel. + /// If you want to use the latest features of maa-core, + /// you can use beta or alpha channel. + /// You can also configure the default channel + /// in the cli configure file `$MAA_CONFIG_DIR/cli.toml` with the key `maa_core.channel`. + /// Note: the alpha channel is only available for windows. + channel: Option, + /// Do not install resource + /// + /// By default, resources are shipped with maa-core, + /// and we will install them when installing maa-core. + /// If you do not want to install resource, + /// you can use this flag to disable it. + /// You can also configure the default value in the cli configure file + /// `$MAA_CONFIG_DIR/cli.toml` with the key `maa_core.component.resource`; + /// set it to false to disable installing resource by default. + /// This is useful when you want to install maa-core only. + /// For my own, I will use this flag to install maa-core, + /// because I use the latest resource from github, + /// and this flag can avoid the resource being overwritten. + /// Note: if you use resources that too new or too old, + /// you may encounter some problems. + /// Use at your own risk. + #[arg(long)] + no_resource: bool, + /// Time to test download speed + /// + /// There are several mirrors of maa-core prebuilt packages. + /// This command will test the download speed of these mirrors, + /// and choose the fastest one to download. + /// This flag is used to set the time in seconds to test download speed. + /// If test time is 0, speed test will be skipped. + #[arg(short, long)] + test_time: Option, + /// URL of api to get version information + /// + /// This flag is used to set the URL of api to get version information. + /// It can also be changed by environment variable `MAA_API_URL`. + #[arg(long)] + api_url: Option, +} + +#[cfg(feature = "core_installer")] +fn apply_core_args(config: &mut cli::maa_core::Config, args: &CoreCommonerArgs) { + if let Some(channel) = args.channel { + config.set_channel(channel); + } + if let Some(test_time) = args.test_time { + config.set_test_time(test_time); + } + if let Some(api_url) = args.api_url.as_ref() { + config.set_api_url(api_url); + } + if args.no_resource { + config.set_components(|components| { + components.resource = false; + }); + } } #[derive(ValueEnum, Clone, Default)] @@ -265,33 +283,42 @@ fn main() -> Result<()> { } match subcommand { - SubCommand::Install { - channel, - no_resource, - test_time, - force, - } => { - let cli_config = - CLIConfig::find_file(&proj_dirs.config().join("cli")).unwrap_or_default(); - let channel = channel.unwrap_or_else(|| cli_config.channel()); - let no_resource = no_resource || !cli_config.resource(); - MaaCore::new(channel).install(&proj_dirs, force, no_resource, test_time)?; + #[cfg(feature = "core_installer")] + SubCommand::Install { common_args, force } => { + let mut core_config = InstallerConfig::find_file(&proj_dirs.config().join("cli")) + .unwrap_or_default() + .core_config(); + apply_core_args(&mut core_config, &common_args); + maa_core::install(&proj_dirs, force, &core_config)?; } - SubCommand::Update { - channel, - no_resource, - test_time, - } => { - let cli_config = - CLIConfig::find_file(&proj_dirs.config().join("cli")).unwrap_or_default(); - let channel = channel.unwrap_or_else(|| cli_config.channel()); - let no_resource = no_resource || !cli_config.resource(); - MaaCore::new(channel).update(&proj_dirs, no_resource, test_time)?; + #[cfg(feature = "core_installer")] + SubCommand::Update { common_args } => { + let mut core_config = InstallerConfig::find_file(&proj_dirs.config().join("cli")) + .unwrap_or_default() + .core_config(); + apply_core_args(&mut core_config, &common_args); + maa_core::update(&proj_dirs, &core_config)?; } - #[cfg(feature = "self")] + #[cfg(feature = "cli_installer")] SubCommand::SelfCommand(self_command) => match self_command { - SelfCommand::Update => { - maa_cli::update(&proj_dirs)?; + SelfCommand::Update { + channel, + api_url, + download_url, + } => { + let mut cli_config = InstallerConfig::find_file(&proj_dirs.config().join("cli")) + .unwrap_or_default() + .cli_config(); + if let Some(channel) = channel { + cli_config.set_channel(channel); + } + if let Some(api_url) = api_url { + cli_config.set_api_url(api_url); + } + if let Some(download_url) = download_url { + cli_config.set_download_url(download_url); + } + maa_cli::update(&proj_dirs, &cli_config)?; } }, SubCommand::Dir { dir_type } => match dir_type { @@ -299,7 +326,8 @@ fn main() -> Result<()> { Dir::Library => { println!( "{}", - maa_core::find_lib_dir(&proj_dirs) + proj_dirs + .find_library() .context("Library not found")? .display() ) @@ -309,7 +337,8 @@ fn main() -> Result<()> { Dir::Resource => { println!( "{}", - maa_core::find_resource(&proj_dirs) + proj_dirs + .find_resource() .context("Resource not found")? .display() ) @@ -372,28 +401,52 @@ mod test { assert!(matches!(CLI::parse_from(["maa", "help", "-qq"]).quiet, 2)); } + #[cfg(feature = "core_installer")] #[test] fn install() { assert!(matches!( CLI::parse_from(["maa", "install"]).command, - SubCommand::Install { .. } + SubCommand::Install { + common_args: CoreCommonerArgs { + channel: None, + test_time: None, + no_resource: false, + api_url: None, + }, + force: false, + } )); assert!(matches!( CLI::parse_from(["maa", "install", "beta"]).command, SubCommand::Install { - channel: Some(Channel::Beta), + common_args: CoreCommonerArgs { + channel: Some(Channel::Beta), + .. + }, .. } )); assert!(matches!( CLI::parse_from(["maa", "install", "-t5"]).command, - SubCommand::Install { test_time: 5, .. } + SubCommand::Install { + common_args: CoreCommonerArgs { + test_time: Some(5), + .. + }, + .. + } )); assert!(matches!( CLI::parse_from(["maa", "install", "--test-time", "5"]).command, - SubCommand::Install { test_time: 5, .. } + SubCommand::Install { + common_args: CoreCommonerArgs { + test_time: Some(5), + .. + }, + .. + } )); assert!(matches!( @@ -404,55 +457,49 @@ mod test { assert!(matches!( CLI::parse_from(["maa", "install", "--no-resource"]).command, SubCommand::Install { - no_resource: true, + common_args: CoreCommonerArgs { + no_resource: true, + .. + }, .. } )); } + #[cfg(feature = "core_installer")] #[test] fn update() { assert!(matches!( CLI::parse_from(["maa", "update"]).command, SubCommand::Update { - channel: None, - test_time: 3, - no_resource: false, - } - )); - - assert!(matches!( - CLI::parse_from(["maa", "update", "beta"]).command, - SubCommand::Update { - channel: Some(Channel::Beta), - .. - } - )); - - assert!(matches!( - CLI::parse_from(["maa", "update", "-t5"]).command, - SubCommand::Update { test_time: 5, .. } - )); - assert!(matches!( - CLI::parse_from(["maa", "update", "--test-time", "5"]).command, - SubCommand::Update { test_time: 5, .. } - )); - - assert!(matches!( - CLI::parse_from(["maa", "update", "--no-resource"]).command, - SubCommand::Update { - no_resource: true, - .. + common_args: CoreCommonerArgs { + channel: None, + test_time: None, + no_resource: false, + api_url: None, + }, } )); } + #[cfg(feature = "cli_installer")] #[test] - #[cfg(feature = "self")] fn self_command() { assert!(matches!( CLI::parse_from(["maa", "self", "update"]).command, - SubCommand::SelfCommand(SelfCommand::Update) + SubCommand::SelfCommand(SelfCommand::Update { + channel: None, + api_url: None, + download_url: None, + }) + )); + + assert!(matches!( + CLI::parse_from(["maa", "self", "update", "beta"]).command, + SubCommand::SelfCommand(SelfCommand::Update { + channel: Some(Channel::Beta), + .. + }) )); } @@ -631,4 +678,73 @@ mod test { )); } } + + mod apple_args { + use super::*; + + #[cfg(feature = "core_installer")] + #[test] + fn core_args() { + fn apply_to_default(args: &CoreCommonerArgs) -> cli::maa_core::Config { + let mut core_config = cli::maa_core::Config::default(); + apply_core_args(&mut core_config, args); + core_config + } + + assert_eq!( + apply_to_default(&CoreCommonerArgs::default()), + cli::maa_core::Config::default() + ); + + assert_eq!( + &apply_to_default(&CoreCommonerArgs { + channel: Some(Channel::Beta), + ..Default::default() + }), + cli::maa_core::Config::default().set_channel(Channel::Beta) + ); + + assert_eq!( + &apply_to_default(&CoreCommonerArgs { + test_time: Some(5), + ..Default::default() + }), + cli::maa_core::Config::default().set_test_time(5) + ); + + assert_eq!( + &apply_to_default(&CoreCommonerArgs { + api_url: Some("https://foo.bar/core/".to_string()), + ..Default::default() + }), + cli::maa_core::Config::default().set_api_url("https://foo.bar/core/") + ); + + assert_eq!( + &apply_to_default(&CoreCommonerArgs { + no_resource: true, + ..Default::default() + }), + cli::maa_core::Config::default().set_components(|components| { + components.resource = false; + }) + ); + + assert_eq!( + &apply_to_default(&CoreCommonerArgs { + channel: Some(Channel::Beta), + test_time: Some(5), + api_url: Some("https://foo.bar/maa_core/".to_string()), + no_resource: true, + }), + cli::maa_core::Config::default() + .with_channel(Channel::Beta) + .with_test_time(5) + .with_api_url("https://foo.bar/maa_core/") + .set_components(|components| { + components.resource = false; + }) + ); + } + } } diff --git a/maa-cli/src/run/mod.rs b/maa-cli/src/run/mod.rs index b5e68ebf..181f3092 100644 --- a/maa-cli/src/run/mod.rs +++ b/maa-cli/src/run/mod.rs @@ -14,8 +14,8 @@ use crate::{ }, Error as ConfigError, FindFile, }, + consts::MAA_CORE_LIB, dirs::{Dirs, Ensure}, - installer::maa_core::{find_lib_dir, find_resource, MAA_CORE_NAME}, log::{set_level, LogLevel}, {debug, normal, warning}, }; @@ -106,7 +106,7 @@ pub fn run(dirs: &Dirs, task: impl Into, args: CommonArgs) -> Result<() // Get directories let state_dir = dirs.state().ensure()?; let config_dir = dirs.config().ensure()?; - let base_resource_dir = find_resource(dirs).context("Failed to find resource!")?; + let base_resource_dir = dirs.find_resource().context("Failed to find resource!")?; debug!("State Directory:", state_dir.display()); debug!("Config Directory:", config_dir.display()); debug!("Base Resource Directory:", base_resource_dir.display()); @@ -553,7 +553,8 @@ fn process_resource_dir(path: PathBuf) -> Option { } fn load_core(dirs: &Dirs) { - if let Some(lib_dir) = find_lib_dir(dirs) { + if let Some(lib_dir) = dirs.find_library() { + debug!("Loading MaaCore from:", lib_dir.display()); // Set DLL directory on Windows #[cfg(target_os = "windows")] { @@ -563,9 +564,10 @@ fn load_core(dirs: &Dirs) { let lib_dir_w: Vec = lib_dir.as_os_str().encode_wide().chain(Some(0)).collect(); unsafe { SetDllDirectoryW(lib_dir_w.as_ptr()) }; } - maa_sys::binding::load(lib_dir.join(MAA_CORE_NAME)); + maa_sys::binding::load(lib_dir.join(MAA_CORE_LIB)); } else { - maa_sys::binding::load(MAA_CORE_NAME); + debug!("MaaCore not found, trying to load from system library path"); + maa_sys::binding::load(MAA_CORE_LIB); } }