diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 382a1f27..2c6505f2 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -7,6 +7,10 @@ inputs: arch: description: 'Target architecture' required: true + coverage: + description: 'Whether to install cargo-tarpaulin' + required: false + default: 'false' runs: using: 'composite' @@ -48,3 +52,8 @@ runs: echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar" >> $GITHUB_ENV echo "CFLAGS_aarch64_unknown_linux_gnu=--sysroot=/usr/aarch64-linux-gnu" >> $GITHUB_ENV echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + - name: Install Cargo tarpaulin + if: fromJson(inputs.coverage) + uses: taiki-e/install-action@v2 + with: + tool: cargo-tarpaulin diff --git a/.github/scripts/parse_version.sh b/.github/scripts/parse_version.sh new file mode 100755 index 00000000..28422283 --- /dev/null +++ b/.github/scripts/parse_version.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +set -e + +CARGO_PKG_VERSION=$(yq -r '.package.version' maa-cli/Cargo.toml) +COMMIT_SHA=$(git rev-parse HEAD) + +if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then + echo "PR detected, marking version as alpha pre-release and skipping publish" + channel="alpha" + publish="false" + VERSION="$CARGO_PKG_VERSION-alpha.$(date +%s)" +elif [ "$GITHUB_EVENT_NAME" == "schedule" ]; then + echo "Scheduled event detected, marking version as alpha pre-release and publish to alpha channel" + # check if there are some new commits + channel="alpha" + pubulished_commit=$(yq -r ".details.commit" version/$channel.json) + last_commit="$COMMIT_SHA" + if [ "$pubulished_commit" == "$last_commit" ]; then + echo "No new commits, exiting, skipping all steps" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + VERSION="$CARGO_PKG_VERSION-alpha.$(date +%s)" + publish="true" +elif [ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]; then + echo "Workflow dispatch event detected, reading inputs" + beta=$(yq -r '.inputs.beta' "$GITHUB_EVENT_PATH") + if [ "$beta" == "true" ]; then + echo "Beta flag detected, marking version as beta pre-release and publish to beta channel" + beta_number=$(yq -r ".details.beta_number" version/beta.json) + VERSION="$CARGO_PKG_VERSION-beta.$beta_number" + channel="beta" + else + echo "No beta flag detected, marking version as stable release and publish to stable channel" + channel="stable" + fi + publish=$(yq -r '.inputs.publish' "$GITHUB_EVENT_PATH") +else + REF_VERSION=${GITHUB_REF#refs/tags/v} + if [ "$REF_VERSION" == "$GITHUB_REF" ]; then + echo "Version tag not matched, aborting" + exit 1 + fi + echo "Tag detected, marking version as stable release and publish to stable channel" + channel="stable" + VERSION="$REF_VERSION" +fi +echo "Release version $VERSION to $channel channel and publish=$publish" +{ + echo "channel=$channel" + echo "commit=$COMMIT_SHA" + echo "version=$VERSION" + echo "publish=$publish" + echo "skip=false" +} >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76b32c1f..9fe2afb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,8 @@ jobs: # Failed to cross compile ring on Windows - os: windows-latest arch: aarch64 + env: + MAA_EXTRA_SHARE_NAME: maa-test steps: - name: Checkout uses: actions/checkout@v4 @@ -52,6 +54,7 @@ jobs: with: os: ${{ matrix.os }} arch: ${{ matrix.arch }} + coverage: true - name: Setup Cache uses: Swatinem/rust-cache@v2 - name: Build (maa-cli) @@ -65,91 +68,68 @@ jobs: if: matrix.arch == 'x86_64' run: | cargo fmt --all -- --check - - name: Test (maa-cli) - 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 - run: | - cargo run -- install beta -t0 - - name: Show installation - if: matrix.arch == 'x86_64' - run: | - MAA_CORE_DIR="$(cargo run -- dir lib)" - MAA_RESOURCE_DIR="$(cargo run -- dir resource)" - ls -l "$MAA_CORE_DIR" - ls -l "$MAA_RESOURCE_DIR" - echo "MAA_CORE_DIR=$MAA_CORE_DIR" >> $GITHUB_ENV - echo "MAA_RESOURCE_DIR=$MAA_RESOURCE_DIR" >> $GITHUB_ENV - - name: Run with MaaCore (default path) - if: matrix.arch == 'x86_64' - timeout-minutes: 1 - continue-on-error: ${{ matrix.os == 'windows-latest' }} env: MAA_CONFIG_DIR: ${{ github.workspace }}/config_examples run: | - cargo run -- version - cargo run -- run daily --dry-run --batch - - name: Run with MaaCore (relative path) + cargo run -- install stable + ls -l "$(cargo run -- dir library)" + ls -l "$(cargo run -- dir resource)" + ls -l "$(cargo run -- dir cache)" + package_name=$(basename "$(ls "$(cargo run -- dir cache)")") + echo "Downloaded MaaCore package: $package_name" + version=${package_name#MAA-v} + version=${version%%-*} + if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Downloaded MaaCore version: $version" + echo "MAA_CORE_VERSION=v$version" >> "$GITHUB_ENV" + fi + echo "MAA_CORE_INSTALLED=true" >> "$GITHUB_ENV" + - name: Test if: matrix.arch == 'x86_64' - timeout-minutes: 1 - continue-on-error: ${{ matrix.os == 'windows-latest' }} - env: - MAA_CONFIG_DIR: ${{ github.workspace }}/config_examples - MAA_EXE: ${{ startsWith(matrix.os, 'windows') && 'maa.exe' || 'maa' }} run: | - local_dir="local" - bin_dir="$local_dir/bin" - lib_dir="$local_dir/lib" - share_dir="$local_dir/share/maa" - mkdir -p "$local_dir" - mkdir -p "$bin_dir" - mkdir -p "$share_dir" - cp -v "target/$CARGO_BUILD_TARGET/debug/$MAA_EXE" "$bin_dir" - mv -v "$MAA_CORE_DIR" "$lib_dir" - mv -v "$MAA_RESOURCE_DIR" "$share_dir/resource" - ls -l "$local_dir" - 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 - - name: Cat MaaCore Log + cargo test + - name: Coverage if: matrix.arch == 'x86_64' run: | - cat "$(cargo run -- dir log)/asst.log" + cargo tarpaulin --all-features --workspace --timeout 120 --out xml ${{ github.run_attempt == 1 && '--skip-clean' || '' }} + - name: Upload to codecov.io + if: matrix.arch == 'x86_64' + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true - coverage: - name: Coverage - needs: build + features: + name: Build and Test (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: Install Cargo tarpaulin - uses: taiki-e/install-action@v2 + - name: Setup Rust + uses: ./.github/actions/setup with: - tool: cargo-tarpaulin + os: ${{ matrix.os }} + arch: x86_64 - name: Setup Cache uses: Swatinem/rust-cache@v2 - - name: Generate code coverage - run: | - cargo tarpaulin --all-features --workspace --timeout 120 --out xml ${{ github.run_attempt == 1 && '--skip-clean' || '' }} - - name: Upload to codecov.io - uses: codecov/codecov-action@v3 with: - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + key: feature-${{ matrix.features }} + - name: Build + run: | + cargo build --package maa-cli --no-default-features ${{ matrix.features }} --locked + - name: Test + run: | + cargo test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57ad1dea..35130e86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,11 +11,24 @@ on: - ".github/workflows/release.yml" workflow_dispatch: inputs: - dryrun: - description: Don't create release + beta: + description: Release as beta default: true required: true type: boolean + beta_number: + description: Beta number of this release + default: 1 + required: true + type: number + publish: + description: Whether to publish a new release + default: false + required: true + type: boolean + schedule: + # Release alpha every day at 20:00 UTC (4:00 AM UTC+8) + - cron: "0 20 * * *" defaults: run: @@ -29,37 +42,24 @@ jobs: name: Meta runs-on: ubuntu-latest outputs: - dryrun: ${{ steps.dryrun.outputs.dryrun }} version: ${{ steps.version.outputs.version }} + commit: ${{ steps.version.outputs.commit }} + channel: ${{ steps.version.outputs.channel }} + prerelease: ${{ steps.version.outputs.channel != 'stable' }} + publish: ${{ steps.version.outputs.publish }} + skip: ${{ steps.version.outputs.skip }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Check if Dryrun - id: dryrun - run: | - if [[ "$GITHUB_EVENT_NAME" = "push" && "$GITHUB_REF" = "refs/tags/v"* ]]; then - dryrun=false - elif [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then - dryrun=${{ inputs.dryrun }} - else - dryrun=true - fi - echo "Dryrun: $dryrun" - echo "dryrun=$dryrun" >> $GITHUB_OUTPUT + - name: Checkout Version Branch + uses: actions/checkout@v4 + with: + ref: version + path: version - name: Get Version id: version run: | - CARGO_VERSION=$(yq -oy ".package.version" maa-cli/Cargo.toml) - # check if version is equal to tag if not PR - if [ "$GITHUB_EVENT_NAME" != "pull_request" ]; then - REF=${{ github.ref }} - REF_VERSION=${REF#refs/tags/v} - if [ "$REF_VERSION" != "$CARGO_VERSION" ]; then - echo "Version mismatch: $REF_VERSION != $CARGO_VERSION" - fi - fi - echo "Version: $CARGO_VERSION" - echo "version=$CARGO_VERSION" >> $GITHUB_OUTPUT + bash .github/scripts/parse_version.sh release-note: name: Generate Release Notes @@ -77,11 +77,12 @@ jobs: uses: orhun/git-cliff-action@v2 with: config: cliff.toml - args: -vv ${{ fromJson(needs.meta.outputs.dryrun) && '-u' || '-l' }} + args: -vv ${{ fromJson(needs.meta.outputs.prerelease) && '-u' || '-l' }} build: name: Build needs: meta + if: ${{ !fromJson(needs.meta.outputs.skip) }} runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -105,16 +106,26 @@ jobs: with: os: ${{ matrix.os }} arch: ${{ matrix.arch }} + - name: Patch Cargo.toml (macOS only) + if: ${{ startsWith(matrix.os, 'macos') }} + working-directory: maa-cli + run: | + sed -i "" '1,6 s/^version = .*/version = "${{ needs.meta.outputs.version }}"/' Cargo.toml + - name: Patch Cargo.tom (GNU sed) + if: ${{ !startsWith(matrix.os, 'macos') }} + working-directory: maa-cli + run: | + sed -i '1,6 s/^version = .*/version = "${{ needs.meta.outputs.version }}"/' Cargo.toml - name: Build env: CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 1 CARGO_PROFILE_RELEASE_LTO: true CARGO_PROFILE_RELEASE_STRIP: true run: | - cargo build --release --locked --package maa-cli + cargo build --release --package maa-cli - name: Tar Artifact run: | - tar -cvf "$CARGO_BUILD_TARGET.tar" -C target/$CARGO_BUILD_TARGET/release maa + tar -cvf "$CARGO_BUILD_TARGET.tar" -C "target/$CARGO_BUILD_TARGET/release" maa - name: Upload Artifact uses: actions/upload-artifact@v3 with: @@ -125,6 +136,7 @@ jobs: build-universal: name: Build Universal Binary + if: ${{ !fromJson(needs.meta.outputs.skip) }} runs-on: macos-latest needs: [meta, build] steps: @@ -151,6 +163,7 @@ jobs: release: name: Release + if: ${{ !fromJson(needs.meta.outputs.skip) }} runs-on: ubuntu-latest needs: [meta, build, build-universal, release-note] permissions: @@ -165,40 +178,69 @@ jobs: path: version - name: Extract files, Generate checksums and Update version.json run: | - version=${{ needs.meta.outputs.version }} + VERSION=${{ needs.meta.outputs.version }} + COMMIT=${{ needs.meta.outputs.commit }} + CHANNEL=${{ needs.meta.outputs.channel }} + + version_files=( + version/alpha.json + ) + [ "$CHANNEL" != "alpha" ] && version_files+=(version/beta.json) + [ "$CHANNEL" == "stable" ] && version_files+=(version/stable.json) + + # target independent version info + for version_file in "${version_files[@]}"; do + yq -i -oj ".version = \"$VERSION\"" "$version_file" + yq -i -oj ".details.tag = \"v$VERSION\"" "$version_file" + yq -i -oj ".details.commit = \"$COMMIT\"" "$version_file" + done + targets=( x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu universal-apple-darwin x86_64-pc-windows-msvc ) + for target in "${targets[@]}"; do dir="maa_cli-$target" tar -xvf "$dir/$target.tar" -C "$dir" # use tar on linux and zip on other platforms if [[ "$target" == *"linux"* ]]; then - archive_name="maa_cli-v$version-$target.tar.gz" - tar -czvf $archive_name $dir/maa + archive_name="maa_cli-v$VERSION-$target.tar.gz" + tar -czvf "$archive_name" "$dir/maa" else - archive_name="maa_cli-v$version-$target.zip" - zip -r $archive_name $dir/maa + archive_name="maa_cli-v$VERSION-$target.zip" + zip -r "$archive_name" "$dir/maa" fi - checksum=$(sha256sum $archive_name) - size=$(stat -c%s $archive_name) - checksum_hash=$(echo $checksum | cut -d ' ' -f 1) - echo $checksum > $archive_name.sha256sum + checksum=$(sha256sum "$archive_name") + checksum_hash=${checksum:0:64} + size=$(stat -c%s "$archive_name") + echo "$checksum" > "$archive_name.sha256" + + # old version info (deprecated) version_file="version/version.json" - yq -i -oj ".maa-cli.$target.version = \"$version\"" $version_file - yq -i -oj ".maa-cli.$target.tag = \"v$version\"" $version_file - yq -i -oj ".maa-cli.$target.name = \"$archive_name\"" $version_file + yq -i -oj ".maa-cli.$target.version = \"$VERSION\"" "$version_file" + yq -i -oj ".maa-cli.$target.tag = \"v$VERSION\"" "$version_file" + yq -i -oj ".maa-cli.$target.name = \"$archive_name\"" "$version_file" yq -i -oj ".maa-cli.$target.size = $size" $version_file - yq -i -oj ".maa-cli.$target.sha256sum = \"$(echo $checksum | cut -d ' ' -f 1)\"" $version_file + yq -i -oj ".maa-cli.$target.sha256sum = \"$checksum_hash\"" "$version_file" + + # target dependent version info + for version_file in "${version_files[@]}"; do + yq -i -oj ".details.assets.$target.name = \"$archive_name\"" "$version_file" + yq -i -oj ".details.assets.$target.size = $size" "$version_file" + yq -i -oj ".details.assets.$target.sha256sum = \"$checksum_hash\"" "$version_file" + done done - name: Create Release uses: softprops/action-gh-release@v1 - if: ${{ !fromJson(needs.meta.outputs.dryrun) }} + if: ${{ fromJson(needs.meta.outputs.publish) }} with: name: v${{ needs.meta.outputs.version }} + # use the same tag for all alpha releases + tag_name: ${{ fromJson(needs.meta.outputs.channel) == 'alpha' && 'alpha' || format('v{0}', needs.meta.outputs.version) }} + prerelease: ${{ fromJson(needs.meta.outputs.prerelease) }} body: ${{ needs.release-note.outputs.content }} fail_on_unmatched_files: true files: | @@ -210,16 +252,18 @@ jobs: run: | git config --local user.name "github-actions[bot]" git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" - echo "Commit changes to version.json" >> $GITHUB_STEP_SUMMARY - echo '```diff' >> $GITHUB_STEP_SUMMARY - git diff version.json >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - git commit version.json -m "Update version.json" - git push --verbose ${{ fromJson(needs.meta.outputs.dryrun) && '--dry-run' || ''}} + { + echo "Commit changes to version.json" + echo '```diff' + git diff *.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + git commit *.json -m "chore: bump version to v${{ needs.meta.outputs.version }}" + git push --verbose ${{ !fromJson(needs.meta.outputs.publish) && '--dry-run' || ''}} publish-homebrew: needs: [meta, release] - if: ${{ !fromJson(needs.meta.outputs.dryrun) }} + if: ${{ fromJson(needs.meta.outputs.publish) && !fromJson(needs.meta.outputs.prerelease) }} name: Publish Homebrew uses: ./.github/workflows/publish-homebrew.yml with: @@ -230,7 +274,7 @@ jobs: publish-aur: needs: [meta, release] - if: ${{ !fromJson(needs.meta.outputs.dryrun) }} + if: ${{ fromJson(needs.meta.outputs.publish) && !fromJson(needs.meta.outputs.prerelease) }} name: Publish AUR Package uses: ./.github/workflows/publish-aur.yml with: diff --git a/Cargo.lock b/Cargo.lock index fd57572b..94e8c44b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -719,7 +719,7 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "maa-cli" -version = "0.3.12" +version = "0.4.0" dependencies = [ "anyhow", "chrono", @@ -731,6 +731,7 @@ dependencies = [ "flate2", "futures-util", "indicatif", + "lazy_static", "maa-sys", "paste", "reqwest", 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..d839377b 100644 --- a/maa-cli/Cargo.toml +++ b/maa-cli/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "maa-cli" authors = ["Loong Wang "] -version = "0.3.12" +version = "0.4.0" edition = "2021" readme = "../README-EN.md" description = "A simple CLI for MAA (MaaAssistantArknights)" @@ -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,51 @@ 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" +lazy_static = "1.4.0" + 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..0c421242 --- /dev/null +++ b/maa-cli/src/config/cli/maa_cli.rs @@ -0,0 +1,222 @@ +use super::{normalize_url, return_true, Channel}; + +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 { + String::from("https://github.com/MaaAssistantArknights/maa-cli/raw/version/") +} + +fn default_download_url() -> String { + String::from("https://github.com/MaaAssistantArknights/maa-cli/releases/download/") +} + +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(Deserialize, Clone)] +pub struct CLIComponents { + #[serde(default = "return_true")] + pub binary: bool, +} + +impl Default for CLIComponents { + fn default() -> Self { + Self { binary: 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_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 + } + + pub fn with_components(mut self, components: CLIComponents) -> Self { + self.components = components; + 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 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) + .set_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() + .set_download_url("https://foo.bar/download/") + .download_url("v0.3.12", "maa_cli.zip"), + "https://foo.bar/download/v0.3.12/maa_cli.zip", + ); + } + + #[test] + fn components() { + assert_eq!( + Config::default().components(), + &CLIComponents { binary: true }, + ); + + assert_eq!( + Config::default() + .with_components(CLIComponents { binary: false }) + .components(), + &CLIComponents { binary: false }, + ); + } + } +} 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..9e503026 --- /dev/null +++ b/maa-cli/src/config/cli/maa_core.rs @@ -0,0 +1,394 @@ +use super::{normalize_url, return_true, Channel}; + +use clap::Parser; +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 + } + + pub fn apply_args(&mut self, args: &CommonArgs) -> &Self { + if let Some(channel) = args.channel { + self.set_channel(channel); + } + if let Some(test_time) = args.test_time { + self.set_test_time(test_time); + } + if let Some(api_url) = &args.api_url { + self.set_api_url(api_url); + } + if args.no_resource { + self.set_components(|components| components.resource = false); + } + self + } +} + +fn default_test_time() -> u64 { + 3 +} + +fn default_api_url() -> String { + String::from("https://ota.maa.plus/MaaAssistantArknights/api/version/") +} + +#[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, + } + } +} + +#[derive(Parser, Default)] +pub struct CommonArgs { + /// 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. + pub 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)] + pub 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)] + pub 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)] + pub api_url: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + use lazy_static::lazy_static; + + 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 + } + } + + lazy_static! { + static ref DEFAULT_CONFIG: Config = Config::default(); + } + + fn default_config() -> Config { + DEFAULT_CONFIG.clone() + } + + 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( + &default_config(), + &[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!(default_config().channel(), Channel::Stable); + assert_eq!( + default_config().set_channel(Channel::Beta).channel(), + Channel::Beta + ); + assert_eq!( + default_config().set_channel(Channel::Alpha).channel(), + Channel::Alpha + ); + } + + #[test] + fn test_time() { + assert_eq!(default_config().test_time(), 3); + assert_eq!(default_config().set_test_time(5).test_time(), 5); + } + + #[test] + fn api_url() { + assert_eq!( + default_config().set_channel(Channel::Stable).api_url(), + "https://ota.maa.plus/MaaAssistantArknights/api/version/stable.json" + ); + assert_eq!( + default_config().set_channel(Channel::Beta).api_url(), + "https://ota.maa.plus/MaaAssistantArknights/api/version/beta.json" + ); + assert_eq!( + default_config().set_channel(Channel::Alpha).api_url(), + "https://ota.maa.plus/MaaAssistantArknights/api/version/alpha.json" + ); + assert_eq!( + default_config() + .set_api_url("https://foo.bar/api/") + .api_url(), + "https://foo.bar/api/stable.json" + ); + } + + #[test] + fn components() { + assert!(matches!( + default_config() + .set_components(|components| components.library = false) + .components(), + &Components { library: false, .. } + )); + assert!(matches!( + default_config() + .set_components(|components| components.resource = false) + .components(), + &Components { + resource: false, + .. + } + )); + } + + #[test] + fn apply_args() { + fn apply_to_default(args: &CommonArgs) -> Config { + let mut config = default_config(); + config.apply_args(args); + config + } + + assert_eq!(apply_to_default(&CommonArgs::default()), default_config()); + + assert_eq!( + &apply_to_default(&CommonArgs { + channel: Some(Channel::Beta), + ..Default::default() + }), + default_config().set_channel(Channel::Beta) + ); + + assert_eq!( + &apply_to_default(&CommonArgs { + test_time: Some(5), + ..Default::default() + }), + default_config().set_test_time(5) + ); + + assert_eq!( + &apply_to_default(&CommonArgs { + api_url: Some("https://foo.bar/core/".to_string()), + ..Default::default() + }), + default_config().set_api_url("https://foo.bar/core/") + ); + + assert_eq!( + &apply_to_default(&CommonArgs { + no_resource: true, + ..Default::default() + }), + default_config().set_components(|components| { + components.resource = false; + }) + ); + + assert_eq!( + &apply_to_default(&CommonArgs { + channel: Some(Channel::Beta), + test_time: Some(5), + api_url: Some("https://foo.bar/maa_core/".to_string()), + no_resource: true, + }), + 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/config/cli/mod.rs b/maa-cli/src/config/cli/mod.rs new file mode 100644 index 00000000..61a60b15 --- /dev/null +++ b/maa-cli/src/config/cli/mod.rs @@ -0,0 +1,224 @@ +#[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 { + /// 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(self) -> maa_core::Config { + 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 { + core: maa_core::Config::default().with_channel(Channel::Alpha), + ..Default::default() + }, + &[ + Token::Map { len: Some(3) }, + 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")] + 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/", + ) + .with_components(maa_cli::CLIComponents { binary: false }), + }; + + 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 { + 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), + ); + } + + #[test] + fn normalize_url_test() { + assert_eq!(normalize_url("https://foo.bar"), "https://foo.bar/"); + assert_eq!(normalize_url("https://foo.bar/"), "https://foo.bar/"); + } +} diff --git a/maa-cli/src/consts.rs b/maa-cli/src/consts.rs new file mode 100644 index 00000000..1fce00a4 --- /dev/null +++ b/maa-cli/src/consts.rs @@ -0,0 +1,11 @@ +pub const MAA_CLI_EXE: &str = if cfg!(windows) { "maa.exe" } else { "maa" }; + +pub const MAA_CORE_LIB: &str = if cfg!(windows) { + "MaaCore.dll" +} else if cfg!(target_os = "macos") { + "libMaaCore.dylib" +} else { + "libMaaCore.so" +}; + +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..dec1c383 100644 --- a/maa-cli/src/dirs.rs +++ b/maa-cli/src/dirs.rs @@ -1,8 +1,14 @@ -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 lazy_static::lazy_static; use paste::paste; macro_rules! matct_loc { @@ -66,9 +72,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 +108,108 @@ impl Dirs { pub fn log(&self) -> &Path { &self.log } + + pub fn find_library(&self, exe_path: &Path) -> Option { + if self.library.join(MAA_CORE_LIB).exists() { + return Some(self.library.clone()); + } + + _find_from(exe_path, |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, exe_path: &Path) -> Option { + if self.resource.exists() { + return Some(self.resource.clone()); + } + + _find_from(exe_path, |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 + }) + } +} + +lazy_static! { + pub static ref DIRS: Dirs = Dirs::new(ProjectDirs::from("com", "loong", "maa")); +} + +pub fn data() -> &'static Path { + DIRS.data() +} + +pub fn library() -> &'static Path { + DIRS.library() +} + +pub fn config() -> &'static Path { + DIRS.config() +} + +pub fn cache() -> &'static Path { + DIRS.cache() +} + +pub fn resource() -> &'static Path { + DIRS.resource() +} + +pub fn state() -> &'static Path { + DIRS.state() +} + +pub fn log() -> &'static Path { + DIRS.log() +} + +pub fn find_library() -> Option { + DIRS.find_library(¤t_exe().ok()?) +} + +pub fn find_resource() -> Option { + DIRS.find_resource(¤t_exe().ok()?) +} + +/// Similar to `finder(exe_path.parent()?)`, but try to canonicalize the path first. +fn _find_from(exe_path: &Path, finder: F) -> Option +where + F: Fn(&Path) -> Option, +{ + // Try to canonicalize the path first. + if let Ok(canonicalized_exe_path) = canonicalize(exe_path) { + if let Some(path) = finder(canonicalized_exe_path.parent()?) { + return Some(path); + }; + } + finder(exe_path.parent()?) } pub trait Ensure: Sized { @@ -147,40 +255,48 @@ impl Ensure for &Path { #[cfg(test)] mod tests { use super::*; + use std::env::{self, temp_dir}; mod get_dir { use super::*; - use std::env; + use std::fs::{create_dir_all, remove_dir_all, File}; + + lazy_static! { + static ref TEST_DIRS: Dirs = Dirs::new(ProjectDirs::from("com", "loong", "maa")); + } #[test] fn state_relative() { env::remove_var("XDG_STATE_HOME"); let project = ProjectDirs::from("com", "loong", "maa"); let home_dir = PathBuf::from(env::var_os("HOME").unwrap()); - let dirs = Dirs::new(project.clone()); if cfg!(target_os = "macos") { assert_eq!( - dirs.state(), + TEST_DIRS.state(), home_dir.join("Library/Application Support/com.loong.maa") ); assert_eq!( - dirs.log(), + TEST_DIRS.log(), home_dir.join("Library/Application Support/com.loong.maa/debug") ); } else if cfg!(target_os = "linux") { - assert_eq!(dirs.state(), home_dir.join(".local/state/maa")); - assert_eq!(dirs.log(), home_dir.join(".local/state/maa/debug")); + assert_eq!(TEST_DIRS.state(), home_dir.join(".local/state/maa")); + assert_eq!(TEST_DIRS.log(), home_dir.join(".local/state/maa/debug")); } + assert_eq!(state(), TEST_DIRS.state()); + assert_eq!(log(), TEST_DIRS.log()); env::set_var("XDG_STATE_HOME", "/xdg"); let dirs = Dirs::new(project.clone()); assert_eq!(dirs.state(), PathBuf::from("/xdg/maa")); assert_eq!(dirs.log(), PathBuf::from("/xdg/maa/debug")); + env::remove_var("XDG_STATE_HOME"); env::set_var("MAA_STATE_DIR", "/maa"); let dirs = Dirs::new(project.clone()); assert_eq!(dirs.state(), PathBuf::from("/maa")); assert_eq!(dirs.log(), PathBuf::from("/maa/debug")); + env::remove_var("MAA_STATE_DIR"); } #[test] @@ -188,24 +304,45 @@ mod tests { env::remove_var("XDG_DATA_HOME"); let project = ProjectDirs::from("com", "loong", "maa"); let home_dir = PathBuf::from(env::var_os("HOME").unwrap()); - let dirs = Dirs::new(project.clone()); if cfg!(target_os = "macos") { assert_eq!( - dirs.data(), + TEST_DIRS.data(), home_dir.join("Library/Application Support/com.loong.maa") ); assert_eq!( - dirs.library(), + TEST_DIRS.library(), home_dir.join("Library/Application Support/com.loong.maa/lib") ); assert_eq!( - dirs.resource(), + TEST_DIRS.resource(), home_dir.join("Library/Application Support/com.loong.maa/resource") ); } else if cfg!(target_os = "linux") { - assert_eq!(dirs.data(), home_dir.join(".local/share/maa")); - assert_eq!(dirs.library(), home_dir.join(".local/share/maa/lib")); - assert_eq!(dirs.resource(), home_dir.join(".local/share/maa/resource")); + assert_eq!(TEST_DIRS.data(), home_dir.join(".local/share/maa")); + assert_eq!(TEST_DIRS.library(), home_dir.join(".local/share/maa/lib")); + assert_eq!( + TEST_DIRS.resource(), + home_dir.join(".local/share/maa/resource") + ); + } + assert_eq!(data(), TEST_DIRS.data()); + assert_eq!(library(), TEST_DIRS.library()); + assert_eq!(resource(), TEST_DIRS.resource()); + // The value of `MAA_COER_VERSION` is set in CI, + // where the MaaCore is installed at standard location. + if env::var_os("MAA_CORE_INSTALLED").is_some() { + // This is not used in this test, but needed. + let extra_dir = Path::new("/usr/local/share/maa"); + assert_eq!( + TEST_DIRS.find_library(extra_dir).unwrap(), + TEST_DIRS.library() + ); + assert_eq!( + TEST_DIRS.find_resource(extra_dir).unwrap(), + TEST_DIRS.resource() + ); + assert_eq!(find_library().unwrap(), library()); + assert_eq!(find_resource().unwrap(), resource()); } env::set_var("XDG_DATA_HOME", "/xdg"); @@ -213,12 +350,103 @@ mod tests { assert_eq!(dirs.data(), PathBuf::from("/xdg/maa")); assert_eq!(dirs.library(), PathBuf::from("/xdg/maa/lib")); assert_eq!(dirs.resource(), PathBuf::from("/xdg/maa/resource")); + env::remove_var("XDG_DATA_HOME"); env::set_var("MAA_DATA_DIR", "/maa"); let dirs = Dirs::new(project.clone()); assert_eq!(dirs.data(), PathBuf::from("/maa")); assert_eq!(dirs.library(), PathBuf::from("/maa/lib")); assert_eq!(dirs.resource(), PathBuf::from("/maa/resource")); + env::remove_var("MAA_DATA_DIR"); + + // In this test case we use the Dirs instance created by former test case. + // Because the /maa directory not exists, and which shadow the installation + // of MaaCore, so we can test the situation that MaaCore is installed at + // non-standard location. + let test_root = temp_dir().join("maa-test-data"); + let test_root = canonicalize(test_root.ensure().unwrap()).unwrap(); + + // Test the situation that maa -> path, core -> path, resource -> path/resource + test_root.ensure_clean().unwrap(); + let bin_dir = test_root.clone(); + let library_dir = test_root.clone(); + let resource_dir = test_root.join("resource"); + bin_dir.ensure_clean().unwrap(); + library_dir.ensure_clean().unwrap(); + resource_dir.ensure_clean().unwrap(); + let bin_exe = bin_dir.join("maa"); + File::create(&bin_exe).unwrap(); + File::create(library_dir.join(MAA_CORE_LIB)).unwrap(); + assert_eq!(dirs.find_library(&bin_exe).unwrap(), library_dir); + assert_eq!(dirs.find_resource(&bin_exe).unwrap(), resource_dir); + + // Test the situation maa -> path/bin, core -> path/lib, resource -> path/share/maa + test_root.ensure_clean().unwrap(); + let bin_dir = test_root.join("bin"); + let library_dir = test_root.join("lib"); + let share_dir = test_root.join("share").join("maa"); + let resource_dir = share_dir.join("resource"); + bin_dir.ensure_clean().unwrap(); + library_dir.ensure_clean().unwrap(); + resource_dir.ensure_clean().unwrap(); + let bin_exe = bin_dir.join("maa"); + File::create(bin_dir.join("maa")).unwrap(); + File::create(library_dir.join(MAA_CORE_LIB)).unwrap(); + assert_eq!(dirs.find_library(&bin_exe).unwrap(), library_dir); + assert_eq!(dirs.find_resource(&bin_exe).unwrap(), resource_dir); + + if let Some(extra_share) = option_env!("MAA_EXTRA_SHARE_NAME") { + let extra_share_dir = test_root.join("share").join(extra_share); + let extra_resource_dir = extra_share_dir.join("resource"); + create_dir_all(&extra_resource_dir).unwrap(); + assert_eq!(dirs.find_resource(&bin_exe).unwrap(), extra_resource_dir); + remove_dir_all(&extra_share_dir).unwrap(); + } + + // Test the situation that maa linked + #[cfg(target_os = "macos")] + { + use std::os::unix::fs::symlink; + + test_root.ensure_clean().unwrap(); + + // Test the situation that maa -> path/cellar/bin, core -> path/cellar/lib, + // resource -> path/share/maa, and maa is linked to path/bin. + let cellar = test_root.join("Cellar"); + let bin_dir = cellar.join("bin"); + let library_dir = cellar.join("lib"); + let share_dir = test_root.join("share").join("maa"); + let resource_dir = share_dir.join("resource"); + let linked_dir = test_root.join("bin"); + bin_dir.ensure_clean().unwrap(); + library_dir.ensure_clean().unwrap(); + resource_dir.ensure_clean().unwrap(); + linked_dir.ensure_clean().unwrap(); + let bin_exe = bin_dir.join("maa"); + let linked_exe = linked_dir.join("maa"); + File::create(&bin_exe).unwrap(); + File::create(library_dir.join(MAA_CORE_LIB)).unwrap(); + symlink(&bin_exe, &linked_exe).unwrap(); + assert_eq!(dirs.find_library(&linked_exe).unwrap(), library_dir); + assert_eq!(dirs.find_resource(&linked_exe).unwrap(), resource_dir); + // Test the situation that maa -> path/cellar/bin, core -> path/lib, resource -> path/share/maa, + // and maa is linked to path/bin. + + // remove old dirs + remove_dir_all(&library_dir).unwrap(); + remove_dir_all(&share_dir).unwrap(); + + let library_dir = test_root.join("lib"); + let share_dir = test_root.join("share").join("maa"); + let resource_dir = share_dir.join("resource"); + std::fs::create_dir_all(&library_dir).unwrap(); + std::fs::create_dir_all(&resource_dir).unwrap(); + File::create(library_dir.join(MAA_CORE_LIB)).unwrap(); + assert_eq!(dirs.find_library(&linked_exe).unwrap(), library_dir); + assert_eq!(dirs.find_resource(&linked_exe).unwrap(), resource_dir); + } + + remove_dir_all(&test_root).unwrap(); } #[test] @@ -226,15 +454,15 @@ mod tests { env::remove_var("XDG_CONFIG_HOME"); let project = ProjectDirs::from("com", "loong", "maa"); let home_dir = PathBuf::from(env::var_os("HOME").unwrap()); - let dirs = Dirs::new(project.clone()); if cfg!(target_os = "macos") { assert_eq!( - dirs.config(), + TEST_DIRS.config(), home_dir.join("Library/Application Support/com.loong.maa/config") ); } else if cfg!(target_os = "linux") { - assert_eq!(dirs.config(), home_dir.join(".config/maa")); + assert_eq!(TEST_DIRS.config(), home_dir.join(".config/maa")); } + assert_eq!(config(), TEST_DIRS.config()); env::set_var("XDG_CONFIG_HOME", "/xdg"); let dirs = Dirs::new(project.clone()); @@ -250,12 +478,15 @@ mod tests { env::remove_var("XDG_CACHE_HOME"); let project = ProjectDirs::from("com", "loong", "maa"); let home_dir = PathBuf::from(env::var_os("HOME").unwrap()); - let dirs = Dirs::new(project.clone()); if cfg!(target_os = "macos") { - assert_eq!(dirs.cache(), home_dir.join("Library/Caches/com.loong.maa")); + assert_eq!( + TEST_DIRS.cache(), + home_dir.join("Library/Caches/com.loong.maa") + ); } else if cfg!(target_os = "linux") { - assert_eq!(dirs.cache(), home_dir.join(".cache/maa")); + assert_eq!(TEST_DIRS.cache(), home_dir.join(".cache/maa")); } + assert_eq!(cache(), TEST_DIRS.cache()); env::set_var("XDG_CACHE_HOME", "/xdg"); let dirs = Dirs::new(project.clone()); @@ -266,4 +497,14 @@ mod tests { assert_eq!(dirs.cache(), PathBuf::from("/maa")); } } + + #[test] + fn ensure() { + let test_root = temp_dir().join("maa-test-ensure"); + let test_dir = test_root.join("test"); + assert_eq!(test_root.ensure_clean().unwrap(), test_root); + assert!(!test_dir.exists()); + assert_eq!(test_dir.ensure().unwrap(), test_dir); + assert!(test_dir.exists()); + } } 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..f7219f16 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::{self, 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(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.zip", + "size": 123456, + "sha256sum": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "aarch64-unknown-linux-gnu": { + "name": "maa-cli.zip", + "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..4a1fd42a 100644 --- a/maa-cli/src/installer/maa_core.rs +++ b/maa-cli/src/installer/maa_core.rs @@ -1,64 +1,70 @@ // 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::{ - dirs::{Dirs, Ensure}, - run, + config::{ + cli::{ + maa_core::{CommonArgs, Components, Config}, + InstallerConfig, + }, + Error as ConfigError, FindFile, + }, + consts::MAA_CORE_LIB, + debug, + dirs::{self, Ensure}, + 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 +72,353 @@ fn extract_mapper( _ => continue, } } + debug!("Ignore file:", src.display()); None } -impl MaaCore { - pub fn new(channel: Channel) -> Self { - Self { channel } - } - - 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") - } - - pub fn install(&self, dirs: &Dirs, force: bool, no_resource: bool, t: u64) -> Result<()> { - let lib_dir = &dirs.library().ensure()?; - - 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") - } +pub fn version() -> Result { + let ver_str = run::core_version()?.trim(); + Version::parse(&ver_str[1..]).context("Failed to parse version") +} - 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()?; +fn get_config(args: &CommonArgs) -> Result { + match InstallerConfig::find_file(&dirs::config().join("cli")) { + Ok(config) => { + let mut config = config.core_config(); + config.apply_args(args); + Ok(config) } - let archive = asset.download(cache_dir, t)?; - archive.extract(|path: &Path| extract_mapper(path, lib_dir, resource_dir, !no_resource))?; - - Ok(()) + Err(ConfigError::FileNotFound(_)) => Ok(Config::default()), + Err(e) => Err(e), } +} - 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(()); - } +pub fn install(force: bool, args: &CommonArgs) -> Result<()> { + let config = get_config(args)?; - println!( - "Found newer MaaCore version: v{} (current: v{}), downloading...", - last_version, current_version - ); + let lib_dir = dirs::library(); - 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))?; + 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") + } - Ok(()) + 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))?; -#[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, + Ok(()) } -impl From<&Channel> for &str { - fn from(channel: &Channel) -> Self { - match channel { - Channel::Stable => "stable", - Channel::Beta => "beta", - Channel::Alpha => "alpha", - } +pub fn update(args: &CommonArgs) -> Result<()> { + let config = get_config(args)?; + + let components = config.components(); + // Check if any component is specified + if !(components.library || components.resource) { + bail!("No component specified, aborting"); + } + // Check if MaaCore is installed and installed by maa + let lib_dir = dirs::library(); + let resource_dir = dirs::resource(); + 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() + ), + _ => {} + } + 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()?; + 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" + } } + "#; + + let version_json: VersionJSON
= + serde_json::from_str(json_str).expect("Failed to parse json"); + + assert!(version_json + .can_update("MaaCore", &Version::parse("4.26.0").unwrap()) + .unwrap()); + assert!(version_json + .can_update("MaaCore", &Version::parse("4.26.1-beta.1").unwrap()) + .unwrap()); + assert!(!version_json + .can_update("MaaCore", &Version::parse("4.27.0").unwrap()) + .unwrap()); + + assert_eq!( + version_json.version(), + &Version::parse("4.26.1").expect("Failed to parse version") + ); -pub fn find_resource(dirs: &Dirs) -> Option { - let resource_dir = dirs.resource(); - if resource_dir.exists() { - return Some(resource_dir.to_path_buf()); - } + let details = version_json.details(); + let asset_name = name(version_json.version()).unwrap(); + let asset = details.asset(&asset_name).unwrap(); - 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); + // 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..95b85f21 100644 --- a/maa-cli/src/main.rs +++ b/maa-cli/src/main.rs @@ -1,22 +1,26 @@ 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}; use clap_complete::{generate, Shell}; -use directories::ProjectDirs; #[derive(Parser)] #[command(name = "maa", author, version)] @@ -40,34 +44,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: cli::maa_core::CommonArgs, /// Force to install even if the maa and resource already exists /// /// If the maa-core and resource already exists, @@ -79,26 +66,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 +75,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: cli::maa_core::CommonArgs, }, /// 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 +146,25 @@ 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, + }, } #[derive(ValueEnum, Clone, Default)] @@ -253,9 +203,6 @@ pub enum Dir { } fn main() -> Result<()> { - let proj = ProjectDirs::from("com", "loong", "maa"); - let proj_dirs = dirs::Dirs::new(proj); - let cli = CLI::parse(); let subcommand = cli.command; @@ -265,79 +212,78 @@ 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 { force, common } => { + maa_core::install(force, &common)?; } - 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 } => { + maa_core::update(&common)?; } - #[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(&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(&cli_config)?; } }, SubCommand::Dir { dir_type } => match dir_type { - Dir::Data => println!("{}", proj_dirs.data().display()), + Dir::Data => println!("{}", dirs::data().display()), Dir::Library => { println!( "{}", - maa_core::find_lib_dir(&proj_dirs) - .context("Library not found")? - .display() + dirs::find_library().context("Library not found")?.display() ) } - Dir::Config => println!("{}", proj_dirs.config().display()), - Dir::Cache => println!("{}", proj_dirs.cache().display()), Dir::Resource => { println!( "{}", - maa_core::find_resource(&proj_dirs) + dirs::find_resource() .context("Resource not found")? .display() ) } - Dir::Log => println!("{}", proj_dirs.log().display()), + Dir::Config => println!("{}", dirs::config().display()), + Dir::Cache => println!("{}", dirs::cache().display()), + Dir::Log => println!("{}", dirs::log().display()), }, SubCommand::Version { component } => match component { Component::All => { println!("maa-cli v{}", env!("CARGO_PKG_VERSION")); - println!("MaaCore {}", run::core_version(&proj_dirs)?); + println!("MaaCore {}", run::core_version()?); } Component::MaaCLI => { println!("maa-cli v{}", env!("CARGO_PKG_VERSION")); } Component::MaaCore => { - println!("MaaCore {}", run::core_version(&proj_dirs)?); + println!("MaaCore {}", run::core_version()?); } }, - SubCommand::Run { task, common } => run::run(&proj_dirs, task, common)?, + SubCommand::Run { task, common } => run::run(task, common)?, SubCommand::Fight { startup, closedown, common, - } => run::fight(&proj_dirs, startup, closedown, common)?, + } => run::fight(startup, closedown, common)?, SubCommand::List => { - let task_dir = proj_dirs.config().join("tasks"); + let task_dir = dirs::config().join("tasks"); if !task_dir.exists() { - println!("No tasks found"); + eprintln!("No tasks found"); } else { for entry in task_dir.read_dir()? { let entry = entry?; @@ -365,35 +311,59 @@ mod test { #[test] fn log_level() { - assert!(matches!(CLI::parse_from(["maa", "-v", "help"]).verbose, 1)); - assert!(matches!(CLI::parse_from(["maa", "help", "-v"]).verbose, 1)); - assert!(matches!(CLI::parse_from(["maa", "help", "-vv"]).verbose, 2)); - assert!(matches!(CLI::parse_from(["maa", "help", "-q"]).quiet, 1)); - assert!(matches!(CLI::parse_from(["maa", "help", "-qq"]).quiet, 2)); + assert!(matches!(CLI::parse_from(["maa", "-v", "list"]).verbose, 1)); + assert!(matches!(CLI::parse_from(["maa", "list", "-v"]).verbose, 1)); + assert!(matches!(CLI::parse_from(["maa", "list", "-vv"]).verbose, 2)); + assert!(matches!(CLI::parse_from(["maa", "list", "-q"]).quiet, 1)); + assert!(matches!(CLI::parse_from(["maa", "list", "-qq"]).quiet, 2)); } + #[cfg(feature = "core_installer")] #[test] fn install() { assert!(matches!( CLI::parse_from(["maa", "install"]).command, - SubCommand::Install { .. } + SubCommand::Install { + common: cli::maa_core::CommonArgs { + 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: cli::maa_core::CommonArgs { + channel: Some(Channel::Beta), + .. + }, .. } )); assert!(matches!( CLI::parse_from(["maa", "install", "-t5"]).command, - SubCommand::Install { test_time: 5, .. } + SubCommand::Install { + common: cli::maa_core::CommonArgs { + test_time: Some(5), + .. + }, + .. + } )); assert!(matches!( CLI::parse_from(["maa", "install", "--test-time", "5"]).command, - SubCommand::Install { test_time: 5, .. } + SubCommand::Install { + common: cli::maa_core::CommonArgs { + test_time: Some(5), + .. + }, + .. + } )); assert!(matches!( @@ -404,55 +374,49 @@ mod test { assert!(matches!( CLI::parse_from(["maa", "install", "--no-resource"]).command, SubCommand::Install { - no_resource: true, + common: cli::maa_core::CommonArgs { + 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: cli::maa_core::CommonArgs { + 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), + .. + }) )); } diff --git a/maa-cli/src/run/fight.rs b/maa-cli/src/run/fight.rs index a2d0f297..12506de2 100644 --- a/maa-cli/src/run/fight.rs +++ b/maa-cli/src/run/fight.rs @@ -4,13 +4,12 @@ use crate::{ value::input::{BoolInput, Input, Select}, Task, TaskConfig, Value, }, - dirs::Dirs, object, }; use super::{run, CommonArgs, Result}; -pub fn fight(dirs: &Dirs, startup: bool, closedown: bool, common: CommonArgs) -> Result<()> { +pub fn fight(startup: bool, closedown: bool, common: CommonArgs) -> Result<()> { let mut task_config = TaskConfig::new(); if startup { @@ -42,5 +41,5 @@ pub fn fight(dirs: &Dirs, startup: bool, closedown: bool, common: CommonArgs) -> )); } - run(dirs, task_config, common) + run(task_config, common) } diff --git a/maa-cli/src/run/mod.rs b/maa-cli/src/run/mod.rs index b5e68ebf..64f151eb 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, }, - dirs::{Dirs, Ensure}, - installer::maa_core::{find_lib_dir, find_resource, MAA_CORE_NAME}, + consts::MAA_CORE_LIB, + dirs::{self, Ensure}, log::{set_level, LogLevel}, {debug, normal, warning}, }; @@ -92,7 +92,7 @@ pub struct CommonArgs { pub dry_run: bool, } -pub fn run(dirs: &Dirs, task: impl Into, args: CommonArgs) -> Result<()> { +pub fn run(task: impl Into, args: CommonArgs) -> Result<()> { if args.dry_run { unsafe { set_level(LogLevel::Debug) }; debug!("Dryrun mode!"); @@ -104,9 +104,9 @@ 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 state_dir = dirs::state().ensure()?; + let config_dir = dirs::config().ensure()?; + 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()); @@ -336,7 +336,7 @@ pub fn run(dirs: &Dirs, task: impl Into, args: CommonArgs) -> Result<() /*----------------------- Start Assistant ----------------------*/ // Load MaaCore - load_core(dirs); + load_core(); // Set user directory (some debug info and cache will be stored here) // Must be called any other function (set_static_option, load_resource, etc.) @@ -432,8 +432,8 @@ pub fn run(dirs: &Dirs, task: impl Into, args: CommonArgs) -> Result<() Ok(()) } -pub fn core_version<'a>(dirs: &Dirs) -> Result<&'a str> { - load_core(dirs); +pub fn core_version<'a>() -> Result<&'a str> { + load_core(); Ok(Assistant::get_version()?) } @@ -552,8 +552,9 @@ fn process_resource_dir(path: PathBuf) -> Option { } } -fn load_core(dirs: &Dirs) { - if let Some(lib_dir) = find_lib_dir(dirs) { +fn load_core() { + 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); } } @@ -607,4 +609,17 @@ mod tests { assert_eq!(ClientType::YoStarKR.resource(), Some("YoStarKR")); } } + + mod run { + use std::env; + + use super::*; + + #[test] + fn run_version() { + if let Ok(version) = env::var("MAA_CORE_VERSION") { + assert_eq!(core_version().unwrap(), version); + } + } + } }