From c6dafc022b8d21009ba97fe07a797045557633ca Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:33:34 +0100 Subject: [PATCH 01/31] Update actions/checkout action to v7 (#1800) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/audit.yml | 2 +- .github/workflows/build.yml | 10 +++++----- .github/workflows/release.yml | 4 ++-- .github/workflows/renovate.yml | 2 +- .github/workflows/update-graphql-schema.yml | 2 +- .github/workflows/update-inno-dependencies.yml | 2 +- .github/workflows/vhs.yml | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 5990e6b0e..7929d45c6 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install cargo-audit uses: taiki-e/install-action@v2 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a98bd833..1b89e912c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -52,7 +52,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -74,7 +74,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -88,7 +88,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: Swatinem/rust-cache@v2 with: @@ -114,7 +114,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Check for unused dependencies uses: bnjbvr/cargo-machete@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb61c3708..b7d4e9844 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: runs-on: ${{ matrix.platform.os }} steps: - name: Clone repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: mlugg/setup-zig@v2 if: contains(matrix.platform.bins, 'cargo-zigbuild') @@ -207,7 +207,7 @@ jobs: id-token: write steps: - name: Clone repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 5fb748d8d..bc4a89a8b 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Validate renovate.json run: renovate-config-validator diff --git a/.github/workflows/update-graphql-schema.yml b/.github/workflows/update-graphql-schema.yml index 3aa1d9903..e32ab9f13 100644 --- a/.github/workflows/update-graphql-schema.yml +++ b/.github/workflows/update-graphql-schema.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Download latest GitHub GraphQL Schema run: | diff --git a/.github/workflows/update-inno-dependencies.yml b/.github/workflows/update-inno-dependencies.yml index ff7446127..a398ad9a1 100644 --- a/.github/workflows/update-inno-dependencies.yml +++ b/.github/workflows/update-inno-dependencies.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Download latest Inno Setup Dependency Installer script run: | diff --git a/.github/workflows/vhs.yml b/.github/workflows/vhs.yml index ca84c021b..631083477 100644 --- a/.github/workflows/vhs.yml +++ b/.github/workflows/vhs.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install Rust uses: moonrepo/setup-rust@v1 From c33bc624198713b15d7344b5f5fbaeabef893371 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Wed, 15 Apr 2026 20:28:19 -0400 Subject: [PATCH 02/31] Anthelion Changes --- .cargo/config.toml | 22 + .gitattributes | 22 +- .github/workflows/audit.yml | 26 - .github/workflows/build.yml | 120 ----- .github/workflows/release.yml | 233 --------- .github/workflows/release_anthelion.yml | 287 +++++++++++ .github/workflows/renovate.yml | 22 - .github/workflows/setup-dev-drive.ps1 | 82 +++ .github/workflows/update-graphql-schema.yml | 61 --- .../workflows/update-inno-dependencies.yml | 34 -- .github/workflows/vhs.yml | 79 --- .gitignore | 71 +++ Cargo.lock | 206 +++++++- Cargo.toml | 22 +- build.rs | 3 + bun.lock | 229 +++++++++ package.json | 43 ++ src/analysis/analyzer.rs | 8 + src/analysis/installers/exe.rs | 22 + src/analysis/installers/zip.rs | 147 +++++- src/anthelion/analyze_installer.rs | 120 +++++ src/anthelion/get_existing_pull_request.rs | 74 +++ src/anthelion/github_configuration.rs | 62 +++ src/anthelion/mod.rs | 20 + src/anthelion/release_notes.rs | 127 +++++ src/anthelion/token.rs | 39 ++ src/anthelion/types.rs | 149 ++++++ src/anthelion/update_helpers.rs | 89 ++++ src/anthelion/update_version.rs | 467 ++++++++++++++++++ src/commands/complete.rs | 28 +- src/commands/new_version.rs | 18 +- src/commands/remove_dead_versions.rs | 2 +- src/commands/remove_version.rs | 16 + src/commands/update_version.rs | 7 + src/download/downloader.rs | 26 +- .../graphql/get_existing_pull_request.rs | 85 ++-- src/github/graphql/update_refs.rs | 4 +- src/github/utils/mod.rs | 83 ++-- src/lib.rs | 19 + src/main.rs | 8 +- src/manifests/mod.rs | 7 +- src/traits/mod.rs | 66 ++- 42 files changed, 2542 insertions(+), 713 deletions(-) create mode 100644 .cargo/config.toml delete mode 100644 .github/workflows/audit.yml delete mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/release_anthelion.yml delete mode 100644 .github/workflows/renovate.yml create mode 100644 .github/workflows/setup-dev-drive.ps1 delete mode 100644 .github/workflows/update-graphql-schema.yml delete mode 100644 .github/workflows/update-inno-dependencies.yml delete mode 100644 .github/workflows/vhs.yml create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/anthelion/analyze_installer.rs create mode 100644 src/anthelion/get_existing_pull_request.rs create mode 100644 src/anthelion/github_configuration.rs create mode 100644 src/anthelion/mod.rs create mode 100644 src/anthelion/release_notes.rs create mode 100644 src/anthelion/token.rs create mode 100644 src/anthelion/types.rs create mode 100644 src/anthelion/update_helpers.rs create mode 100644 src/anthelion/update_version.rs create mode 100644 src/lib.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..676007d1b --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,22 @@ +[target.'cfg(any(target_env = "msvc", target_env = "musl"))'] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.'cfg(target_arch = "x86_64")'] +rustflags = ["-C", "target-cpu=x86-64-v3"] + +[target.'cfg(all(target_arch = "aarch64", not(target_vendor = "apple")))'] +rustflags = ["-C", "target-cpu=cortex-a76"] + +[target.'cfg(all(windows, target_env = "msvc"))'] +linker = "rust-lld" + +# Avoid linking with vcruntime140.dll by statically linking everything, +# and then explicitly linking with ucrtbase.dll dynamically. +# We do this, because vcruntime140.dll is an optional Windows component. +[target.'cfg(all(target_os = "windows", target_env = "msvc"))'] +rustflags = [ + "-Clink-args=/DEFAULTLIB:ucrt.lib", + "-Clink-args=/NODEFAULTLIB:vcruntime.lib", + "-Clink-args=/NODEFAULTLIB:msvcrt.lib", + "-Clink-args=/NODEFAULTLIB:libucrt.lib", +] diff --git a/.gitattributes b/.gitattributes index 24db350c9..8baee6069 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,18 @@ -# Auto detect text files and perform normalization -* text=auto +# Auto detect text files and perform LF normalization +* text=auto -*.rs text diff=rust -*.toml text diff=toml -Cargo.lock text + +*.ts text eol=lf merge=union +*.tsx text eol=lf merge=union +*.rs text eol=lf merge=union +*.js text eol=lf merge=union +*.json text eol=lf merge=union +*.debug text eol=lf merge=union + +# Generated codes +index.js linguist-detectable=false +index.d.ts linguist-detectable=false +anthelion-komac.wasi-browser.js linguist-detectable=false +anthelion-komac.wasi.cjs linguist-detectable=false +wasi-worker-browser.mjs linguist-detectable=false +wasi-worker.mjs linguist-detectable=false diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml deleted file mode 100644 index 7929d45c6..000000000 --- a/.github/workflows/audit.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Security audit - -on: - schedule: - - cron: '0 0 * * *' - -permissions: - issues: none - checks: write - -jobs: - security_audit: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Install cargo-audit - uses: taiki-e/install-action@v2 - with: - tool: cargo-audit - - - uses: rustsec/audit-check@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 1b89e912c..000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Cargo Build & Test - -on: - push: - paths: - - .github/workflows/build.yml - - assets/** - - src/** - - Cargo.toml - - Cargo.lock - - build.rs - pull_request: - paths: - - .github/workflows/build.yml - - assets/** - - src/** - - Cargo.toml - - Cargo.lock - - build.rs - -env: - CARGO_TERM_COLOR: always - -jobs: - build_and_test: - name: Rust project - latest - strategy: - matrix: - os: [ ubuntu-latest, windows-latest, macos-latest ] - runs-on: ${{ matrix.os }} - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Check - run: cargo check - - - name: Test - run: cargo test - - clippy: - name: Cargo clippy - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - uses: Swatinem/rust-cache@v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Run clippy - run: cargo clippy - env: - RUSTFLAGS: "-D warnings" - - publish_dry_run: - name: Publish dry run - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Publish dry run - run: cargo publish --dry-run --no-verify - - msrv: - name: MSRV - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - uses: Swatinem/rust-cache@v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-bin: false - - - name: Install cargo-msrv - uses: taiki-e/install-action@v2 - with: - tool: cargo-msrv - - - name: Verify MSRV - id: verify - run: cargo msrv verify - - - name: Find actual MSRV - if: ${{ failure() && steps.verify.conclusion == 'failure' }} - run: cargo msrv find - - unused_dependencies: - name: Unused dependencies - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Check for unused dependencies - uses: bnjbvr/cargo-machete@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index b7d4e9844..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,233 +0,0 @@ -name: Release - -on: - schedule: - - cron: "0 0 * * 0" - release: - types: [ released ] - -permissions: - contents: write - -env: - CARGO_TERM_COLOR: always - -jobs: - release: - name: Release - ${{ matrix.platform.target }} - strategy: - fail-fast: false - matrix: - platform: - - os: windows-2025 - target: x86_64-pc-windows-msvc - inno_arch: x64 - - os: windows-2025 - target: aarch64-pc-windows-msvc - inno_arch: arm64 - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - bins: cargo-zigbuild, cargo-deb, cargo-generate-rpm - - os: ubuntu-latest - target: aarch64-unknown-linux-gnu - bins: cargo-zigbuild, cargo-deb, cargo-generate-rpm - - os: macos-latest - target: x86_64-apple-darwin - - os: macos-latest - target: aarch64-apple-darwin - - runs-on: ${{ matrix.platform.os }} - steps: - - name: Clone repository - uses: actions/checkout@v7 - - - uses: mlugg/setup-zig@v2 - if: contains(matrix.platform.bins, 'cargo-zigbuild') - - - uses: moonrepo/setup-rust@v1 - with: - targets: ${{ matrix.platform.target }} - bins: ${{ matrix.platform.bins }} - cache: false - - - name: Build binary - shell: bash - env: - GLIBC_VERSION: 2.35 - run: | - if [[ "${{ matrix.platform.bins }}" == *"zigbuild"* ]]; then - build="cargo zigbuild --target ${{ matrix.platform.target }}.${GLIBC_VERSION}" - else - build="cargo build --target ${{ matrix.platform.target }}" - fi - eval "$build --release --locked" - - - name: Create Deb package - if: contains(matrix.platform.bins, 'cargo-deb') - shell: pwsh - run: | - $nightly = if ($env:GITHUB_EVENT_NAME -ne 'release') { "--deb-version=2.nightly" } else { "" } - cargo deb --target ${{ matrix.platform.target }} --no-build --no-strip $nightly - - - name: Create RPM package - if: contains(matrix.platform.bins, 'cargo-generate-rpm') - shell: pwsh - run: | - if ($env:GITHUB_EVENT_NAME -ne 'release') { - cargo generate-rpm --target ${{ matrix.platform.target }} --set-metadata='version="nightly"' - } else { - cargo generate-rpm --target ${{ matrix.platform.target }} - } - - - name: Rename binary - id: rename_binary - shell: pwsh - run: | - $bin = if ($env:RUNNER_OS -eq 'Windows') { "komac.exe" } else { "komac" } - $newName = "komac" - if ($env:GITHUB_EVENT_NAME -ne 'release') { - $newName += "-nightly" - } else { - $newName += "-$($env:GITHUB_REF_NAME.TrimStart('v'))" - } - $newName += "-${{ matrix.platform.target }}" - if ($env:RUNNER_OS -eq 'Windows') { - $newName += ".exe" - Rename-Item -Path "target/${{ matrix.platform.target }}/release/$bin" -NewName "$newName" - } - "name=$newName" >> $env:GITHUB_OUTPUT - - - name: Create Tar archive - if: ${{ runner.os != 'Windows' }} - run: | - tar -cvf ${{ steps.rename_binary.outputs.name }}.tar.gz \ - --use-compress-program="gzip -9" \ - -C target/${{ matrix.platform.target }}/release komac \ - -C ../../../ LICENSE.md - rm target/${{ matrix.platform.target }}/release/komac - - - name: Create Inno Installer - if: ${{ runner.os == 'Windows' }} - shell: pwsh - run: | - winget install --exact JRSoftware.InnoSetup --scope machine --silent --accept-package-agreements --accept-source-agreements --disable-interactivity - $input = "../../target/${{ matrix.platform.target }}/release/${{ steps.rename_binary.outputs.name }}" - & "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" assets/inno/installer.iss ` - /DInputExecutable=$input ` - /DArchitecture=${{ matrix.platform.inno_arch }} ` - /F$("${{ steps.rename_binary.outputs.name }}".Replace("komac", "komac-setup").TrimEnd(".exe")) - - - name: Set Release variables - id: set_release_vars - shell: pwsh - run: | - if ($env:GITHUB_EVENT_NAME -ne 'release') { - "name=Nightly release" >> $env:GITHUB_OUTPUT - "tag_name=nightly" >> $env:GITHUB_OUTPUT - "prerelease=true" >> $env:GITHUB_OUTPUT - "generate_release_notes=false" >> $env:GITHUB_OUTPUT - } else { - "prerelease=false" >> $env:GITHUB_OUTPUT - "generate_release_notes=true" >> $env:GITHUB_OUTPUT - } - - - name: Release binaries - uses: softprops/action-gh-release@v3 - with: - name: ${{ steps.set_release_vars.outputs.name }} - tag_name: ${{ steps.set_release_vars.outputs.tag_name }} - prerelease: ${{ steps.set_release_vars.outputs.prerelease }} - generate_release_notes: ${{ steps.set_release_vars.outputs.generate_release_notes }} - files: | - target/${{ matrix.platform.target }}/release/${{ steps.rename_binary.outputs.name }} - target/${{ matrix.platform.target }}/debian/*.deb - target/${{ matrix.platform.target }}/generate-rpm/*.rpm - ${{ steps.rename_binary.outputs.name }}.tar.gz - assets/inno/Output/* - - generate-sha256sums: - name: Generate SHA256SUMS - needs: release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Set tag name - id: set_tag - run: | - if [ "${{ github.event_name }}" != "release" ]; then - echo "tag=nightly" >> $GITHUB_OUTPUT - else - echo "tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT - fi - - - name: Download release assets - uses: robinraju/release-downloader@v1 - with: - repository: ${{ github.repository }} - tag: ${{ steps.set_tag.outputs.tag }} - fileName: "*" - out-file-path: ./release-assets - - - name: Compute SHA256SUMS - run: | - cd release-assets - rm --force SHA256SUMS - sha256sum --binary * > SHA256SUMS - cat SHA256SUMS - - - name: Upload SHA256SUMS to release - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ steps.set_tag.outputs.tag }} - files: release-assets/SHA256SUMS - - upload-virus-total: - needs: release - if: ${{ github.event_name == 'release' }} - runs-on: ubuntu-latest - steps: - - name: VirusTotal Scan - uses: crazy-max/ghaction-virustotal@v5 - with: - vt_api_key: ${{ secrets.VT_API_KEY }} - request_rate: 4 - files: | - .exe$ - .deb$ - .rpm$ - .tar.gz$ - - publish-crates-io: - needs: release - if: ${{ github.event_name == 'release' }} - runs-on: ubuntu-latest - environment: release - permissions: - id-token: write - steps: - - name: Clone repository - uses: actions/checkout@v7 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Retrieve temporary crates.io token - uses: rust-lang/crates-io-auth-action@v1 - id: auth - - - name: Publish to crates.io - run: cargo publish - env: - CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} - - publish-winget: - needs: [ release, publish-crates-io ] - if: ${{ github.event_name == 'release' }} - runs-on: ubuntu-latest - steps: - - uses: vedantmgoyal9/winget-releaser@main - with: - identifier: RussellBanks.Komac - installers-regex: 'komac-setup.*\.exe$' - token: ${{ secrets.WINGET_TOKEN }} diff --git a/.github/workflows/release_anthelion.yml b/.github/workflows/release_anthelion.yml new file mode 100644 index 000000000..18d22c48b --- /dev/null +++ b/.github/workflows/release_anthelion.yml @@ -0,0 +1,287 @@ +name: Release + +on: + workflow_dispatch: + +env: + DEBUG: napi:* + APP_NAME: anthelion-komac + DEV_DRIVE_WORKSPACE: ${{ github.workspace }} + +defaults: + run: + shell: bash -euo pipefail {0} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + build-cli: + name: Build CLI - ${{ matrix.platform.target }} + + strategy: + fail-fast: false + + # Cross compiling from arm64 runners is faster than compiling on x64 runners + matrix: + platform: + - os: windows-11-arm + target: x86_64-pc-windows-msvc + - os: windows-11-arm + target: aarch64-pc-windows-msvc + - os: ubuntu-24.04-arm + target: x86_64-unknown-linux-musl + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + - os: macos-26 + target: aarch64-apple-darwin + - os: macos-26 + target: x86_64-apple-darwin + + runs-on: ${{ matrix.platform.os }} + + env: + RUST_TARGET: ${{ matrix.platform.target }} + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Setup Dev Drive + if: ${{ contains(matrix.platform.os, 'windows') }} + uses: samypr100/setup-dev-drive@30f0f98ae5636b2b6501e181dfb3631b9974818d # v4.0.0 + with: + workspace-copy: true + drive-size: 12GB + drive-format: ReFS + env-mapping: | + CARGO_HOME,{{ DEV_DRIVE }}/.cargo + RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup + + - parallel: + - name: Setup Rust + uses: moonrepo/setup-rust@abb2d32350334249b178c401e5ec5836e0cd88d3 # v1.3.0 + with: + channel: nightly + targets: ${{ matrix.platform.target }} + cache: false + + - name: Setup Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + if: ${{ contains(matrix.platform.target, 'linux') }} + with: + version: master + use-cache: false + + - name: Install Tools + uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2.82.5 + if: ${{ contains(matrix.platform.target, 'linux') }} + with: + tool: cargo-zigbuild + + - name: Build Binary + working-directory: ${{ env.DEV_DRIVE_WORKSPACE }} + run: | + [[ "${RUNNER_OS}" == "Linux" ]] && subcommand="zigbuild" || subcommand="build" + + cargo "${subcommand}" --target "${RUST_TARGET}" --bin komac --release --locked + + - name: Package Binary + id: package + working-directory: ${{ env.DEV_DRIVE_WORKSPACE }} + run: | + bin="komac" + artifact_name="komac-nightly-${RUST_TARGET}" + + if [[ "${RUNNER_OS}" == "Windows" ]]; then + bin="komac.exe" + artifact_name+=".exe" + mv "target/${RUST_TARGET}/release/${bin}" "${artifact_name}" + echo "file=${artifact_name}" >> "$GITHUB_OUTPUT" + else + archive_name="${artifact_name}.tar.zst" + tar -cvf "${archive_name}" \ + --use-compress-program="zstd" \ + -C "target/${RUST_TARGET}/release" \ + "${bin}" + echo "file=${archive_name}" >> "$GITHUB_OUTPUT" + fi + + echo "artifact_name=cli-${RUST_TARGET}" >> "$GITHUB_OUTPUT" + + - name: Upload Artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.package.outputs.artifact_name }} + path: ${{ env.DEV_DRIVE_WORKSPACE }}/${{ steps.package.outputs.file }} + if-no-files-found: error + + build-js-bindings: + name: Build JS Bindings - ${{ matrix.platform.target }} + + strategy: + fail-fast: false + + # Cross compiling from arm64 runners is faster than compiling on x64 runners + matrix: + platform: + - os: windows-11-arm + target: x86_64-pc-windows-msvc + - os: windows-11-arm + target: aarch64-pc-windows-msvc + - os: ubuntu-24.04-arm + target: x86_64-unknown-linux-gnu + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + - os: ubuntu-24.04-arm + target: x86_64-unknown-linux-musl + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + - os: macos-26 + target: aarch64-apple-darwin + - os: macos-26 + target: x86_64-apple-darwin + + runs-on: ${{ matrix.platform.os }} + + env: + RUST_TARGET: ${{ matrix.platform.target }} + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Setup Dev Drive + if: ${{ contains(matrix.platform.os, 'windows') }} + uses: samypr100/setup-dev-drive@30f0f98ae5636b2b6501e181dfb3631b9974818d # v4.0.0 + with: + workspace-copy: true + drive-size: 12GB + drive-format: ReFS + env-mapping: | + CARGO_HOME,{{ DEV_DRIVE }}/.cargo + RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup + + - parallel: + - name: Setup Rust + uses: moonrepo/setup-rust@abb2d32350334249b178c401e5ec5836e0cd88d3 # v1.3.0 + with: + channel: nightly + targets: ${{ matrix.platform.target }} + cache: false + + - name: Setup Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + if: ${{ contains(matrix.platform.target, 'linux') }} + with: + version: master + use-cache: false + + - name: Install Tools + uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2.82.5 + if: ${{ contains(matrix.platform.target, 'linux') }} + with: + tool: cargo-zigbuild + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Install Dependencies + working-directory: ${{ env.DEV_DRIVE_WORKSPACE }} + run: bun ci + + - name: Build Binary + working-directory: ${{ env.DEV_DRIVE_WORKSPACE }} + run: | + [[ "${RUNNER_OS}" == "Linux" ]] && zigbuild_flag="-x" || zigbuild_flag="" + + bun run build --target "${RUST_TARGET}" ${zigbuild_flag} -- --locked + + - name: Upload Artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bindings-${{ matrix.platform.target }} + path: | + ${{ env.DEV_DRIVE_WORKSPACE }}/${{ env.APP_NAME }}.*.node + ${{ env.DEV_DRIVE_WORKSPACE }}/${{ env.APP_NAME }}.*.wasm + ${{ env.DEV_DRIVE_WORKSPACE }}/index.js + ${{ env.DEV_DRIVE_WORKSPACE }}/index.d.ts + if-no-files-found: error + + release: + name: Publish Release + + needs: + - build-cli + - build-js-bindings + + runs-on: ubuntu-slim + + permissions: + contents: write # Required to publish GitHub release + id-token: write # Required for npm Trusted Publishing + + steps: + - parallel: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Download Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: ./artifacts + + - parallel: + - name: Create Release + run: | + shopt -s extglob + gh release delete nightly --yes --cleanup-tag 2>/dev/null || true + sleep 5 # cli/cli#8458 + gh release create nightly \ + --target "anthelion" \ + --title "Anthelion komac Build" \ + --latest \ + --notes "Custom build of komac for use with [UnownPlain/anthelion](https://github.com/UnownPlain/anthelion)" \ + artifacts/**/!(*.js|*.ts|*.d.ts) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + + # Publish npm + + - name: Overwrite README + run: | + echo 'komac JS bindings for Anthelion' > README.md + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + no-cache: true + + - name: Copy JS files + run: cp artifacts/bindings-x86_64-unknown-linux-gnu/{index.js,index.d.ts} . + + - name: Install Dependencies + run: bun ci + + - name: Create npm Directories + run: bun napi create-npm-dirs + + - name: Move Artifacts + run: bun run artifacts + + - name: List Packages + run: ls -R ./npm + + - name: Publish to npm + run: | + npm publish --provenance --access public diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml deleted file mode 100644 index bc4a89a8b..000000000 --- a/.github/workflows/renovate.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Validate Renovate Config - -on: - push: - paths: - - .github/renovate.json - pull_request: - paths: - - .github/renovate.json - -jobs: - validate: - name: Validate Renovate Config - runs-on: ubuntu-latest - container: ghcr.io/renovatebot/renovate - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Validate renovate.json - run: renovate-config-validator diff --git a/.github/workflows/setup-dev-drive.ps1 b/.github/workflows/setup-dev-drive.ps1 new file mode 100644 index 000000000..c20e8cf66 --- /dev/null +++ b/.github/workflows/setup-dev-drive.ps1 @@ -0,0 +1,82 @@ +# Configures a drive for testing in CI. +# +# When using standard GitHub Actions runners, a `D:` drive is present and has +# similar or better performance characteristics than a ReFS dev drive. Sometimes +# using a larger runner is still more performant (e.g., when running the test +# suite) and we need to create a dev drive. This script automatically configures +# the appropriate drive. +# +# When using GitHub Actions' "larger runners", the `D:` drive is not present and +# we create a DevDrive mount on `C:`. This is purported to be more performant +# than an ReFS drive, though we did not see a change when we switched over. +# +# When using self-hosted runners, the underlying infrastructure may not +# support Hyper-V. The `New-VHD` commandlet only works with Hyper-V, but we can +# create a ReFS drive using `diskpart` and `format` directory. We cannot use a +# DevDrive, as that also requires Hyper-V. Self-hosted runners may use `D:` +# already, so we must check if it's self-hosted first, and we use `V:` as the +# target instead. + + +if ($env:RUNNER_ENVIRONMENT -eq "self-hosted") { + Write-Output "Self-hosted runner detected, setting up custom dev drive..." + + # Create VHD and configure drive using diskpart + $vhdPath = "C:\dev_drive.vhdx" + @" +create vdisk file="$vhdPath" maximum=25600 type=expandable +attach vdisk +create partition primary +active +assign letter=V +"@ | diskpart + + # Format the drive as ReFS + format V: /fs:ReFS /q /y + $Drive = "V:" + + Write-Output "Custom dev drive created at $Drive" +} elseif (Test-Path "D:\") { + # Note `Get-PSDrive` is not sufficient because the drive letter is assigned. + Write-Output "Using existing drive at D:" + $Drive = "D:" +} else { + # The size (25 GB) is chosen empirically to be large enough for our + # workflows; larger drives can take longer to set up. + $Volume = New-VHD -Path C:/dev_drive.vhdx -SizeBytes 25GB | + Mount-VHD -Passthru | + Initialize-Disk -Passthru | + New-Partition -AssignDriveLetter -UseMaximumSize | + Format-Volume -DevDrive -Confirm:$false -Force + + $Drive = "$($Volume.DriveLetter):" + + # Set the drive as trusted + # See https://learn.microsoft.com/en-us/windows/dev-drive/#how-do-i-designate-a-dev-drive-as-trusted + fsutil devdrv trust $Drive + + # Disable antivirus filtering on dev drives + # See https://learn.microsoft.com/en-us/windows/dev-drive/#how-do-i-configure-additional-filters-on-dev-drive + fsutil devdrv enable /disallowAv + + # Remount so the changes take effect + Dismount-VHD -Path C:/dev_drive.vhdx + Mount-VHD -Path C:/dev_drive.vhdx + + # Show some debug information + Write-Output $Volume + fsutil devdrv query $Drive + + Write-Output "Using Dev Drive at $Volume" +} + +# Prepare Cargo and rustup homes on the dev drive before setup-rust installs the toolchain. +New-Item -Path "$($Drive)/.cargo/bin" -ItemType Directory -Force +New-Item -Path "$($Drive)/.rustup" -ItemType Directory -Force + +Write-Output ` + "DEV_DRIVE=$($Drive)" ` + "RUSTUP_HOME=$($Drive)/.rustup" ` + "CARGO_HOME=$($Drive)/.cargo" ` + "PATH=$($Drive)/.cargo/bin;$env:PATH" ` + >> $env:GITHUB_ENV diff --git a/.github/workflows/update-graphql-schema.yml b/.github/workflows/update-graphql-schema.yml deleted file mode 100644 index e32ab9f13..000000000 --- a/.github/workflows/update-graphql-schema.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Update local copy of GraphQL schema - -on: - workflow_dispatch: - schedule: - - cron: '0 0 1 * *' - -jobs: - update-schema: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Clone repository - uses: actions/checkout@v7 - - - name: Download latest GitHub GraphQL Schema - run: | - curl \ - --location \ - --fail \ - --silent \ - --show-error \ - --dump-header headers.txt \ - --output assets/github.graphql \ - https://docs.github.com/public/fpt/schema.docs.graphql - - - - name: Get last modified date - id: schema_last_modified - run: | - last_modified=$( - grep --ignore-case '^last-modified:' headers.txt 2>/dev/null \ - | sed --expression 's/^[Ll]ast-[Mm]odified:[[:space:]]*//' \ - | tr --delete '\r' - ) - - rm --force headers.txt - - if [ -n "$last_modified" ]; then - echo "Schema Last-Modified header: ${last_modified}" - if ! last_modified=$(date --utc --date "$last_modified" '+%A %d %b %Y %H:%M:%S' 2>/dev/null); then - echo "Failed to parse Last-Modified date, using current UTC time" - last_modified=$(date --utc '+%A %d %b %Y %H:%M:%S') - fi - else - echo "No Last-Modified header found, using current UTC time" - last_modified=$(date --utc '+%A %d %b %Y %H:%M:%S') - fi - - echo "LAST_MODIFIED=${last_modified}" >> $GITHUB_OUTPUT - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 - with: - commit-message: "Update GitHub GraphQL Schema" - branch: update-github-graphql-schema - title: "Update GitHub GraphQL Schema" - body: "This is an automated pull request to update the local GitHub GraphQL schema as of ${{ steps.schema_last_modified.outputs.LAST_MODIFIED }}" diff --git a/.github/workflows/update-inno-dependencies.yml b/.github/workflows/update-inno-dependencies.yml deleted file mode 100644 index a398ad9a1..000000000 --- a/.github/workflows/update-inno-dependencies.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Update local copy of Inno Setup Dependency Installer script - -on: - workflow_dispatch: - schedule: - - cron: '0 9 1 * *' - -jobs: - update-schema: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Clone repository - uses: actions/checkout@v7 - - - name: Download latest Inno Setup Dependency Installer script - run: | - curl --location \ - --output assets/inno/CodeDependencies.iss \ - --fail \ - --silent \ - --show-error \ - https://github.com/DomGries/InnoDependencyInstaller/raw/HEAD/CodeDependencies.iss - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 - with: - commit-message: "Update Inno Setup Dependency Installer script" - branch: update-inno-dependencies-script - title: "Update Inno Setup Dependency Installer script" - body: "This is an automated pull request to update the Inno Setup Dependency Installer script from [DomGries/InnoDependencyInstaller](https://github.com/DomGries/InnoDependencyInstaller)" diff --git a/.github/workflows/vhs.yml b/.github/workflows/vhs.yml deleted file mode 100644 index 631083477..000000000 --- a/.github/workflows/vhs.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: vhs -on: - workflow_dispatch: - inputs: - tape: - description: 'Tape' - required: true - default: 'all' - type: choice - options: - - all - - demo.tape - - new_package.tape - - sync.tape - -jobs: - vhs: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Install Rust - uses: moonrepo/setup-rust@v1 - with: - cache-target: release - - - name: Setup Go - uses: actions/setup-go@v6 - - - name: Install VHS - run: | - sudo apt update - sudo apt install -y ffmpeg ttyd - go install github.com/charmbracelet/vhs@latest - - - name: Install komac - run: cargo install --locked --path . - - - name: Recreate all VHS GIFs - if: ${{ inputs.tape == 'all' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - for tape in demo.tape new_package.tape sync.tape; do - vhs "assets/vhs/$tape" - done - - - name: Recreate specific VHS GIF - if: ${{ inputs.tape != 'all' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: vhs "assets/vhs/${{ inputs.tape }}" - - - name: Set PR metadata - id: prmeta - run: | - if [ "${{ inputs.tape }}" = "all" ]; then - echo "title=Update all VHS GIFs" >> $GITHUB_OUTPUT - echo "branch=update-all-gifs" >> $GITHUB_OUTPUT - echo "body=This pull request updates all demo recordings." >> $GITHUB_OUTPUT - else - base="${TAPE%.tape}" - echo "title=Update ${base}.gif" >> $GITHUB_OUTPUT - echo "branch=update-${base}-gif" >> $GITHUB_OUTPUT - echo "body=This pull request updates the ${base}.gif recording from assets/vhs/${TAPE}." >> $GITHUB_OUTPUT - fi - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 - with: - commit-message: ${{ steps.prmeta.outputs.title }} - branch: ${{ steps.prmeta.outputs.branch }} - title: ${{ steps.prmeta.outputs.title }} - body: ${{ steps.prmeta.outputs.body }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6e58a853b..abaabab12 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,74 @@ target # RustRover .idea/ + +# Anthelion Start + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# dotenv environment variables file +.env +.env.test + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +*.node +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +/npm +index.js +index.d.ts +test.ts + +# Anthelion End \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 3bf5425e6..4ccc74006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -312,7 +312,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling 0.23.0", + "darling 0.20.11", "ident_case", "prettyplease", "proc-macro2", @@ -680,6 +680,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -816,6 +825,12 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "ctor" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378f0974ae2468eaf63aa036dbe9c926b0dc7ea64c156f2ea618bc2f75b934f0" + [[package]] name = "cynic" version = "3.13.2" @@ -1012,7 +1027,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -1179,12 +1194,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" -[[package]] -name = "fast32" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35a73237400bde66c82e38387343f90d7182a2f2f22729e096a2abd57d75db9" - [[package]] name = "fastrand" version = "2.4.1" @@ -1280,6 +1289,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1287,6 +1311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1295,6 +1320,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -1336,6 +1372,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1365,6 +1402,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1886,7 +1932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f85dac6c239acc85fd61934c572292d93adfd2de459d9c032aa22b553506e915" dependencies = [ "either", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "strum 0.27.2", @@ -1928,7 +1974,7 @@ dependencies = [ "crc32fast", "encoding_rs", "flate2", - "itertools", + "itertools 0.14.0", "liblzma", "nt-time 0.13.2", "thiserror 2.0.18", @@ -2022,6 +2068,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2177,18 +2232,22 @@ dependencies = [ "indoc", "inno", "inquire", - "itertools", + "itertools 0.14.0", "itoa", "keyring-core", "liblzma", "memchr", "msi", + "napi", + "napi-build", + "napi-derive", "nt-time 0.15.0", "num_cpus", "open", "ordinal", "owo-colors", "percent-encoding", + "pulldown-cmark", "quick-xml", "rand 0.10.1", "ratatui", @@ -2289,6 +2348,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "liblzma" version = "0.4.6" @@ -2542,6 +2611,64 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "napi" +version = "3.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b41bda2ac390efb5e8d22025d925ccc3f3807d8c1bea6d19b36127247c4b8f83" +dependencies = [ + "bitflags 2.12.1", + "ctor", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "3.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d66f70256ad5aef58659966064471d0ad90e2897bc36a5a5e0389c85aabc1e" +dependencies = [ + "convert_case 0.11.0", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "napi-derive-backend" +version = "5.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b4b08f15eed7a2a20c3f4c6314013fc3ac890a3afa9892b594485299ebdb2d" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "semver", + "syn 2.0.117", +] + +[[package]] +name = "napi-sys" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400" +dependencies = [ + "libloading", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -2578,6 +2705,12 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -2861,14 +2994,12 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "package-family-name" -version = "2.1.2" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78e7c144b7a3401bea37f4f0a17bf2cf33d188db130e9967f172b5d7b2dc3310" +checksum = "cbdea4b26c3ae1459694aa1237ad5246dc46b9f8840a8401f6da26b8eefa8833" dependencies = [ - "fast32", - "heapless", "serde_core", - "sha2 0.10.9", + "sha2 0.11.0", "thiserror 2.0.18", ] @@ -3071,6 +3202,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.33" @@ -3191,6 +3328,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.12.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quick-xml" version = "0.40.1" @@ -3374,7 +3530,7 @@ dependencies = [ "critical-section", "hashbrown 0.17.1", "indoc", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", @@ -3442,7 +3598,7 @@ dependencies = [ "hashbrown 0.17.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -4728,6 +4884,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4746,7 +4908,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -5436,8 +5598,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winget-types" version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47fe97cf45a2ddc46bf9070014ea4f294618830d91cb78a00044701161f088e5" +source = "git+https://github.com/UnownPlain/winget-types.git?branch=anthelion#8c7035e549725897dbf05a43057ac48398ddd9e5" dependencies = [ "base16ct", "bitflags 2.13.0", @@ -5447,11 +5608,12 @@ dependencies = [ "compact_str", "heapless", "icu_locale", - "itertools", + "itertools 0.15.0", "jiff", "package-family-name", "percent-encoding", "serde", + "serde-saphyr", "sha2 0.11.0", "smallvec", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index 54a62f5d9..f4734babe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ debug = true codegen-units = 1 lto = true strip = true +opt-level = 3 # Anthelion [dependencies] anstream = "1.0.0" @@ -101,9 +102,14 @@ typed-path = "0.12.3" url = { version = "2.5.8", features = ["serde"] } uuid = { version = "1.23.3", features = ["serde", "v4"] } walkdir = "2.5.0" -winget-types = { version = "0.4.3", features = ["serde", "std", "chrono"] } +winget-types = { version = "0.4.3", features = ["serde", "std", "chrono", "serde-saphyr"] } # Anthelion zerocopy = { version = "0.8.52", features = ["derive", "std"] } zip = { version = "8.6.0", default-features = false, features = ["deflate"] } +# Anthelion Start +napi = { version = "3.9.4", features = ["async", "tokio_rt"] } +napi-derive = "3.5.7" +pulldown-cmark = "0.13.4" +# Anthelion End [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"))'.dependencies] dbus-secret-service-keyring-store = { version = "1.0.0", features = ["vendored"] } @@ -122,12 +128,20 @@ rustls = ["dbus-secret-service-keyring-store/crypto-rust", "reqwest/rustls"] [build-dependencies] cynic-codegen = { version = "3.13.2", features = ["rkyv"] } windows_exe_info = { version = "0.5.2", features = ["manifest"] } +# Anthelion Start +napi-build = "2.3.2" + +[lib] +name = "anthelion_komac" +crate-type = ["cdylib"] + +[patch.crates-io] +winget-types = { git = "https://github.com/UnownPlain/winget-types.git", branch = "anthelion" } +# Anthelion End [dev-dependencies] indoc = "2.0.7" rstest = "0.26.1" [package.metadata.generate-rpm] -assets = [ - { source = "target/release/komac", dest = "/usr/bin/komac", mode = "755" }, -] +assets = [{ source = "target/release/komac", dest = "/usr/bin/komac", mode = "755" }] diff --git a/build.rs b/build.rs index 09d79e263..8587fc569 100644 --- a/build.rs +++ b/build.rs @@ -9,4 +9,7 @@ fn main() { .unwrap(); windows_exe_info::icon::icon_ico("assets/branding/logo.ico"); windows_exe_info::versioninfo::link_cargo_env(); + // Anthelion Start + napi_build::setup(); + // Anthelion End } diff --git a/bun.lock b/bun.lock new file mode 100644 index 000000000..b8e179611 --- /dev/null +++ b/bun.lock @@ -0,0 +1,229 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "anthelion-komac", + "devDependencies": { + "@napi-rs/cli": "3.7.2", + }, + }, + }, + "packages": { + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], + + "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], + + "@inquirer/editor": ["@inquirer/editor@5.2.2", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/external-editor": "^3.0.3", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg=="], + + "@inquirer/expand": ["@inquirer/expand@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@3.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], + + "@inquirer/input": ["@inquirer/input@5.1.2", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg=="], + + "@inquirer/number": ["@inquirer/number@4.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA=="], + + "@inquirer/password": ["@inquirer/password@5.1.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg=="], + + "@inquirer/prompts": ["@inquirer/prompts@8.5.2", "", { "dependencies": { "@inquirer/checkbox": "^5.2.1", "@inquirer/confirm": "^6.1.1", "@inquirer/editor": "^5.2.2", "@inquirer/expand": "^5.1.1", "@inquirer/input": "^5.1.2", "@inquirer/number": "^4.1.1", "@inquirer/password": "^5.1.1", "@inquirer/rawlist": "^5.3.1", "@inquirer/search": "^4.2.1", "@inquirer/select": "^5.2.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@5.3.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og=="], + + "@inquirer/search": ["@inquirer/search@4.2.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g=="], + + "@inquirer/select": ["@inquirer/select@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw=="], + + "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], + + "@napi-rs/cli": ["@napi-rs/cli@3.7.2", "", { "dependencies": { "@inquirer/prompts": "^8.5.2", "@napi-rs/cross-toolchain": "^1.0.3", "@napi-rs/wasm-tools": "^1.0.1", "@octokit/rest": "^22.0.1", "clipanion": "^4.0.0-rc.4", "colorette": "^2.0.20", "emnapi": "^1.11.1", "es-toolkit": "^1.47.0", "js-yaml": "^4.2.0", "obug": "^2.1.2", "semver": "^7.8.2", "typanion": "^3.14.0" }, "peerDependencies": { "@emnapi/runtime": "^1.7.1" }, "optionalPeers": ["@emnapi/runtime"], "bin": { "napi": "dist/cli.js", "napi-raw": "cli.mjs" } }, "sha512-shDW0Td/XZQpP04Yy+OsMt1ILMKGGkoLcy1zVAsSAK0fLfWm0Upgkmfs/NOV2ZhMQwkgpR3ZEdyHmTwgrUDQuA=="], + + "@napi-rs/cross-toolchain": ["@napi-rs/cross-toolchain@1.0.3", "", { "dependencies": { "@napi-rs/lzma": "^1.4.5", "@napi-rs/tar": "^1.1.0", "debug": "^4.4.1" }, "peerDependencies": { "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" }, "optionalPeers": ["@napi-rs/cross-toolchain-arm64-target-aarch64", "@napi-rs/cross-toolchain-arm64-target-armv7", "@napi-rs/cross-toolchain-arm64-target-ppc64le", "@napi-rs/cross-toolchain-arm64-target-s390x", "@napi-rs/cross-toolchain-arm64-target-x86_64", "@napi-rs/cross-toolchain-x64-target-aarch64", "@napi-rs/cross-toolchain-x64-target-armv7", "@napi-rs/cross-toolchain-x64-target-ppc64le", "@napi-rs/cross-toolchain-x64-target-s390x", "@napi-rs/cross-toolchain-x64-target-x86_64"] }, "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg=="], + + "@napi-rs/lzma": ["@napi-rs/lzma@1.4.5", "", { "optionalDependencies": { "@napi-rs/lzma-android-arm-eabi": "1.4.5", "@napi-rs/lzma-android-arm64": "1.4.5", "@napi-rs/lzma-darwin-arm64": "1.4.5", "@napi-rs/lzma-darwin-x64": "1.4.5", "@napi-rs/lzma-freebsd-x64": "1.4.5", "@napi-rs/lzma-linux-arm-gnueabihf": "1.4.5", "@napi-rs/lzma-linux-arm64-gnu": "1.4.5", "@napi-rs/lzma-linux-arm64-musl": "1.4.5", "@napi-rs/lzma-linux-ppc64-gnu": "1.4.5", "@napi-rs/lzma-linux-riscv64-gnu": "1.4.5", "@napi-rs/lzma-linux-s390x-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-musl": "1.4.5", "@napi-rs/lzma-wasm32-wasi": "1.4.5", "@napi-rs/lzma-win32-arm64-msvc": "1.4.5", "@napi-rs/lzma-win32-ia32-msvc": "1.4.5", "@napi-rs/lzma-win32-x64-msvc": "1.4.5" } }, "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg=="], + + "@napi-rs/lzma-android-arm-eabi": ["@napi-rs/lzma-android-arm-eabi@1.4.5", "", { "os": "android", "cpu": "arm" }, "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q=="], + + "@napi-rs/lzma-android-arm64": ["@napi-rs/lzma-android-arm64@1.4.5", "", { "os": "android", "cpu": "arm64" }, "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ=="], + + "@napi-rs/lzma-darwin-arm64": ["@napi-rs/lzma-darwin-arm64@1.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA=="], + + "@napi-rs/lzma-darwin-x64": ["@napi-rs/lzma-darwin-x64@1.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw=="], + + "@napi-rs/lzma-freebsd-x64": ["@napi-rs/lzma-freebsd-x64@1.4.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw=="], + + "@napi-rs/lzma-linux-arm-gnueabihf": ["@napi-rs/lzma-linux-arm-gnueabihf@1.4.5", "", { "os": "linux", "cpu": "arm" }, "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw=="], + + "@napi-rs/lzma-linux-arm64-gnu": ["@napi-rs/lzma-linux-arm64-gnu@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg=="], + + "@napi-rs/lzma-linux-arm64-musl": ["@napi-rs/lzma-linux-arm64-musl@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w=="], + + "@napi-rs/lzma-linux-ppc64-gnu": ["@napi-rs/lzma-linux-ppc64-gnu@1.4.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ=="], + + "@napi-rs/lzma-linux-riscv64-gnu": ["@napi-rs/lzma-linux-riscv64-gnu@1.4.5", "", { "os": "linux", "cpu": "none" }, "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA=="], + + "@napi-rs/lzma-linux-s390x-gnu": ["@napi-rs/lzma-linux-s390x-gnu@1.4.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw=="], + + "@napi-rs/lzma-linux-x64-musl": ["@napi-rs/lzma-linux-x64-musl@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ=="], + + "@napi-rs/lzma-wasm32-wasi": ["@napi-rs/lzma-wasm32-wasi@1.4.5", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA=="], + + "@napi-rs/lzma-win32-arm64-msvc": ["@napi-rs/lzma-win32-arm64-msvc@1.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg=="], + + "@napi-rs/lzma-win32-ia32-msvc": ["@napi-rs/lzma-win32-ia32-msvc@1.4.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg=="], + + "@napi-rs/lzma-win32-x64-msvc": ["@napi-rs/lzma-win32-x64-msvc@1.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q=="], + + "@napi-rs/tar": ["@napi-rs/tar@1.1.0", "", { "optionalDependencies": { "@napi-rs/tar-android-arm-eabi": "1.1.0", "@napi-rs/tar-android-arm64": "1.1.0", "@napi-rs/tar-darwin-arm64": "1.1.0", "@napi-rs/tar-darwin-x64": "1.1.0", "@napi-rs/tar-freebsd-x64": "1.1.0", "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", "@napi-rs/tar-linux-arm64-gnu": "1.1.0", "@napi-rs/tar-linux-arm64-musl": "1.1.0", "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", "@napi-rs/tar-linux-s390x-gnu": "1.1.0", "@napi-rs/tar-linux-x64-gnu": "1.1.0", "@napi-rs/tar-linux-x64-musl": "1.1.0", "@napi-rs/tar-wasm32-wasi": "1.1.0", "@napi-rs/tar-win32-arm64-msvc": "1.1.0", "@napi-rs/tar-win32-ia32-msvc": "1.1.0", "@napi-rs/tar-win32-x64-msvc": "1.1.0" } }, "sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ=="], + + "@napi-rs/tar-android-arm-eabi": ["@napi-rs/tar-android-arm-eabi@1.1.0", "", { "os": "android", "cpu": "arm" }, "sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA=="], + + "@napi-rs/tar-android-arm64": ["@napi-rs/tar-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw=="], + + "@napi-rs/tar-darwin-arm64": ["@napi-rs/tar-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw=="], + + "@napi-rs/tar-darwin-x64": ["@napi-rs/tar-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA=="], + + "@napi-rs/tar-freebsd-x64": ["@napi-rs/tar-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g=="], + + "@napi-rs/tar-linux-arm-gnueabihf": ["@napi-rs/tar-linux-arm-gnueabihf@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg=="], + + "@napi-rs/tar-linux-arm64-gnu": ["@napi-rs/tar-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA=="], + + "@napi-rs/tar-linux-arm64-musl": ["@napi-rs/tar-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ=="], + + "@napi-rs/tar-linux-ppc64-gnu": ["@napi-rs/tar-linux-ppc64-gnu@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ=="], + + "@napi-rs/tar-linux-s390x-gnu": ["@napi-rs/tar-linux-s390x-gnu@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ=="], + + "@napi-rs/tar-linux-x64-gnu": ["@napi-rs/tar-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww=="], + + "@napi-rs/tar-linux-x64-musl": ["@napi-rs/tar-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ=="], + + "@napi-rs/tar-wasm32-wasi": ["@napi-rs/tar-wasm32-wasi@1.1.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw=="], + + "@napi-rs/tar-win32-arm64-msvc": ["@napi-rs/tar-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg=="], + + "@napi-rs/tar-win32-ia32-msvc": ["@napi-rs/tar-win32-ia32-msvc@1.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ=="], + + "@napi-rs/tar-win32-x64-msvc": ["@napi-rs/tar-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@napi-rs/wasm-tools": ["@napi-rs/wasm-tools@1.0.1", "", { "optionalDependencies": { "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", "@napi-rs/wasm-tools-android-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-x64": "1.0.1", "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" } }, "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ=="], + + "@napi-rs/wasm-tools-android-arm-eabi": ["@napi-rs/wasm-tools-android-arm-eabi@1.0.1", "", { "os": "android", "cpu": "arm" }, "sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw=="], + + "@napi-rs/wasm-tools-android-arm64": ["@napi-rs/wasm-tools-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg=="], + + "@napi-rs/wasm-tools-darwin-arm64": ["@napi-rs/wasm-tools-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g=="], + + "@napi-rs/wasm-tools-darwin-x64": ["@napi-rs/wasm-tools-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A=="], + + "@napi-rs/wasm-tools-freebsd-x64": ["@napi-rs/wasm-tools-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg=="], + + "@napi-rs/wasm-tools-linux-arm64-gnu": ["@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q=="], + + "@napi-rs/wasm-tools-linux-arm64-musl": ["@napi-rs/wasm-tools-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ=="], + + "@napi-rs/wasm-tools-linux-x64-gnu": ["@napi-rs/wasm-tools-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw=="], + + "@napi-rs/wasm-tools-linux-x64-musl": ["@napi-rs/wasm-tools-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ=="], + + "@napi-rs/wasm-tools-wasm32-wasi": ["@napi-rs/wasm-tools-wasm32-wasi@1.0.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA=="], + + "@napi-rs/wasm-tools-win32-arm64-msvc": ["@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ=="], + + "@napi-rs/wasm-tools-win32-ia32-msvc": ["@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw=="], + + "@napi-rs/wasm-tools-win32-x64-msvc": ["@napi-rs/wasm-tools-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ=="], + + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "@octokit/endpoint": ["@octokit/endpoint@11.0.2", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="], + + "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], + + "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "clipanion": ["clipanion@4.0.0-rc.4", "", { "dependencies": { "typanion": "^3.8.0" } }, "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "emnapi": ["emnapi@1.11.1", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-kSRjhIcxjMFsBqk7ORvoc9aA5SBKDmecrtF5RMcmOTao0kD/zamaxsuTxMI8C1//wGUuvE7a+19pCE7AEhGVnA=="], + + "es-toolkit": ["es-toolkit@1.47.1", "", {}, "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q=="], + + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typanion": ["typanion@3.14.0", "", {}, "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..5345f4ad3 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "@unownplain/anthelion-komac", + "version": "0.0.48", + "description": "komac JS bindings for Anthelion", + "license": "GPL-3.0-or-later", + "repository": { + "type": "git", + "url": "git+https://github.com/unpn-org/Komac.git" + }, + "files": [ + "index.d.ts", + "index.js" + ], + "type": "module", + "main": "index.js", + "scripts": { + "artifacts": "napi artifacts", + "build": "napi build --platform --release --esm --strip", + "build:debug": "napi build --platform --esm", + "prepublishOnly": "napi prepublish -t npm --no-gh-release", + "preversion": "napi build --platform && git add .", + "version": "napi version" + }, + "devDependencies": { + "@napi-rs/cli": "3.7.2" + }, + "napi": { + "binaryName": "anthelion-komac", + "targets": [ + "x86_64-pc-windows-msvc", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "aarch64-pc-windows-msvc" + ] + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/src/analysis/analyzer.rs b/src/analysis/analyzer.rs index 6bb6598c6..7bd1facab 100644 --- a/src/analysis/analyzer.rs +++ b/src/analysis/analyzer.rs @@ -24,6 +24,10 @@ pub struct Analyzer<'reader, R: Read + Seek> { pub copyright: Option, pub package_name: Option, pub publisher: Option, + #[allow(dead_code)] + pub file_version: Option, + #[allow(dead_code)] + pub product_version: Option, pub installers: Vec, pub zip: Option>, } @@ -64,6 +68,8 @@ impl<'reader, R: Read + Seek> Analyzer<'reader, R> { .company_name .take() .and_then(|company_name| Publisher::new(company_name).ok()), + file_version: exe.file_version.take(), + product_version: exe.product_version.take(), ..Self::default() }); } @@ -83,6 +89,8 @@ impl Default for Analyzer<'_, R> { copyright: None, package_name: None, publisher: None, + file_version: None, + product_version: None, installers: Vec::default(), zip: None, } diff --git a/src/analysis/installers/exe.rs b/src/analysis/installers/exe.rs index ca539a1b8..e4cb139aa 100644 --- a/src/analysis/installers/exe.rs +++ b/src/analysis/installers/exe.rs @@ -25,6 +25,8 @@ pub struct Exe { pub legal_copyright: Option, pub product_name: Option, pub company_name: Option, + pub file_version: Option, + pub product_version: Option, } pub enum ExeType { @@ -57,6 +59,14 @@ impl Exe { .as_mut() .and_then(|table| table.swap_remove("CompanyName")) .map(str::to_owned); + let file_version = string_table + .as_mut() + .and_then(|table| table.swap_remove("FileVersion")) + .map(str::to_owned); + let product_version = string_table + .as_mut() + .and_then(|table| table.swap_remove("ProductVersion")) + .map(str::to_owned); match AdvancedInstaller::new(&mut reader) { Ok(advanced) => { @@ -65,6 +75,8 @@ impl Exe { legal_copyright, product_name, company_name, + file_version, + product_version, }); } Err(AdvancedInstallerError::NotAdvancedInstallerFile) => {} @@ -78,6 +90,8 @@ impl Exe { legal_copyright, product_name, company_name, + file_version, + product_version, }); } Err(BurnError::NotBurnFile) => {} @@ -91,6 +105,8 @@ impl Exe { legal_copyright, product_name, company_name, + file_version, + product_version, }); } Err(InnoError::NotInnoFile) => {} @@ -104,6 +120,8 @@ impl Exe { legal_copyright, product_name, company_name, + file_version, + product_version, }); } Err(NsisError::NotNsisFile) => {} @@ -117,6 +135,8 @@ impl Exe { legal_copyright, product_name, company_name, + file_version, + product_version, }); } Err(SquirrelError::NotSquirrelFile) => {} @@ -145,6 +165,8 @@ impl Exe { legal_copyright, product_name, company_name, + file_version, + product_version, }) } } diff --git a/src/analysis/installers/zip.rs b/src/analysis/installers/zip.rs index b9e0fb4e3..2d571c069 100644 --- a/src/analysis/installers/zip.rs +++ b/src/analysis/installers/zip.rs @@ -8,6 +8,7 @@ use std::{ use camino::{Utf8Path, Utf8PathBuf}; use color_eyre::eyre::Result; use inquire::{CustomType, MultiSelect, min_length}; +use regex::Regex; use tracing::debug; use winget_types::installer::{ Installer, InstallerType, NestedInstallerFiles, PortableCommandAlias, @@ -15,19 +16,98 @@ use winget_types::installer::{ use zip::ZipArchive; use super::super::Analyzer; -use crate::prompts::handle_inquire_error; +use crate::{prompts::handle_inquire_error, traits::path::LowercaseExtension}; const VALID_NESTED_FILE_EXTENSIONS: [&str; 6] = ["msix", "msi", "appx", "exe", "msixbundle", "appxbundle"]; const IGNORABLE_FOLDERS: [&str; 2] = ["__MACOSX", "resources"]; +enum NestedFileMatch { + Contains(String), + Glob(Regex), +} + +impl NestedFileMatch { + fn new(pattern: &str) -> Result { + if pattern.contains(['*', '?', '[']) { + Ok(Self::Glob(Regex::new(&glob_to_regex(pattern))?)) + } else { + Ok(Self::Contains(pattern.to_ascii_lowercase())) + } + } + + fn matches(&self, path: &Utf8Path) -> bool { + match self { + Self::Contains(pattern) => path.as_str().to_ascii_lowercase().contains(pattern), + Self::Glob(pattern) => { + let path = path.as_str().to_ascii_lowercase(); + let file_name = Utf8Path::new(&path).file_name().unwrap_or(path.as_str()); + + pattern.is_match(&path) || pattern.is_match(file_name) + } + } + } +} + +fn glob_to_regex(pattern: &str) -> String { + let pattern = pattern.replace('\\', "/").to_ascii_lowercase(); + let mut regex = String::from("^"); + let mut chars = pattern.chars().peekable(); + + while let Some(character) = chars.next() { + match character { + '*' => { + if chars.next_if_eq(&'*').is_some() { + regex.push_str(".*"); + } else { + regex.push_str("[^/]*"); + } + } + '?' => regex.push_str("[^/]"), + '[' => { + regex.push('['); + if chars.next_if_eq(&'!').is_some() { + regex.push('^'); + } else if chars.next_if_eq(&'^').is_some() { + regex.push('\\'); + regex.push('^'); + } + + for character in chars.by_ref() { + if character == ']' { + regex.push(']'); + break; + } + if character == '\\' { + regex.push('/'); + } else { + regex.push(character); + } + } + } + _ => regex.push_str(®ex::escape(&character.to_string())), + } + } + + regex.push('$'); + regex +} + pub struct Zip { archive: ZipArchive, pub possible_installer_files: Vec, pub installers: Vec, } +pub struct MatchedInstaller { + pub installer: Installer, + #[allow(dead_code)] + pub file_version: Option, + #[allow(dead_code)] + pub product_version: Option, +} + impl Zip { pub fn new(reader: R) -> Result { let mut zip = ZipArchive::new(reader)?; @@ -172,4 +252,69 @@ impl Zip { } Ok(()) } + + #[allow(dead_code)] + pub fn analyze_matches(&mut self, matches: &[String]) -> Result> { + Ok(self + .analyze_matches_with_metadata(matches)? + .into_iter() + .map(|analysis| analysis.installer) + .collect()) + } + + pub fn analyze_matches_with_metadata( + &mut self, + matches: &[String], + ) -> Result> { + let matches = matches + .iter() + .map(|pattern| NestedFileMatch::new(pattern)) + .collect::>>()?; + + let installers = self + .possible_installer_files + .iter() + .filter(|path| matches.iter().any(|file_match| file_match.matches(path))) + .map(|path| { + let mut nested_file = self.archive.by_name(path.as_str())?; + let mut temp_file = tempfile::tempfile()?; + io::copy(&mut nested_file, &mut temp_file)?; + temp_file.seek(SeekFrom::Start(0))?; + + let nested_analyzer = Analyzer::new(&mut temp_file, path.as_str())?; + let nested_installer_files = BTreeSet::from([NestedInstallerFiles { + relative_file_path: path.lowercase_extension(), + portable_command_alias: None, + }]); + let file_version = nested_analyzer.file_version; + let product_version = nested_analyzer.product_version; + + Ok(nested_analyzer + .installers + .into_iter() + .map(move |installer| Installer { + r#type: Some(InstallerType::Zip), + nested_installer_type: installer + .r#type + .and_then(|installer_type| installer_type.try_into().ok()), + nested_installer_files: nested_installer_files.clone(), + ..installer + }) + .map({ + let file_version = file_version.clone(); + let product_version = product_version.clone(); + move |installer| MatchedInstaller { + installer, + file_version: file_version.clone(), + product_version: product_version.clone(), + } + })) + }) + .collect::>>()? + .into_iter() + .flatten() + .collect::>(); + + Ok(installers) + } } diff --git a/src/anthelion/analyze_installer.rs b/src/anthelion/analyze_installer.rs new file mode 100644 index 000000000..672b19e28 --- /dev/null +++ b/src/anthelion/analyze_installer.rs @@ -0,0 +1,120 @@ +use std::{num::NonZeroUsize, str::FromStr}; + +use napi::bindgen_prelude::*; +use napi_derive::napi; + +use super::types::{ + AnalyzeInstallerResult, AppsAndFeaturesEntryAnalysis, InstallerAnalysis, + NestedInstallerFileAnalysis, +}; +use crate::{download::Downloader, manifests::Url}; + +/// Analyze an installer from a URL and return installer analysis information. +/// +/// When the installer is a ZIP, `matches` can be used to select nested installer +/// files. Plain strings are matched as case-insensitive substrings, while values +/// containing glob metacharacters are matched as glob patterns. +/// +/// # Errors +/// +/// Returns `InvalidArg` if `url` is invalid. +/// Returns `GenericFailure` if downloading or analyzing the installer fails. +#[napi] +pub async fn analyze_installer( + url: String, + matches: Option>, +) -> napi::Result { + let installer_url = Url::from_str(&url) + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid URL: {e}")))?; + + let downloader = Downloader::new_with_concurrent_and_progress(NonZeroUsize::MIN, false) + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to create downloader: {e}"), + ) + })?; + + let mut downloaded_file = downloader + .download([installer_url]) + .await + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to download installer: {e}"), + ) + })? + .into_iter() + .next() + .ok_or_else(|| Error::new(Status::GenericFailure, "No installer was downloaded"))?; + + let mut analyzer = + crate::analysis::Analyzer::new(&mut downloaded_file.file, &downloaded_file.file_name) + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to analyze installer: {e}"), + ) + })?; + + if let (Some(zip), Some(matches)) = (&mut analyzer.zip, matches.as_ref()) + && !matches.is_empty() + { + analyzer.installers = zip.analyze_matches(matches).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to analyze matching ZIP installers: {e}"), + ) + })?; + } + + for installer in &mut analyzer.installers { + installer.url = downloaded_file.url.inner().clone(); + installer.release_date = downloaded_file.last_modified; + } + + let analysis = analyzer + .installers + .iter() + .map(|installer| InstallerAnalysis { + installer_locale: installer + .locale + .as_ref() + .map(std::string::ToString::to_string), + architecture: installer.architecture.to_string(), + installer_type: installer.r#type.map(|t| t.to_string()), + nested_installer_type: installer.nested_installer_type.map(|t| t.to_string()), + nested_installer_files: installer + .nested_installer_files + .iter() + .map(|f| NestedInstallerFileAnalysis { + relative_file_path: f.relative_file_path.to_string(), + }) + .collect(), + apps_and_features_entries: installer + .apps_and_features_entries + .iter() + .map(|entry| AppsAndFeaturesEntryAnalysis { + display_name: entry.display_name().map(str::to_owned), + publisher: entry.publisher().map(str::to_owned), + display_version: entry + .display_version() + .map(std::string::ToString::to_string), + product_code: entry.product_code().map(str::to_owned), + upgrade_code: entry.upgrade_code().map(str::to_owned), + installer_type: entry.installer_type().map(|t| t.to_string()), + }) + .collect(), + scope: installer.scope.map(|s| s.to_string()), + installer_url: installer.url.to_string(), + installer_sha256: installer.sha_256.to_string(), + release_date: installer.release_date.map(|d| d.to_string()), + }) + .collect(); + + Ok(AnalyzeInstallerResult { + analysis, + file_version: analyzer.file_version, + product_version: analyzer.product_version, + }) +} diff --git a/src/anthelion/get_existing_pull_request.rs b/src/anthelion/get_existing_pull_request.rs new file mode 100644 index 000000000..91d45fa5c --- /dev/null +++ b/src/anthelion/get_existing_pull_request.rs @@ -0,0 +1,74 @@ +use napi::bindgen_prelude::*; +use napi_derive::napi; +use winget_types::{PackageIdentifier, PackageVersion}; + +use super::{ + token::resolve_github_token, + types::{ExistingPullRequestResult, GetExistingPullRequestOptions}, +}; +use crate::github::{client::GitHub, graphql::types::PullRequestState}; + +/// Get an existing pull request for a package version in winget-pkgs. +/// +/// # Errors +/// +/// Returns `InvalidArg` if `package_identifier` or `version` are invalid. +/// Returns `GenericFailure` if creating the GitHub client or querying pull requests fails. +#[napi] +pub async fn get_existing_pull_request( + options: GetExistingPullRequestOptions, +) -> napi::Result> { + let package_identifier: PackageIdentifier = + options.package_identifier.parse().map_err(|e| { + Error::new( + Status::InvalidArg, + format!("Invalid package identifier: {e}"), + ) + })?; + + let package_version: PackageVersion = options + .version + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid package version: {e}")))?; + + let token = resolve_github_token(options.token.as_deref())?; + + let github = GitHub::new(&token).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to create GitHub client: {e}"), + ) + })?; + + let ignore_other_users = options + .ignore_pull_requests_created_by_other_users + .unwrap_or_default(); + let pull_request = github + .get_existing_pull_request(&package_identifier, &package_version, ignore_other_users) + .await + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to get existing pull request: {e}"), + ) + })?; + + let Some(pull_request) = pull_request else { + return Ok(None); + }; + + let created_by = pull_request.author_login().cloned().unwrap_or_default(); + + Ok(Some(ExistingPullRequestResult { + pull_request_url: pull_request.url.to_string(), + created_by, + created_by_authenticated_user: pull_request.viewer_did_author, + state: match pull_request.state { + PullRequestState::Open => "open", + PullRequestState::Closed => "closed", + PullRequestState::Merged => "merged", + } + .to_string(), + created_at: pull_request.created_at.to_rfc3339(), + })) +} diff --git a/src/anthelion/github_configuration.rs b/src/anthelion/github_configuration.rs new file mode 100644 index 000000000..47865a9d8 --- /dev/null +++ b/src/anthelion/github_configuration.rs @@ -0,0 +1,62 @@ +use std::sync::OnceLock; + +use napi::bindgen_prelude::*; +use napi_derive::napi; + +use crate::github::{MICROSOFT, WINGET_PKGS}; + +static CONFIG: OnceLock = OnceLock::new(); + +struct Config { + github_token: Option, + dry_run: Option, +} + +impl Config { + fn get() -> &'static Self { + CONFIG.get_or_init(|| Self { + github_token: None, + dry_run: None, + }) + } +} + +pub fn github_token() -> Option<&'static str> { + Config::get().github_token.as_deref() +} + +pub fn dry_run() -> Option { + Config::get().dry_run +} + +fn get_env(env: &Object, name: &str) -> napi::Result> { + Ok(env + .get_named_property::>(name)? + .filter(|value| !value.trim().is_empty())) +} + +/// Copies configuration from Node's `process.env` into the native binding. +#[napi(module_exports)] +pub fn configure(env: Env) -> napi::Result<()> { + let process: Object = env.get_global()?.get_named_property("process")?; + let process_env: Object = process.get_named_property("env")?; + + if let Some(owner) = get_env(&process_env, "KOMAC_GITHUB_OWNER")? { + MICROSOFT.set(owner); + } + if let Some(repo) = get_env(&process_env, "KOMAC_GITHUB_REPO")? { + WINGET_PKGS.set(repo); + } + + let dry_run = get_env(&process_env, "DRY_RUN")? + .map(|value| value.parse()) + .transpose() + .map_err(|_| Error::new(Status::InvalidArg, "DRY_RUN must be true or false"))?; + + let _ = CONFIG.set(Config { + github_token: get_env(&process_env, "GITHUB_TOKEN")?, + dry_run, + }); + + Ok(()) +} diff --git a/src/anthelion/mod.rs b/src/anthelion/mod.rs new file mode 100644 index 000000000..a71e1ad1a --- /dev/null +++ b/src/anthelion/mod.rs @@ -0,0 +1,20 @@ +mod analyze_installer; +mod get_existing_pull_request; +mod github_configuration; +mod release_notes; +mod token; +mod types; +mod update_helpers; +mod update_version; + +pub use analyze_installer::analyze_installer; +pub use get_existing_pull_request::get_existing_pull_request; +pub use release_notes::{ + get_formatted_github_release_notes, html_to_plain_text, markdown_to_plain_text, +}; +pub use types::{ + AnalyzeInstallerResult, AppsAndFeaturesEntryAnalysis, ExistingPullRequestResult, + GetExistingPullRequestOptions, InstallerAnalysis, ManifestChange, NestedInstallerFileAnalysis, + UpdateVersionOptions, UpdateVersionResult, +}; +pub use update_version::update_version; diff --git a/src/anthelion/release_notes.rs b/src/anthelion/release_notes.rs new file mode 100644 index 000000000..46b1f4c53 --- /dev/null +++ b/src/anthelion/release_notes.rs @@ -0,0 +1,127 @@ +use napi::bindgen_prelude::*; +use napi_derive::napi; +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; +use winget_types::locale::ReleaseNotes; + +use super::token::resolve_github_token; +use crate::{ + github::{client::GitHub, graphql::types::Html}, + traits::FromHtml, +}; + +/// Fetch, normalize, and return release notes for a GitHub release tag. +/// +/// # Errors +/// +/// Returns `GenericFailure` if creating the GitHub client or fetching release data fails. +/// Returns an error from token resolution when no usable token is available. +#[napi] +pub async fn get_formatted_github_release_notes( + owner: String, + repo: String, + tag: String, + token: Option, +) -> napi::Result> { + let token = resolve_github_token(token.as_deref())?; + + let github = GitHub::new(&token).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to create GitHub client: {e}"), + ) + })?; + + let github_values = github + .get_all_values() + .owner(owner) + .repo(repo) + .tag_name(tag) + .send() + .await + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to fetch GitHub release values: {e}"), + ) + })?; + + Ok(github_values.release_notes.map(|notes| notes.to_string())) +} + +#[napi] +/// Convert HTML release notes content to plain text. +/// +/// # Errors +/// +/// Returns `GenericFailure` if the conversion task panics. +pub async fn html_to_plain_text(html: String) -> napi::Result> { + tokio::task::spawn_blocking(move || { + ReleaseNotes::from_html(&Html::new(html)).map(|notes| notes.to_string()) + }) + .await + .map_err(|e| Error::new(Status::GenericFailure, format!("Task panicked: {e}"))) +} + +#[napi] +/// Convert Markdown release notes content to plain text. +/// +/// # Errors +/// +/// Returns `GenericFailure` if the conversion task panics. +pub async fn markdown_to_plain_text(markdown: String) -> napi::Result> { + tokio::task::spawn_blocking(move || { + let mut text = String::new(); + let mut seen_heading = false; + + for event in Parser::new_ext(&markdown, Options::all()) { + match event { + Event::Start(Tag::Heading { .. }) => { + if seen_heading && !text.ends_with("\n\n") { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push('\n'); + } + seen_heading = true; + } + Event::Start(Tag::Item) => { + if !text.ends_with('\n') && !text.is_empty() { + text.push('\n'); + } + text.push_str("- "); + } + Event::Text(content) + | Event::Code(content) + | Event::Html(content) + | Event::InlineHtml(content) => text.push_str(&content), + Event::SoftBreak | Event::HardBreak => text.push('\n'), + Event::Rule if !text.ends_with('\n') => text.push('\n'), + Event::TaskListMarker(checked) => { + text.push_str(if checked { "[x] " } else { "[ ] " }); + } + Event::End(tag) + if matches!( + tag, + TagEnd::Paragraph + | TagEnd::Heading(..) + | TagEnd::BlockQuote(..) + | TagEnd::CodeBlock + | TagEnd::Item + | TagEnd::List(..) + | TagEnd::Table + | TagEnd::TableHead + | TagEnd::TableRow + ) && !text.ends_with('\n') => + { + text.push('\n'); + } + _ => {} + } + } + + let plain_text = text.trim(); + (!plain_text.is_empty()).then(|| plain_text.to_string()) + }) + .await + .map_err(|e| Error::new(Status::GenericFailure, format!("Task panicked: {e}"))) +} diff --git a/src/anthelion/token.rs b/src/anthelion/token.rs new file mode 100644 index 000000000..f02af0f43 --- /dev/null +++ b/src/anthelion/token.rs @@ -0,0 +1,39 @@ +use napi::bindgen_prelude::*; +use secrecy::SecretString; + +use super::github_configuration; + +pub struct GitHubToken { + token: SecretString, +} + +impl GitHubToken { + fn from_string(token: String) -> Self { + Self { + token: SecretString::new(token.into_boxed_str()), + } + } +} + +impl AsRef for GitHubToken { + fn as_ref(&self) -> &SecretString { + &self.token + } +} + +pub fn resolve_github_token(token: Option<&str>) -> napi::Result { + if let Some(env_token) = github_configuration::github_token() { + return Ok(GitHubToken::from_string(env_token.to_owned())); + } + + if let Some(token) = token + && !token.trim().is_empty() + { + return Ok(GitHubToken::from_string(token.to_owned())); + } + + Err(Error::new( + Status::InvalidArg, + "No GitHub token provided. Set GITHUB_TOKEN or pass token.", + )) +} diff --git a/src/anthelion/types.rs b/src/anthelion/types.rs new file mode 100644 index 000000000..fe4ef2e99 --- /dev/null +++ b/src/anthelion/types.rs @@ -0,0 +1,149 @@ +use napi_derive::napi; + +/// Options for update version. +#[napi(object)] +pub struct UpdateVersionOptions { + /// The package's unique identifier (e.g. "Microsoft.VisualStudioCode") + pub package_identifier: String, + /// The new package version. + /// + /// You can also pass one of: + /// - `displayVersion` + /// - `productVersion` + /// - `fileVersion` + /// + /// In that case, the version will be resolved from installer analysis. + pub version: String, + /// List of installer URLs (may include "|architecture" suffix) + pub urls: Vec, + /// ZIP nested installer match patterns used during installer analysis. + /// These matches also affect `displayVersion`, `productVersion`, and + /// `fileVersion` version selector resolution. + /// + /// Plain strings are matched as case-insensitive substrings, while values + /// containing glob metacharacters are matched as glob patterns. + pub installer_matches: Option>, + /// URL to the release notes + pub release_notes_url: Option, + /// Release notes text for the manifest + pub release_notes: Option, + /// Run without submitting a PR + pub dry_run: Option, + /// Version to replace (use "latest" for the latest version) + pub replace: Option, + /// Look for the package under fonts instead of probing manifests first + pub font: Option, + /// GitHub personal access token with the `public_repo` scope + pub token: Option, +} + +/// Options for get existing pull request. +#[napi(object)] +pub struct GetExistingPullRequestOptions { + /// The package's unique identifier (e.g. "Microsoft.VisualStudioCode") + pub package_identifier: String, + /// The package version to search for + pub version: String, + /// GitHub personal access token with the `public_repo` scope. + /// If omitted, `GITHUB_TOKEN` from the environment is used. + pub token: Option, + /// Ignore pull requests not created by the authenticated user. + pub ignore_pull_requests_created_by_other_users: Option, +} + +/// Existing pull request metadata. +#[napi(object)] +pub struct ExistingPullRequestResult { + /// The URL of the existing pull request + pub pull_request_url: String, + /// The GitHub login of the user or bot that created the pull request + pub created_by: String, + /// Whether the pull request was created by the authenticated user + pub created_by_authenticated_user: bool, + /// The current state of the pull request (`open`, `closed`, or `merged`) + pub state: String, + /// The pull request creation timestamp in RFC3339 format + pub created_at: String, +} + +/// Result of the update version operation. +#[napi(object)] +pub struct UpdateVersionResult { + /// The URL of the created pull request, if submitted + pub pull_request_url: Option, + /// The generated manifest changes as a list of (path, content) pairs + pub changes: Vec, + /// The package identifier that was updated + pub package_identifier: String, + /// The version that was created + pub version: String, +} + +/// A single manifest file change. +#[napi(object)] +pub struct ManifestChange { + /// The file path within the winget-pkgs repository + pub path: String, + /// The YAML content of the manifest + pub content: String, +} + +/// Result of the analyze installer operation. +#[napi(object)] +pub struct AnalyzeInstallerResult { + /// Detected installer information. + pub analysis: Vec, + /// PE `FileVersion` string from the version info resource, if present. + pub file_version: Option, + /// PE `ProductVersion` string from the version info resource, if present. + pub product_version: Option, +} + +/// Installer information detected during analysis. +#[napi(object)] +pub struct InstallerAnalysis { + /// Installer locale (if present). + pub installer_locale: Option, + /// Installer architecture. + pub architecture: String, + /// Installer type (if detected). + pub installer_type: Option, + /// Nested installer type (if present). + pub nested_installer_type: Option, + /// Nested installer files within archives. + pub nested_installer_files: Vec, + /// Apps and Features / ARP entries detected for this installer. + pub apps_and_features_entries: Vec, + /// Install scope (if present). + pub scope: Option, + /// Installer download URL. + pub installer_url: String, + /// Installer SHA-256 hash. + pub installer_sha256: String, + /// Installer release date, if available. + pub release_date: Option, +} + +/// Apps and Features / ARP entry information detected during analysis. +#[napi(object)] +pub struct AppsAndFeaturesEntryAnalysis { + /// Display name registered in Apps and Features, if present. + pub display_name: Option, + /// Publisher registered in Apps and Features, if present. + pub publisher: Option, + /// Display version registered in Apps and Features, if present. + pub display_version: Option, + /// Product code registered in Apps and Features, if present. + pub product_code: Option, + /// Upgrade code registered in Apps and Features, if present. + pub upgrade_code: Option, + /// Installer type registered in Apps and Features, if present. + pub installer_type: Option, +} + +/// Nested installer file information. +#[napi(object)] +pub struct NestedInstallerFileAnalysis { + /// Relative path to the nested installer file. + pub relative_file_path: String, +} diff --git a/src/anthelion/update_helpers.rs b/src/anthelion/update_helpers.rs new file mode 100644 index 000000000..20a473490 --- /dev/null +++ b/src/anthelion/update_helpers.rs @@ -0,0 +1,89 @@ +use winget_types::{PackageVersion, installer::NestedInstallerFiles}; + +use crate::{ + analysis::installers::Zip, + github::{ + GITHUB_HOST, GitHubError, + client::{GitHub, GitHubValues}, + }, + manifests::Url, + traits::path::{LowercaseExtension, NormalizePath}, +}; + +pub fn resolve_replace_version<'a>( + replace: Option<&'a PackageVersion>, + versions: &'a std::collections::BTreeSet, + latest_version: &'a PackageVersion, + package_version: &PackageVersion, +) -> std::result::Result, String> { + let replace_version = replace + .map(|version| { + if version.is_latest() { + latest_version + } else { + version + } + }) + .filter(|&version| version.as_str() != package_version.as_str()); + + if let Some(version) = replace_version + && !versions.contains(version) + { + if let Some(closest) = version.closest(versions) { + return Err(format!( + "Replacement version {version} does not exist. The closest version is {closest}" + )); + } + return Err(format!("Replacement version {version} does not exist")); + } + + Ok(replace_version) +} + +pub async fn fetch_github_values( + github: &GitHub, + urls: &[Url], +) -> std::result::Result, GitHubError> { + if let Some(url) = urls.iter().find(|url| url.host_str() == Some(GITHUB_HOST)) { + github + .get_all_values_from_url(url.clone().into_inner()) + .await + .transpose() + } else { + Ok(None) + } +} + +pub fn fix_relative_paths( + nested_installer_files: std::collections::BTreeSet, + zip: Option<&Zip>, +) -> std::collections::BTreeSet { + let Some(zip) = zip else { + return nested_installer_files; + }; + + nested_installer_files + .into_iter() + .filter_map(|nested_installer_files| { + if zip + .possible_installer_files + .contains(&nested_installer_files.relative_file_path.normalize()) + { + Some(nested_installer_files) + } else { + zip.possible_installer_files + .iter() + .min_by_key(|file_path| { + strsim::levenshtein( + file_path.as_str(), + nested_installer_files.relative_file_path.as_str(), + ) + }) + .map(|path| NestedInstallerFiles { + relative_file_path: path.lowercase_extension(), + ..nested_installer_files + }) + } + }) + .collect() +} diff --git a/src/anthelion/update_version.rs b/src/anthelion/update_version.rs new file mode 100644 index 000000000..c148b832a --- /dev/null +++ b/src/anthelion/update_version.rs @@ -0,0 +1,467 @@ +use std::{mem, num::NonZeroUsize, str::FromStr}; + +use futures_util::TryFutureExt; +use itertools::Itertools; +use napi::bindgen_prelude::*; +use napi_derive::napi; +use tokio::try_join; +use winget_types::{ + PackageIdentifier, PackageVersion, + installer::{InstallerType, MinimumOSVersion}, + locale::ReleaseNotes, + url::ReleaseNotesUrl, +}; + +use super::{ + github_configuration, + token::resolve_github_token, + types::{ManifestChange, UpdateVersionOptions, UpdateVersionResult}, + update_helpers::{fetch_github_values, fix_relative_paths, resolve_replace_version}, +}; +use crate::{ + download::Downloader, + github::{ + client::GitHub, + utils::{PackagePath, pull_request::pr_changes}, + }, + manifests::Url, + match_installers::match_installers, + traits::LocaleExt, +}; + +enum VersionSelector { + Explicit(Box), + ProductVersion, + FileVersion, + DisplayVersion, +} + +fn parse_version_selector(version: &str) -> napi::Result { + let trimmed = version.trim(); + if trimmed.is_empty() { + return Err(Error::new(Status::InvalidArg, "A version is required")); + } + + Ok(match trimmed { + "productVersion" => VersionSelector::ProductVersion, + "fileVersion" => VersionSelector::FileVersion, + "displayVersion" => VersionSelector::DisplayVersion, + value => VersionSelector::Explicit(Box::new(value.parse().map_err(|e| { + Error::new(Status::InvalidArg, format!("Invalid package version: {e}")) + })?)), + }) +} + +/// Update an existing package version in winget-pkgs. +/// +/// # Errors +/// +/// Returns `InvalidArg` when provided arguments are invalid (identifier, URLs, versions, or selectors). +/// Returns `GenericFailure` when downloading installers, analyzing content, loading manifests, +/// or creating the pull request fails. +#[napi] +pub async fn update_version(options: UpdateVersionOptions) -> napi::Result { + let package_identifier: PackageIdentifier = + options.package_identifier.parse().map_err(|e| { + Error::new( + Status::InvalidArg, + format!("Invalid package identifier: {e}"), + ) + })?; + + let version_selector = parse_version_selector(&options.version)?; + + let urls: Vec = options + .urls + .iter() + .map(|u| Url::from_str(u)) + .collect::, _>>() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid URL: {e}")))?; + + if urls.is_empty() { + return Err(Error::new( + Status::InvalidArg, + "At least one URL is required", + )); + } + + let release_notes_url: Option = options + .release_notes_url + .map(|u| u.parse()) + .transpose() + .map_err(|e| { + Error::new( + Status::InvalidArg, + format!("Invalid release notes URL: {e}"), + ) + })?; + + let release_notes: Option = options + .release_notes + .map(ReleaseNotes::new) + .transpose() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid release notes: {e}")))?; + + let dry_run = options + .dry_run + .or_else(github_configuration::dry_run) + .unwrap_or(false); + + let replace: Option = options + .replace + .map(|v| v.parse()) + .transpose() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid replace version: {e}")))?; + + let force_font = options.font.unwrap_or_default(); + + let token = resolve_github_token(options.token.as_deref())?; + + let github = GitHub::new(&token).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to create GitHub client: {e}"), + ) + })?; + + let versions = github + .get_versions(&package_identifier) + let (versions, font) = github + .get_versions(&package_identifier, force_font.then_some(true)) + .await + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to get versions: {e}"), + ) + })?; + + let latest_version = versions + .last() + .ok_or_else(|| Error::new(Status::GenericFailure, "No versions found for package"))?; + + let downloader = Downloader::new_with_concurrent_and_progress( + NonZeroUsize::new(num_cpus::get()).unwrap_or(NonZeroUsize::MIN), + false, + ) + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to create downloader: {e}"), + ) + })?; + + let (mut manifests, mut github_values, mut files) = try_join!( + github + .get_manifests(&package_identifier, latest_version) + .map_err(|e| Error::new( + Status::GenericFailure, + format!("Failed to get manifests: {e}") + )), + fetch_github_values(&github, &urls).map_err(|e| Error::new( + Status::GenericFailure, + format!("Failed to get GitHub values: {e}") + )), + downloader + .download(urls.iter().cloned()) + .map_err(|e| Error::new( + Status::GenericFailure, + format!("Failed to download installers: {e}") + )), + )?; + + let mut download_results = { + use std::collections::HashMap; + + use winget_types::{installer::Architecture, url::DecodedUrl}; + + use crate::analysis::Analyzer; + + let mut results = HashMap::new(); + for file in &mut files { + let mut file_analyzer = + Analyzer::new(&mut file.file, &file.file_name).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to analyze file: {e}"), + ) + })?; + if let (Some(zip), Some(installer_matches)) = + (&mut file_analyzer.zip, options.installer_matches.as_ref()) + && !installer_matches.is_empty() + { + let matched_analysis = zip + .analyze_matches_with_metadata(installer_matches) + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to analyze matching ZIP installers: {e}"), + ) + })?; + + if let Some(file_version) = matched_analysis + .iter() + .filter_map(|analysis| analysis.file_version.as_deref()) + .map(str::trim) + .find(|value| !value.is_empty()) + { + file_analyzer.file_version = Some(file_version.to_owned()); + } + + if let Some(product_version) = matched_analysis + .iter() + .filter_map(|analysis| analysis.product_version.as_deref()) + .map(str::trim) + .find(|value| !value.is_empty()) + { + file_analyzer.product_version = Some(product_version.to_owned()); + } + + file_analyzer.installers = matched_analysis + .into_iter() + .map(|analysis| analysis.installer) + .collect(); + } + let architecture = file + .url + .override_architecture() + .or_else(|| Architecture::from_url(file.url.as_str())); + for installer in &mut file_analyzer.installers { + if let Some(architecture) = architecture { + installer.architecture = architecture; + } + installer.url = file.url.inner().clone(); + installer.sha_256 = file.sha_256.clone(); + installer.release_date = file.last_modified; + } + file_analyzer.file_name = mem::take(&mut file.file_name); + let url_key: DecodedUrl = mem::take(file.url.inner_mut()); + results.insert(url_key, file_analyzer); + } + results + }; + + let installer_results = download_results + .iter_mut() + .flat_map(|(_url, analyzer)| mem::take(&mut analyzer.installers)) + .collect::>(); + + let product_version = download_results + .values() + .filter_map(|analyzer| analyzer.product_version.as_deref()) + .map(str::trim) + .find(|value| !value.is_empty()); + let file_version = download_results + .values() + .filter_map(|analyzer| analyzer.file_version.as_deref()) + .map(str::trim) + .find(|value| !value.is_empty()); + let display_version = installer_results + .iter() + .flat_map(|installer| installer.apps_and_features_entries.iter()) + .filter_map(|entry| { + entry + .display_version() + .map(std::string::ToString::to_string) + }) + .find(|value| !value.trim().is_empty()); + + let package_version: PackageVersion = match version_selector { + VersionSelector::Explicit(package_version) => *package_version, + VersionSelector::ProductVersion => product_version + .ok_or_else(|| { + Error::new( + Status::InvalidArg, + "`version` was set to `productVersion`, but no ProductVersion was found during analysis", + ) + })? + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid ProductVersion: {e}")))?, + VersionSelector::FileVersion => file_version + .ok_or_else(|| { + Error::new( + Status::InvalidArg, + "`version` was set to `fileVersion`, but no FileVersion was found during analysis", + ) + })? + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid FileVersion: {e}")))?, + VersionSelector::DisplayVersion => display_version + .ok_or_else(|| { + Error::new( + Status::InvalidArg, + "`version` was set to `displayVersion`, but no DisplayVersion was found during analysis", + ) + })? + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid DisplayVersion: {e}")))?, + }; + + let replace_version = resolve_replace_version( + replace.as_ref(), + &versions, + latest_version, + &package_version, + ) + .map_err(|e| Error::new(Status::InvalidArg, e))?; + + let previous_installers = mem::take(&mut manifests.installer.installers) + .into_iter() + .map(|mut installer| { + if manifests.installer.r#type.is_some() { + installer.r#type = manifests.installer.r#type; + } + if manifests.installer.nested_installer_type.is_some() { + installer.nested_installer_type = manifests.installer.nested_installer_type; + } + if manifests.installer.scope.is_some() { + installer.scope = manifests.installer.scope; + } + installer + }) + .collect::>(); + + let duplicate_urls = previous_installers + .iter() + .map(|installer| installer.url.clone()) + .duplicates() + .collect::>(); + + manifests.default_locale.package_version = package_version.clone(); + let matched_installers = match_installers(previous_installers, &installer_results); + let mut installers = matched_installers + .into_iter() + .map(|(previous_installer, new_installer)| { + let analyzer = &download_results[&new_installer.url]; + let installer_type = match previous_installer.r#type { + Some(InstallerType::Portable) => previous_installer.r#type, + _ => match new_installer.r#type { + Some(InstallerType::Portable) => previous_installer.r#type, + _ => new_installer.r#type, + }, + }; + + let previous_nested_files = previous_installer.nested_installer_files.clone(); + let previous_url = previous_installer.url.clone(); + let previous_architecture = previous_installer.architecture; + + let mut installer = new_installer.clone().merge_with(previous_installer); + installer.r#type = installer_type; + installer.url.clone_from(&new_installer.url); + + let nested_files_to_fix = [ + &previous_nested_files, + &manifests.installer.nested_installer_files, + &installer.nested_installer_files, + ] + .into_iter() + .find(|files| !files.is_empty()) + .cloned(); + + if let Some(nested_files) = nested_files_to_fix { + installer.nested_installer_files = + fix_relative_paths(nested_files, analyzer.zip.as_ref()); + } + + if duplicate_urls.contains(&previous_url) { + installer.architecture = previous_architecture; + } + + for entry in &mut installer.apps_and_features_entries { + entry.deduplicate(&manifests.default_locale); + } + installer + }) + .collect::>(); + + if installers + .iter() + .flat_map(|installer| &installer.locale) + .all_equal() + { + for installer in &mut installers { + installer.locale = None; + } + } + + manifests.installer.locale = None; + + manifests.installer.package_version = package_version.clone(); + manifests.installer.minimum_os_version = manifests + .installer + .minimum_os_version + .filter(|minimum_os_version| *minimum_os_version != MinimumOSVersion::new(10, 0, 0, 0)); + manifests.installer.installers = installers; + manifests.installer.optimize(); + + manifests.default_locale.update( + &package_version, + &mut github_values, + release_notes_url.as_ref(), + ); + + if let Some(release_notes) = release_notes { + manifests.default_locale.release_notes = Some(release_notes); + } + + manifests.locales.iter_mut().for_each(|locale| { + locale.update(&package_version, &mut github_values, None); + }); + + manifests.version.update(&package_version); + + let package_path = PackagePath::new(&package_identifier, Some(&package_version), None); + let changes = pr_changes() + .package_identifier(&package_identifier) + .manifests(&manifests) + .package_path(&package_path) + .create() + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to create PR changes: {e}"), + ) + })?; + + if dry_run { + return Ok(UpdateVersionResult { + pull_request_url: None, + changes: changes + .iter() + .map(|(path, content)| ManifestChange { + path: path.clone(), + content: content.clone(), + }) + .collect(), + package_identifier: package_identifier.to_string(), + version: package_version.to_string(), + }); + } + + let pull_request_url = github + .add_version() + .identifier(&package_identifier) + .version(&package_version) + .versions(&versions) + .changes(changes.clone()) + .maybe_replace_version(replace_version) + .issue_resolves(&[]) + .send() + .await + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to create pull request: {e}"), + ) + })?; + + Ok(UpdateVersionResult { + pull_request_url: Some(pull_request_url.url().to_string()), + changes: changes + .into_iter() + .map(|(path, content)| ManifestChange { path, content }) + .collect(), + package_identifier: package_identifier.to_string(), + version: package_version.to_string(), + }) +} diff --git a/src/commands/complete.rs b/src/commands/complete.rs index bb9c0d1be..549e5490b 100644 --- a/src/commands/complete.rs +++ b/src/commands/complete.rs @@ -1,9 +1,7 @@ -use clap::{CommandFactory, Parser}; -use clap_complete::{Shell, generate}; +use clap::Parser; +use clap_complete::Shell; use color_eyre::{Result, Section, eyre::eyre}; -use crate::Cli; - /// Outputs an autocompletion script for the given shell. Example usage: /// /// Bash: echo "source <(komac complete bash)" >> ~/.bashrc @@ -22,7 +20,15 @@ pub struct Complete { } impl Complete { - pub fn run(self) -> Result<()> { + /// Generate shell completions. + /// + /// When invoked from the binary, the caller should provide a closure that + /// generates completions for the given shell (since the `Cli` type is only + /// available in `main.rs`). + pub fn run_with(self, generate_fn: F) -> Result<()> + where + F: FnOnce(Shell), + { let Some(shell) = self.shell.or_else(Shell::from_env) else { return Err( eyre!("Unable to determine the current shell from the environment") @@ -30,10 +36,14 @@ impl Complete { ); }; - let mut command = Cli::command(); - let command_name = command.get_name().to_owned(); - generate(shell, &mut command, command_name, &mut anstream::stdout()); - + generate_fn(shell); Ok(()) } + + #[allow(dead_code)] + pub fn run() -> Result<()> { + Err(eyre!( + "Completion generation is not available in library mode" + )) + } } diff --git a/src/commands/new_version.rs b/src/commands/new_version.rs index c8f20a994..32bb15262 100644 --- a/src/commands/new_version.rs +++ b/src/commands/new_version.rs @@ -174,13 +174,17 @@ impl NewVersion { let package_version = required_prompt(self.package_version, None::<&str>)?; - if !self.skip_pr_check - && !self.dry_run - && let Some(pull_request) = github - .get_existing_pull_request(&package_identifier, &package_version) - .await? - && !prompt_existing_pull_request(&package_identifier, &package_version, &pull_request)? - { + let existing_pr = github + .get_existing_pull_request(&package_identifier, &package_version, false) + .await?; + + if should_abort_for_existing_pr( + &package_identifier, + &package_version, + existing_pr, + self.skip_pr_check, + self.dry_run, + )? { return Ok(()); } diff --git a/src/commands/remove_dead_versions.rs b/src/commands/remove_dead_versions.rs index 982357ee0..7a09076e6 100644 --- a/src/commands/remove_dead_versions.rs +++ b/src/commands/remove_dead_versions.rs @@ -255,7 +255,7 @@ async fn confirm_removal( auto: bool, ) -> Result { if let Some(pull_request) = github - .get_existing_pull_request(identifier, version) + .get_existing_pull_request(identifier, version, false) .await? && pull_request.is_open() { diff --git a/src/commands/remove_version.rs b/src/commands/remove_version.rs index 0247efc36..87baaf0ef 100644 --- a/src/commands/remove_version.rs +++ b/src/commands/remove_version.rs @@ -92,6 +92,22 @@ impl RemoveVersion { "Latest version of {}: {latest_version}", &self.package_identifier ); + + let existing_pr = github + .get_existing_pull_request(&self.package_identifier, &self.package_version, false) + .await? + .filter(|pull_request| pull_request.is_open()); + + if should_abort_for_existing_pr( + &self.package_identifier, + &self.package_version, + existing_pr, + false, + false, + )? { + return Ok(()); + } + let deletion_reason = match self.deletion_reason { Some(reason) => reason, None => Text::new(&format!( diff --git a/src/commands/update_version.rs b/src/commands/update_version.rs index 153ac8eb8..6834de4c0 100644 --- a/src/commands/update_version.rs +++ b/src/commands/update_version.rs @@ -114,6 +114,13 @@ impl UpdateVersion { let (versions, existing_pr) = try_join!( github.get_versions(&self.package_identifier), github.get_existing_pull_request(&self.package_identifier, &self.package_version), + let ((versions, font), existing_pr) = try_join!( + github.get_versions(&self.package_identifier, self.font.then_some(true)), + github.get_existing_pull_request( + &self.package_identifier, + &self.package_version, + false, + ), )?; let latest_version = versions.last().unwrap_or_else(|| unreachable!()); diff --git a/src/download/downloader.rs b/src/download/downloader.rs index 3606f7adf..e7fd02e78 100644 --- a/src/download/downloader.rs +++ b/src/download/downloader.rs @@ -3,7 +3,7 @@ use std::{fmt, num::NonZeroUsize}; use chrono::DateTime; use color_eyre::{Result, eyre::bail}; use futures_util::{StreamExt, TryStreamExt, stream}; -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; use itertools::{Itertools, Position}; use reqwest::{ Client, @@ -26,6 +26,7 @@ use super::{Download, DownloadedFile}; pub struct Downloader { client: Client, concurrent_downloads: NonZeroUsize, + show_progress: bool, } impl Downloader { @@ -67,12 +68,29 @@ impl Downloader { /// /// [`ClientBuilder::build`]: reqwest::ClientBuilder::build pub fn new_with_concurrent(concurrent_downloads: NonZeroUsize) -> reqwest::Result { + Self::new_with_concurrent_and_progress(concurrent_downloads, true) + } + + /// Creates a new Downloader with a specified number of maximum concurrent downloads and + /// optional progress rendering. + /// + /// # Errors + /// + /// Propagates the error from [`ClientBuilder::build`] which fails if a TLS backend cannot be + /// initialized, or the resolver cannot load the system configuration. + /// + /// [`ClientBuilder::build`]: reqwest::ClientBuilder::build + pub fn new_with_concurrent_and_progress( + concurrent_downloads: NonZeroUsize, + show_progress: bool, + ) -> reqwest::Result { Ok(Self { client: Client::builder() .default_headers(Self::headers()) .referer(false) .build()?, concurrent_downloads, + show_progress, }) } @@ -81,7 +99,11 @@ impl Downloader { I: IntoIterator, D: Into, { - let multi_progress = MultiProgress::new(); + let multi_progress = if self.show_progress { + MultiProgress::new() + } else { + MultiProgress::with_draw_target(ProgressDrawTarget::hidden()) + }; let downloaded_files = stream::iter(downloads.into_iter().map(D::into).unique()) .map(|download| self.fetch(&self.client, download, &multi_progress)) diff --git a/src/github/graphql/get_existing_pull_request.rs b/src/github/graphql/get_existing_pull_request.rs index d1eab0885..e20f4831f 100644 --- a/src/github/graphql/get_existing_pull_request.rs +++ b/src/github/graphql/get_existing_pull_request.rs @@ -42,9 +42,25 @@ pub struct PullRequest { pub url: Url, pub state: PullRequestState, pub created_at: DateTime, + #[allow(dead_code)] + pub viewer_did_author: bool, + #[allow(dead_code)] + pub author: Option, +} + +#[derive(cynic::QueryFragment)] +pub struct Actor { + #[allow(dead_code)] + pub login: String, } impl PullRequest { + #[allow(dead_code)] + #[inline] + pub fn author_login(&self) -> Option<&String> { + self.author.as_ref().map(|author| &author.login) + } + /// Returns `true` if the pull request has been closed without being merged. #[expect(unused)] #[inline] @@ -87,37 +103,42 @@ impl GitHub { &self, identifier: &PackageIdentifier, version: &PackageVersion, + authenticated_user_only: bool, ) -> Result, GitHubError> { - self - .0 - .post(GRAPHQL_URL) - .run_graphql(GetExistingPullRequest::build(GetExistingPullRequestVariables { - query: &format!("repo:{WINGET_PKGS_FULL_NAME} is:pull-request in:title {identifier} {version}"), - })) - .await - .map(|response| { - response - .data? - .into_pull_requests() - .find(|pull_request| { - let title = &*pull_request.title; - // Check that the identifier and version are used in their entirety and not - // part of another package identifier or version. For example, ensuring we - // match against "Microsoft.Excel" not "Microsoft.Excel.Beta", or "1.2.3" - // and not "1.2.3-beta" as `in:title` in the query only does a 'contains' - // rather than a word boundary match. - [identifier.as_str(), version.as_str()] - .into_iter() - .all(|needle| { - title.match_indices(needle).any(|(index, matched)| { - let before = title[..index].chars().next_back(); - let after = title[index + matched.len()..].chars().next(); - // Check whether the characters before and after the identifier - // are either None (at the boundary of the title) or whitespace - before.is_none_or(char::is_whitespace) - && after.is_none_or(char::is_whitespace) - }) - }) + let author_filter = if authenticated_user_only { + " author:@me" + } else { + "" + }; + let query = format!( + "repo:{WINGET_PKGS_FULL_NAME} is:pull-request in:title {identifier} {version}{author_filter}" + ); + + let response = self + .run_graphql_with_retry(&GetExistingPullRequest::build( + GetExistingPullRequestVariables { query: &query }, + )) + .await?; + + Ok(response.data.and_then(|data| { + data.into_pull_requests().find(|pull_request| { + let title = &*pull_request.title; + // Check that the identifier and version are used in their entirety and not + // part of another package identifier or version. For example, ensuring we + // match against "Microsoft.Excel" not "Microsoft.Excel.Beta", or "1.2.3" + // and not "1.2.3-beta" as `in:title` in the query only does a 'contains' + // rather than a word boundary match. + [identifier.as_str(), version.as_str()] + .into_iter() + .all(|needle| { + title.match_indices(needle).any(|(index, matched)| { + let before = title[..index].chars().next_back(); + let after = title[index + matched.len()..].chars().next(); + // Check whether the characters before and after the identifier + // are either None (at the boundary of the title) or whitespace + before.is_none_or(char::is_whitespace) + && after.is_none_or(char::is_whitespace) + }) }) }) .map_err(GitHubError::CynicRequest) @@ -143,6 +164,10 @@ mod tests { url state createdAt + viewerDidAuthor + author { + login + } } } } diff --git a/src/github/graphql/update_refs.rs b/src/github/graphql/update_refs.rs index 21f875838..2b2d9ec61 100644 --- a/src/github/graphql/update_refs.rs +++ b/src/github/graphql/update_refs.rs @@ -55,7 +55,7 @@ impl<'id> UpdateRefsInput<'id> { #[derive(cynic::QueryFragment)] #[cynic(graphql_type = "Mutation", variables = "UpdateRefsInput")] pub struct UpdateRefs { - #[expect(dead_code)] + #[allow(dead_code)] #[arguments(input: { clientMutationId: $client_mutation_id, refUpdates: $ref_updates, repositoryId: $repository_id })] pub update_refs: Option, } @@ -63,7 +63,7 @@ pub struct UpdateRefs { #[derive(cynic::QueryFragment)] pub struct UpdateRefsPayload { /// A unique identifier for the client performing the mutation. - #[expect(dead_code)] + #[allow(dead_code)] pub client_mutation_id: Option, } diff --git a/src/github/utils/mod.rs b/src/github/utils/mod.rs index 046ce9dad..5d8e79367 100644 --- a/src/github/utils/mod.rs +++ b/src/github/utils/mod.rs @@ -5,7 +5,6 @@ pub mod pull_request; use std::{env, fmt::Write, num::NonZeroU32}; use bon::builder; -use clap::{crate_name, crate_version}; pub use commit_title::CommitTitle; use itertools::Itertools; pub use package_path::PackagePath; @@ -21,6 +20,14 @@ const YAML_EXTENSION: &str = ".yaml"; const LOCALE_PART: &str = ".locale."; const INSTALLER_PART: &str = ".installer"; +fn is_running_via_cli() -> bool { + std::env::current_exe().ok().is_some_and(|path| { + !["bun", "deno", "node"] + .iter() + .any(|bin| path.to_string_lossy().contains(bin)) + }) +} + pub fn is_manifest_file( file_name: &str, package_identifier: &PackageIdentifier, @@ -72,55 +79,23 @@ pub fn is_manifest_file( pub fn pull_request_body( #[builder(default)] issue_resolves: &[NonZeroU32], alternative_text: Option<&str>, - created_with: Option<&str>, - created_with_url: Option<&DecodedUrl>, + _created_with: Option<&str>, + _created_with_url: Option<&DecodedUrl>, ) -> String { - const FRUITS: [&str; 16] = [ - "apple", - "banana", - "blueberries", - "cherries", - "grapes", - "green_apple", - "kiwi_fruit", - "lemon", - "mango", - "melon", - "peach", - "pear", - "pineapple", - "strawberry", - "tangerine", - "watermelon", - ]; + const EMOJIS: [&str; 10] = ["🌌", "🌠", "⭐", "✨", "🌟", "☄️", "🚀", "🛰️", "🌠", "🔭"]; let mut body = String::new(); if let Some(alternative_text) = alternative_text { let _ = writeln!(body, "### {alternative_text}"); } else { let mut rng = rand::rng(); + let emoji = EMOJIS[rng.random_range(0..EMOJIS.len())]; - let emoji = if rng.random_ratio(1, 50) { - FRUITS[rng.random_range(0..FRUITS.len())] - } else { - "rocket" - }; - - body.push_str("### Pull request has been created with "); - - if let (Some(tool_name), Some(tool_url)) = (created_with, created_with_url) { - let _ = write!(body, "[{tool_name}]({tool_url})"); + if is_running_via_cli() { + let _ = write!(body, "Created by {emoji} Anthelion."); } else { - let _ = write!( - body, - "[{}]({}) v{}", - crate_name!(), - env!("CARGO_PKG_REPOSITORY"), - crate_version!() - ); + let _ = write!(body, "Automatically updated by {emoji} Anthelion."); } - - let _ = writeln!(body, " :{emoji}:"); } if !issue_resolves.is_empty() { @@ -130,6 +105,34 @@ pub fn pull_request_body( } } + if env::var("GITHUB_ACTIONS").is_ok_and(|v| v == "true") { + if !body.ends_with('\n') { + body.push('\n'); + } + body.push('\n'); + let _ = writeln!(body, "
"); + let _ = writeln!(body, "Debug"); + let _ = writeln!(body); + + if let (Ok(repository), Ok(run_id)) = + (env::var("GITHUB_REPOSITORY"), env::var("GITHUB_RUN_ID")) + { + let server_url = + env::var("GITHUB_SERVER_URL").unwrap_or_else(|_| "https://github.com".to_owned()); + let _ = writeln!( + body, + "Run URL: {server_url}/{repository}/actions/runs/{run_id}" + ); + } + let _ = writeln!( + body, + "Anthelion komac SHA: {}", + option_env!("GITHUB_SHA").unwrap_or("N/A") + ); + + let _ = writeln!(body, "
"); + } + body } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 000000000..4298a8b3c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,19 @@ +#![allow(dead_code)] + +mod analysis; +mod anthelion; +mod commands; +mod download; +mod download_file; +mod editor; +mod github; +mod manifests; +mod match_installers; +mod prompts; +mod read; +mod terminal; +mod token; +mod traits; +mod update_state; + +pub use anthelion::*; diff --git a/src/main.rs b/src/main.rs index ed7e5dcf0..0779bc940 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,7 +60,13 @@ async fn main() -> Result<()> { Commands::List(list_versions) => list_versions.run().await, Commands::Show(show_version) => show_version.run().await, Commands::Sync(sync_fork) => sync_fork.run().await, - Commands::Complete(complete) => complete.run(), + Commands::Complete(complete) => complete.run_with(|shell| { + use clap::CommandFactory; + use clap_complete::generate; + let mut command = Cli::command(); + let command_name = command.get_name().to_owned(); + generate(shell, &mut command, command_name, &mut anstream::stdout()); + }), Commands::Analyze(analyse) => analyse.run(), Commands::RemoveDeadVersions(remove_dead_versions) => remove_dead_versions.run().await, Commands::Submit(submit) => submit.run().await, diff --git a/src/manifests/mod.rs b/src/manifests/mod.rs index 47ed05346..19a14bca4 100644 --- a/src/manifests/mod.rs +++ b/src/manifests/mod.rs @@ -109,15 +109,12 @@ pub fn print_manifest(lock: &mut AutoStream>, manifest: &str pub fn build_manifest_string( manifest: &T, - created_with: Option<&str>, + _created_with: Option<&str>, ) -> serde_yaml::Result where T: Manifest + Serialize, { - let mut result = String::from("# Created with "); - if let Some(created_with_tool) = created_with { - let _ = write!(result, "{created_with_tool} using "); - } + let mut result = String::from("# Created by Anthelion using "); let _ = writeln!(result, "{} v{}", crate_name!(), crate_version!()); let _ = writeln!(result, "# yaml-language-server: $schema={}", T::SCHEMA); let _ = writeln!(result); diff --git a/src/traits/mod.rs b/src/traits/mod.rs index e450c4b9b..18530741d 100644 --- a/src/traits/mod.rs +++ b/src/traits/mod.rs @@ -1,7 +1,7 @@ mod ascii_ext; pub mod name; pub mod path; -use std::{mem, sync::LazyLock}; +use std::{cell::Cell, mem, sync::LazyLock}; pub use ascii_ext::AsciiExt; use html2text::render::{TaggedLine, TextDecorator}; @@ -69,7 +69,12 @@ impl IntoWingetArchitecture for PE { } } -struct GitHubHtmlDecorator; +#[derive(Default)] +struct GitHubHtmlDecorator { + seen_header: Cell, +} + +const HEADER_MARKER: &str = "__KOMAC_HEADER__ "; impl TextDecorator for GitHubHtmlDecorator { type Annotation = (); @@ -118,12 +123,13 @@ impl TextDecorator for GitHubHtmlDecorator { fn decorate_preformat_cont(&self) -> Self::Annotation {} - fn decorate_image(&mut self, _src: &str, title: &str) -> (String, Self::Annotation) { - (title.to_string(), ()) + fn decorate_image(&mut self, _src: &str, _title: &str) -> (String, Self::Annotation) { + (String::new(), ()) } fn header_prefix(&self, _level: usize) -> String { - String::new() + self.seen_header.set(true); + String::from(HEADER_MARKER) } fn quote_prefix(&self) -> String { @@ -139,7 +145,7 @@ impl TextDecorator for GitHubHtmlDecorator { } fn make_subblock_decorator(&self) -> Self { - Self + Self::default() } fn finalise(&mut self, _links: Vec) -> Vec> { @@ -153,15 +159,57 @@ pub trait FromHtml { Self: Sized; } +fn add_section_spacing(text: &str) -> String { + let mut output = String::with_capacity(text.len() + 32); + let mut seen_header = false; + let mut previous_line_blank = true; + + for line in text.lines() { + let is_blank = line.trim().is_empty(); + if let Some(header) = line.strip_prefix(HEADER_MARKER) { + if seen_header && !previous_line_blank { + output.push('\n'); + } + output.push_str(header); + output.push('\n'); + seen_header = true; + previous_line_blank = false; + continue; + } + + output.push_str(line); + output.push('\n'); + previous_line_blank = is_blank; + } + + output +} + impl FromHtml for ReleaseNotes { fn from_html(html: &Html) -> Option { // Strings that have whitespace before newlines get escaped and treated as literal strings // in YAML so this regex identifies any amount of whitespace and duplicate newlines static NEWLINE_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"\s+\n").unwrap()); + // GitHub release notes often end with compare lines such as: + // "Full Changelog: v2.14.0...v2.15.0" or "Full Changelog: 2.14.0...2.15.0". + // Remove any line containing a tag/version compare range. + static COMPARE_RANGE_LINE_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"(?im)^[^\n]*\b[0-9A-Za-z][0-9A-Za-z._+-]*\.\.\.[0-9A-Za-z][0-9A-Za-z._+-]*[^\n]*\n?") + .unwrap() + }); - html2text::from_read_with_decorator(html.as_bytes(), usize::MAX, GitHubHtmlDecorator) - .ok() - .and_then(|text| Self::new(NEWLINE_REGEX.replace_all(&text, "\n")).ok()) + html2text::from_read_with_decorator( + html.as_bytes(), + usize::MAX, + GitHubHtmlDecorator::default(), + ) + .ok() + .and_then(|text| { + let normalized_text = COMPARE_RANGE_LINE_REGEX.replace_all(&text, ""); + let normalized_text = NEWLINE_REGEX.replace_all(&normalized_text, "\n"); + let normalized_text = add_section_spacing(&normalized_text); + Self::new(normalized_text.trim()).ok() + }) } } From b1f729ab02443eb6b536f048790165cd9b026d58 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Sat, 25 Apr 2026 14:49:42 -0400 Subject: [PATCH 03/31] Enable traces in release builds --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index f4734babe..fcf7a30ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,7 +93,7 @@ supports-hyperlinks = "3.2.0" tempfile = "3.27.0" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "fs", "parking_lot"] } -tracing = { version = "0.1.44", features = ["release_max_level_warn"] } +tracing = { version = "0.1.44", features = ["release_max_level_trace"] } tracing-indicatif = "0.3.14" tracing-subscriber = "0.3.23" tree-sitter-highlight = "0.26.9" From 0192567ffe43dc770d77b226e58afb64a94c9920 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Mon, 9 Mar 2026 18:15:16 -0400 Subject: [PATCH 04/31] Add configurable winget-pkgs repo --- src/github/mod.rs | 148 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 5 deletions(-) diff --git a/src/github/mod.rs b/src/github/mod.rs index bb557da38..06a6c76d4 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -4,10 +4,148 @@ pub mod graphql; mod rest; pub mod utils; -use const_format::formatcp; +use std::{ + env, + fmt::{self, Display, Formatter}, + ops::Deref, + sync::OnceLock, +}; + pub use error::GitHubError; -pub const MICROSOFT: &str = "microsoft"; -pub const WINGET_PKGS: &str = "winget-pkgs"; -pub const WINGET_PKGS_FULL_NAME: &str = formatcp!("{MICROSOFT}/{WINGET_PKGS}"); -pub const GITHUB_HOST: &str = "github.com"; +pub struct EnvStr { + env_var: &'static str, + default: &'static str, + value: OnceLock, +} + +impl EnvStr { + pub const fn new(env_var: &'static str, default: &'static str) -> Self { + Self { + env_var, + default, + value: OnceLock::new(), + } + } + + pub fn as_str(&self) -> &str { + self.value + .get_or_init(|| { + env::var(self.env_var) + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| self.default.to_owned()) + }) + .as_str() + } + + pub fn set(&self, value: String) { + if !value.is_empty() { + let _ = self.value.set(value); + } + } +} + +impl AsRef for EnvStr { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for EnvStr { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl Display for EnvStr { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl PartialEq<&EnvStr> for &str { + fn eq(&self, other: &&EnvStr) -> bool { + *self == other.as_str() + } +} + +impl PartialEq<&str> for &EnvStr { + fn eq(&self, other: &&str) -> bool { + self.as_str() == *other + } +} + +pub struct GitHubFullName { + value: OnceLock, +} + +impl GitHubFullName { + pub const fn new() -> Self { + Self { + value: OnceLock::new(), + } + } + + pub fn as_str(&self) -> &str { + self.value + .get_or_init(|| format!("{}/{}", MICROSOFT.as_str(), WINGET_PKGS.as_str())) + .as_str() + } +} + +impl AsRef for GitHubFullName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for GitHubFullName { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl Display for GitHubFullName { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl PartialEq<&GitHubFullName> for String { + fn eq(&self, other: &&GitHubFullName) -> bool { + self.as_str() == other.as_str() + } +} + +impl PartialEq for &GitHubFullName { + fn eq(&self, other: &String) -> bool { + self.as_str() == other.as_str() + } +} + +impl PartialEq<&GitHubFullName> for &str { + fn eq(&self, other: &&GitHubFullName) -> bool { + *self == other.as_str() + } +} + +impl PartialEq<&str> for &GitHubFullName { + fn eq(&self, other: &&str) -> bool { + self.as_str() == *other + } +} + +static MICROSOFT_VALUE: EnvStr = EnvStr::new("KOMAC_GITHUB_OWNER", "microsoft"); +static WINGET_PKGS_VALUE: EnvStr = EnvStr::new("KOMAC_GITHUB_REPO", "winget-pkgs"); +static WINGET_PKGS_FULL_NAME_VALUE: GitHubFullName = GitHubFullName::new(); +static GITHUB_HOST_VALUE: EnvStr = EnvStr::new("KOMAC_GITHUB_HOST", "github.com"); + +pub static MICROSOFT: &EnvStr = &MICROSOFT_VALUE; +pub static WINGET_PKGS: &EnvStr = &WINGET_PKGS_VALUE; +pub static WINGET_PKGS_FULL_NAME: &GitHubFullName = &WINGET_PKGS_FULL_NAME_VALUE; +pub static GITHUB_HOST: &EnvStr = &GITHUB_HOST_VALUE; From a260f7d6d5c26ecd0b01e959465a2beb91ba86b1 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Mon, 2 Mar 2026 17:50:36 -0500 Subject: [PATCH 05/31] Remove InstallerLocale if only 1 locale is present --- src/commands/update_version.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/commands/update_version.rs b/src/commands/update_version.rs index 6834de4c0..d84884a85 100644 --- a/src/commands/update_version.rs +++ b/src/commands/update_version.rs @@ -173,7 +173,7 @@ impl UpdateVersion { manifests.default_locale.package_version = self.package_version.clone(); let matched_installers = match_installers(previous_installers, &installer_results); - let installers = matched_installers + let mut installers = matched_installers .into_iter() .map(|(previous_installer, new_installer)| { let analyzer = &download_results[&new_installer.url]; @@ -218,6 +218,18 @@ impl UpdateVersion { }) .collect::>(); + if installers + .iter() + .flat_map(|installer| &installer.locale) + .all_equal() + { + for installer in &mut installers { + installer.locale = None; + } + } + + manifests.installer.locale = None; + manifests.installer.package_version = self.package_version.clone(); manifests.installer.minimum_os_version = manifests .installer From a6006342016c086953283e94545322da2610736c Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Mon, 2 Mar 2026 17:48:44 -0500 Subject: [PATCH 06/31] Lowercase RelativeFilePath extensions --- src/analysis/installers/zip.rs | 4 ++-- src/commands/update_version.rs | 7 +++++-- src/traits/path.rs | 10 ++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/analysis/installers/zip.rs b/src/analysis/installers/zip.rs index 2d571c069..7589b6144 100644 --- a/src/analysis/installers/zip.rs +++ b/src/analysis/installers/zip.rs @@ -164,7 +164,7 @@ impl Zip { { let chosen_file_name = &possible_installer_files[0]; nested_installer_files = BTreeSet::from([NestedInstallerFiles { - relative_file_path: chosen_file_name.clone(), + relative_file_path: chosen_file_name.lowercase_extension(), portable_command_alias: None, }]); if let Ok(mut chosen_file) = zip.by_name(chosen_file_name.as_str()) { @@ -234,7 +234,7 @@ impl Zip { } else { None }, - relative_file_path: path, + relative_file_path: path.lowercase_extension(), }) }) .collect::>>()?; diff --git a/src/commands/update_version.rs b/src/commands/update_version.rs index d84884a85..c58ba28ed 100644 --- a/src/commands/update_version.rs +++ b/src/commands/update_version.rs @@ -38,7 +38,10 @@ use crate::{ manifests::Url, match_installers::match_installers, token::TokenManager, - traits::{LocaleExt, path::NormalizePath}, + traits::{ + LocaleExt, + path::{LowercaseExtension, NormalizePath}, + }, }; /// Add a version to a pre-existing package @@ -411,7 +414,7 @@ fn fix_relative_paths( ) }) .map(|path| NestedInstallerFiles { - relative_file_path: path.to_path_buf(), + relative_file_path: path.lowercase_extension(), ..nested_installer_files }) } diff --git a/src/traits/path.rs b/src/traits/path.rs index 17e88875e..f7d233318 100644 --- a/src/traits/path.rs +++ b/src/traits/path.rs @@ -9,6 +9,16 @@ pub trait NormalizePath { fn normalize(&self) -> Utf8PathBuf; } +pub trait LowercaseExtension { + fn lowercase_extension(&self) -> Utf8PathBuf; +} + +impl LowercaseExtension for Utf8PathBuf { + fn lowercase_extension(&self) -> Utf8PathBuf { + self.with_extension(self.extension().unwrap_or_default().to_ascii_lowercase()) + } +} + impl NormalizePath for Utf8Path { fn normalize(&self) -> Utf8PathBuf { let mut components = self.components().peekable(); From 9d1ae2c2dae48fbceac7ce9d653776852457a0f6 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Thu, 5 Feb 2026 22:35:42 -0500 Subject: [PATCH 07/31] Add external editor support for editing manifests --- src/commands/utils/environment.rs | 6 +++ src/commands/utils/submit_option.rs | 16 ++++-- src/editor.rs | 82 ++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/commands/utils/environment.rs b/src/commands/utils/environment.rs index 09c5f68c1..d1370ca59 100644 --- a/src/commands/utils/environment.rs +++ b/src/commands/utils/environment.rs @@ -5,3 +5,9 @@ pub static CI: LazyLock = pub static VHS: LazyLock = LazyLock::new(|| env::var("VHS").is_ok_and(|vhs| vhs.parse() == Ok(true))); + +pub static EDITOR: LazyLock> = LazyLock::new(|| { + ["KOMAC_EDITOR", "EDITOR"] + .into_iter() + .find_map(|key| env::var(key).ok().filter(|val| !val.is_empty())) +}); diff --git a/src/commands/utils/submit_option.rs b/src/commands/utils/submit_option.rs index de290f326..20c0c66fa 100644 --- a/src/commands/utils/submit_option.rs +++ b/src/commands/utils/submit_option.rs @@ -2,10 +2,13 @@ use std::fmt; use color_eyre::Result; use inquire::Select; +use tracing::error; use winget_types::{PackageIdentifier, PackageVersion}; use crate::{ - commands::utils::environment::VHS, editor::Editor, manifests::print_changes, + commands::utils::environment::{EDITOR, VHS}, + editor::{Editor, EditorError, edit_externally}, + manifests::print_changes, prompts::handle_inquire_error, }; @@ -47,11 +50,16 @@ impl SubmitOption { .map_err(handle_inquire_error)? }; - if submit_option.is_edit() { - Editor::new(changes).run()?; - } else { + if !submit_option.is_edit() { break; } + + if let Err(error) = edit_externally(EDITOR.as_deref(), changes) { + if !matches!(error, EditorError::CommandEmpty) { + error!("External editor failed to load: {}", error); + } + Editor::new(changes).run()?; + } } Ok(submit_option) diff --git a/src/editor.rs b/src/editor.rs index 7416dc27b..262d0b890 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,5 +1,6 @@ -use std::{borrow::Cow, fmt::Display, io, ops::Add}; +use std::{borrow::Cow, fmt::Display, fs, io, ops::Add, process::Command}; +use camino::Utf8Path; use ratatui::{ DefaultTerminal, layout::{Constraint, Direction, Layout}, @@ -8,6 +9,85 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, }; use ratatui_textarea::{CursorMove, DataCursor, Input, Key, TextArea}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum EditorError { + #[error("command is empty")] + CommandEmpty, + #[error("process exited with code {0}")] + NonZeroExit(i32), + #[error("process terminated by signal")] + SignalTermination(), + #[error(transparent)] + Io(#[from] io::Error), +} + +pub fn edit_externally( + editor: Option<&str>, + changes: &mut [(String, String)], +) -> Result<(), EditorError> { + let Some(editor) = editor else { + return Err(EditorError::CommandEmpty); + }; + + let temp_dir = tempfile::tempdir()?; + + let file_paths: Vec<_> = changes + .iter() + .map(|(path, content)| { + let file_name = Utf8Path::new(path).file_name().unwrap_or(path.as_str()); + let file_path = temp_dir.path().join(file_name); + fs::write(&file_path, content.as_bytes())?; + Ok(file_path) + }) + .collect::>()?; + + println!("Waiting for your editor to close the files..."); + + #[cfg(windows)] + let status = { + // Command::new behavior is weird on Windows, spawn a shell to avoid that + let file_args = file_paths + .iter() + .map(|path| { + let escaped = path.to_string_lossy().replace('\'', "''"); + format!("'{escaped}'") + }) + .collect::>() + .join(" "); + + Command::new("powershell") + .arg("-NoProfile") + .arg("-Command") + .arg(format!("{editor} {file_args}")) + .status()? + }; + + #[cfg(not(windows))] + let status = { + let mut parts = editor.split_whitespace(); + let program = parts.next().ok_or(EditorError::CommandEmpty)?; + + Command::new(program) + .args(parts) + .args(&file_paths) + .status()? + }; + + if !status.success() { + match status.code() { + Some(status) => return Err(EditorError::NonZeroExit(status)), + None => return Err(EditorError::SignalTermination()), + } + } + + for (file_path, (_, content)) in file_paths.iter().zip(changes.iter_mut()) { + *content = fs::read_to_string(file_path)?; + } + + Ok(()) +} struct SearchBox<'a> { textarea: TextArea<'a>, From 1ff48f525242b9204af7bbc93f9c0835313a8a74 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Mon, 16 Mar 2026 21:13:21 -0400 Subject: [PATCH 08/31] Match installer locales correctly --- src/match_installers.rs | 244 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 235 insertions(+), 9 deletions(-) diff --git a/src/match_installers.rs b/src/match_installers.rs index 9f5a46b38..4acc16f85 100644 --- a/src/match_installers.rs +++ b/src/match_installers.rs @@ -3,6 +3,54 @@ use std::collections::HashMap; use camino::Utf8Path; use winget_types::installer::{Architecture, Installer, Scope, VALID_FILE_EXTENSIONS}; +fn locale_score(previous_installer: &Installer, new_installer: &Installer) -> f64 { + let Some(previous_locale) = previous_installer.locale.as_ref() else { + return 0.0; + }; + + // Compare directly if installer analysis can detect a locale + if let Some(new_locale) = new_installer.locale.as_ref() { + if new_locale == previous_locale { + return 3.0; + } + if new_locale.language() == previous_locale.language() { + return 2.0; + } + return 0.0; + } + + // Otherwise fall back to inferring locale from the URL + let url = new_installer.url.as_str().to_ascii_lowercase(); + let url_tokens: Vec<&str> = url + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|t| !t.is_empty()) + .collect(); + let has_token = |s: &str| url_tokens.contains(&s.to_ascii_lowercase().as_str()); + let mut score = 0.0; + + if has_token(previous_locale.language().as_str()) { + score += 2.0; + } + if let Some(script) = previous_locale.script() + && has_token(script.as_str()) + { + score += 1.0; + } + if let Some(region) = previous_locale.region() + && has_token(region.as_str()) + { + score += 1.0; + } + score +} + +fn url_similarity_score(previous_url: &str, new_url: &str) -> f64 { + let previous_url = previous_url.to_ascii_lowercase(); + let new_url = new_url.to_ascii_lowercase(); + + strsim::jaro_winkler(&previous_url, &new_url) * 3.0 +} + pub fn match_installers( previous_installers: Vec, new_installers: &[Installer], @@ -26,28 +74,34 @@ pub fn match_installers( previous_installers .into_iter() .map(|previous_installer| { - let mut max_score = 0; + let mut max_score = 0.0; let mut best_match = None; for new_installer in new_installers { let installer_url = &new_installer.url; - let mut score = 0; + let mut score = 0.0; if new_installer.architecture == previous_installer.architecture { - score += 1; + score += 1.0; } if found_architectures.get(installer_url) == Some(&previous_installer.architecture) { - score += 1; + score += 1.0; } if new_installer.r#type == previous_installer.r#type { - score += 3; + score += 3.0; } if new_installer.nested_installer_type == previous_installer.nested_installer_type { - score += 3; + score += 3.0; } if new_installer.scope == previous_installer.scope { - score += 1; + score += 1.0; } + score += locale_score(&previous_installer, new_installer); + score += url_similarity_score( + previous_installer.url.as_str(), + new_installer.url.as_str(), + ); + let new_extension = Utf8Path::new(new_installer.url.as_str()) .extension() .filter(|extension| VALID_FILE_EXTENSIONS.contains(extension)) @@ -57,7 +111,7 @@ pub fn match_installers( .filter(|extension| VALID_FILE_EXTENSIONS.contains(extension)) .unwrap_or_default(); if new_extension != previous_extension { - score = 0; + score = 0.0; } let is_new_architecture = !found_architectures.is_empty() @@ -66,7 +120,8 @@ pub fn match_installers( !found_scopes.is_empty() && !found_scopes.contains_key(installer_url); if score > max_score - || (score == max_score && (is_new_architecture || is_new_scope)) + || (score.total_cmp(&max_score).is_eq() + && (is_new_architecture || is_new_scope)) || best_match.is_none() { max_score = score; @@ -83,6 +138,7 @@ pub fn match_installers( mod tests { use std::{collections::HashMap, str::FromStr}; + use rstest::rstest; use winget_types::{ installer::{Architecture, Installer, Scope}, url::DecodedUrl, @@ -143,4 +199,174 @@ mod tests { expected ); } + + #[rstest] + #[case::exact_english( + "en-US", + "https://www.example.com/downloads/installer_2eae0ed.exe", + "en-US", + "https://www.example.com/downloads/installer_419b1e8.exe", + "fr-FR", + "https://www.example.com/downloads/installer_aba97fd.exe" + )] + #[case::exact_french( + "fr-FR", + "https://www.example.com/downloads/installer_8a4a542.exe", + "fr-FR", + "https://www.example.com/downloads/installer_aba97fd.exe", + "en-US", + "https://www.example.com/downloads/installer_419b1e8.exe" + )] + #[case::language_only_english( + "en-US", + "https://www.example.com/downloads/installer_2eae0ed.exe", + "en-GB", + "https://www.example.com/downloads/installer_419b1e8.exe", + "fr-CA", + "https://www.example.com/downloads/installer_aba97fd.exe" + )] + #[case::language_only_french( + "fr-FR", + "https://www.example.com/downloads/installer_8a4a542.exe", + "fr-CA", + "https://www.example.com/downloads/installer_aba97fd.exe", + "en-GB", + "https://www.example.com/downloads/installer_419b1e8.exe" + )] + fn matches_locales_by_direct_analysis( + #[case] previous_locale: &str, + #[case] previous_url: &str, + #[case] expected_locale: &str, + #[case] expected_url: &str, + #[case] competing_locale: &str, + #[case] competing_url: &str, + ) { + let previous_installer = Installer { + locale: Some(previous_locale.parse().unwrap()), + url: DecodedUrl::from_str(previous_url).unwrap(), + ..Installer::default() + }; + let expected_installer = Installer { + locale: Some(expected_locale.parse().unwrap()), + url: DecodedUrl::from_str(expected_url).unwrap(), + ..Installer::default() + }; + let competing_installer = Installer { + locale: Some(competing_locale.parse().unwrap()), + url: DecodedUrl::from_str(competing_url).unwrap(), + ..Installer::default() + }; + + assert_eq!( + match_installers( + vec![previous_installer.clone()], + &[competing_installer, expected_installer.clone()], + ), + HashMap::from([(previous_installer, expected_installer)]) + ); + } + + #[rstest] + #[case::icu_english( + "en-US", + "https://www.example.com/app-1.0.en-US.exe", + "https://www.example.com/app-2.0.en-US.exe" + )] + #[case::icu_french( + "fr-FR", + "https://www.example.com/app-1.0.fr-FR.exe", + "https://www.example.com/app-2.0.fr-FR.exe" + )] + #[case::icu_german( + "de-DE", + "https://www.example.com/app-1.0.de-DE.exe", + "https://www.example.com/app-2.0.de.exe" + )] + #[case::icu_portuguese( + "pt-BR", + "https://www.example.com/app-1.0.pt-BR.exe", + "https://www.example.com/app-2.0.pt-BR.exe" + )] + #[case::icu_simplified_chinese( + "zh-CN", + "https://www.example.com/app-1.0.zh.exe", + "https://www.example.com/app-2.0.zh.exe" + )] + #[case::icu_traditional_chinese( + "zh-TW", + "https://www.example.com/app-1.0.zh-TW.exe", + "https://www.example.com/app-2.0.zh-TW.exe" + )] + #[case::similarity_english( + "en-GB", + "https://www.example.com/downloads/app_2_8_English.exe", + "https://www.example.com/downloads/app_2_9_English.exe" + )] + #[case::similarity_french( + "fr-FR", + "https://www.example.com/downloads/app_2_8_French.exe", + "https://www.example.com/downloads/app_2_9_French.exe" + )] + #[case::similarity_italian( + "it-IT", + "https://www.example.com/downloads/app_2_8_Italian.exe", + "https://www.example.com/downloads/app_2_9_Italian.exe" + )] + #[case::similarity_portuguese( + "pt-PT", + "https://www.example.com/downloads/app_2_8_Portuguese.exe", + "https://www.example.com/downloads/app_2_9_Portuguese.exe" + )] + #[case::similarity_russian( + "ru-RU", + "https://www.example.com/downloads/app_2_8_Russian.exe", + "https://www.example.com/downloads/app_2_9_Russian.exe" + )] + #[case::similarity_spanish( + "es-AR", + "https://www.example.com/downloads/app_2_8_Espanol.exe", + "https://www.example.com/downloads/app_2_9_Espanol.exe" + )] + #[case::similarity_turkish( + "tr-TR", + "https://www.example.com/downloads/app_2_8_Turkish.exe", + "https://www.example.com/downloads/app_2_9_Turkish.exe" + )] + #[case::similarity_german( + "de-DE", + "https://www.example.com/downloads/app_2_8_Deutsch.exe", + "https://www.example.com/downloads/app_2_9_Deutsch.exe" + )] + #[case::similarity_simplified_chinese( + "zh-CN", + "https://www.example.com/downloads/app_2_8_SCN.exe", + "https://www.example.com/downloads/app_2_9_SCN.exe" + )] + fn matches_locale_by_url( + #[case] locale: &str, + #[case] previous_url: &str, + #[case] expected_url: &str, + ) { + let previous_installer = Installer { + locale: Some(locale.parse().unwrap()), + url: DecodedUrl::from_str(previous_url).unwrap(), + ..Installer::default() + }; + let expected_installer = Installer { + url: DecodedUrl::from_str(expected_url).unwrap(), + ..Installer::default() + }; + let competing_installer = Installer { + url: DecodedUrl::from_str("https://www.example.com/downloads/installer_419b1e8.exe") + .unwrap(), + ..Installer::default() + }; + assert_eq!( + match_installers( + vec![previous_installer.clone()], + &[competing_installer, expected_installer.clone()], + ), + HashMap::from([(previous_installer, expected_installer)]) + ); + } } From 13c34db5a713c2c5767163230faead5d0071e40a Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Mon, 23 Mar 2026 17:15:29 -0400 Subject: [PATCH 09/31] Set default `app_versioned_name` --- src/analysis/installers/inno/mod.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/analysis/installers/inno/mod.rs b/src/analysis/installers/inno/mod.rs index 1e06cc58c..c5d7b2e0f 100644 --- a/src/analysis/installers/inno/mod.rs +++ b/src/analysis/installers/inno/mod.rs @@ -41,10 +41,31 @@ impl Installers for Inno { .product_code() .filter(|code| !code.starts_with(CODE)); + let app_versioned_name = self + .header + .app_versioned_name() + .filter(|code| !code.starts_with(CODE)) + .map(str::to_owned) + .or_else(|| { + if self.version().major() >= 7 { + Some(format!( + "{} {}", + self.header.app_name()?, + self.header.app_version()? + )) + } else { + Some(format!( + "{} version {}", + self.header.app_name()?, + self.header.app_version()? + )) + } + }); + let display_name = self .header .uninstall_name() - .or_else(|| self.header.app_versioned_name()) + .or(app_versioned_name.as_deref()) .filter(|name| !name.starts_with(CODE)); let publisher = self From 83e78c11596c771e07de4ab21a8b0d2f760237a3 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Wed, 15 Apr 2026 20:23:18 -0400 Subject: [PATCH 10/31] Switch to `serde-saphyr` --- Cargo.lock | 97 +++++++++++++++++++++++++---------- Cargo.toml | 3 +- src/commands/analyze.rs | 9 ++-- src/commands/list_versions.rs | 4 +- src/commands/show_version.rs | 22 ++++---- src/commands/submit.rs | 10 ++-- src/github/client.rs | 12 ++--- src/github/error.rs | 2 +- src/manifests/mod.rs | 17 +++++- src/traits/mod.rs | 1 + 10 files changed, 120 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ccc74006..be0b84ae0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,19 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -69,6 +82,17 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92570a3f9c98e7e84df84b71d0965ac99b1871fcd75a3773a3bd1bad13f64cf7" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + [[package]] name = "anstream" version = "1.0.0" @@ -145,6 +169,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayvec" version = "0.7.6" @@ -1143,6 +1173,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1464,6 +1503,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "granit-parser" +version = "0.0.7" +source = "git+https://github.com/bourumir-wyngs/granit-parser#eedf67ee6fc999938e4f8449dc335eec2e372566" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "h2" version = "0.4.14" @@ -2257,6 +2305,7 @@ dependencies = [ "rstest", "secrecy", "serde", + "serde-saphyr", "serde_json", "serde_with", "sevenz-rust2", @@ -2279,7 +2328,6 @@ dependencies = [ "windows-native-keyring-store", "windows_exe_info", "winget-types", - "yaml_serde", "zerocopy", "zip", ] @@ -2384,12 +2432,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libyaml-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" - [[package]] name = "libz-sys" version = "1.1.28" @@ -2617,7 +2659,7 @@ version = "3.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b41bda2ac390efb5e8d22025d925ccc3f3807d8c1bea6d19b36127247c4b8f83" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "ctor", "futures", "napi-build", @@ -3202,12 +3244,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" version = "0.3.33" @@ -3334,7 +3370,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "getopts", "memchr", "pulldown-cmark-escape", @@ -4017,6 +4053,24 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "0.0.28" +source = "git+https://github.com/UnownPlain/serde-saphyr.git?branch=anthelion#94dcb42f3de139bf71c89590ba5f672b448a530a" +dependencies = [ + "ahash", + "annotate-snippets", + "base64", + "encoding_rs_io", + "getrandom 0.3.4", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5740,19 +5794,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "yaml_serde" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "libyaml-rs", - "ryu", - "serde", -] - [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index fcf7a30ad..a22a56036 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,7 +84,7 @@ secrecy = "0.10.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" serde_with = "3.21.0" -serde_yaml = { package = "yaml_serde", version = "0.10.4" } +serde-saphyr = "0.0.28" sevenz-rust2 = "0.21.0" sha2 = "0.11.0" strsim = "0.11.1" @@ -137,6 +137,7 @@ crate-type = ["cdylib"] [patch.crates-io] winget-types = { git = "https://github.com/UnownPlain/winget-types.git", branch = "anthelion" } +serde-saphyr = { git = "https://github.com/UnownPlain/serde-saphyr.git", branch = "anthelion" } # Anthelion End [dev-dependencies] diff --git a/src/commands/analyze.rs b/src/commands/analyze.rs index 2463d6ba6..b9e09e190 100644 --- a/src/commands/analyze.rs +++ b/src/commands/analyze.rs @@ -11,7 +11,10 @@ use color_eyre::{Result, eyre::ensure}; use sha2::{Digest, Sha256, digest::Output}; use winget_types::Sha256String; -use crate::{analysis::Analyzer, manifests::print_manifest}; +use crate::{ + analysis::Analyzer, + manifests::{print_manifest, to_yaml_string}, +}; /// Analyzes a file and outputs information about it #[derive(Parser)] @@ -57,8 +60,8 @@ impl Analyze { } } let yaml = match installers.as_slice() { - [installer] => serde_yaml::to_string(installer)?, - installers => serde_yaml::to_string(installers)?, + [installer] => to_yaml_string(installer)?, + installers => to_yaml_string(&installers)?, }; let mut lock = stdout().lock(); print_manifest(&mut lock, &yaml); diff --git a/src/commands/list_versions.rs b/src/commands/list_versions.rs index 7e201c9d9..525f5d3d3 100644 --- a/src/commands/list_versions.rs +++ b/src/commands/list_versions.rs @@ -56,7 +56,9 @@ impl ListVersions { pretty_json: true, .. } => serde_json::to_writer_pretty(&mut stdout_lock, &versions)?, OutputType { json: true, .. } => serde_json::to_writer(&mut stdout_lock, &versions)?, - OutputType { yaml: true, .. } => serde_yaml::to_writer(&mut stdout_lock, &versions)?, + OutputType { yaml: true, .. } => { + serde_saphyr::to_io_writer(&mut stdout_lock, &versions)?; + } _ => { for version in &versions { writeln!(&mut stdout_lock, "{version}")?; diff --git a/src/commands/show_version.rs b/src/commands/show_version.rs index 0781ca32f..8123ef8e9 100644 --- a/src/commands/show_version.rs +++ b/src/commands/show_version.rs @@ -3,7 +3,11 @@ use color_eyre::Result; use secrecy::SecretString; use winget_types::{PackageIdentifier, PackageVersion}; -use crate::{github::client::GitHub, manifests::print_changes, token::TokenManager}; +use crate::{ + github::client::GitHub, + manifests::{print_changes, to_yaml_string}, + token::TokenManager, +}; /// Output the manifests for a given package and version #[expect(clippy::struct_excessive_bools)] @@ -67,22 +71,20 @@ impl ShowVersion { ); let mut contents = Vec::new(); + if all || self.installer_manifest { - contents.push(serde_yaml::to_string(&manifests.installer)?); + contents.push(to_yaml_string(&manifests.installer)?); } if all || self.default_locale_manifest { - contents.push(serde_yaml::to_string(&manifests.default_locale)?); + contents.push(to_yaml_string(&manifests.default_locale)?); } if all || self.locale_manifests { - contents.extend( - manifests - .locales - .into_iter() - .flat_map(|locale_manifest| serde_yaml::to_string(&locale_manifest)), - ); + for locale_manifest in &manifests.locales { + contents.push(to_yaml_string(locale_manifest)?); + } } if all || self.version_manifest { - contents.push(serde_yaml::to_string(&manifests.version)?); + contents.push(to_yaml_string(&manifests.version)?); } print_changes(contents); diff --git a/src/commands/submit.rs b/src/commands/submit.rs index efd5ad060..e90e6c45d 100644 --- a/src/commands/submit.rs +++ b/src/commands/submit.rs @@ -73,15 +73,15 @@ impl Submit { // Deserialize the manifest into just the manifest type so that it can be determined // which manifest to properly deserialize into - let manifest = match serde_yaml::from_str::(&manifest)?.r#type { + let manifest = match serde_saphyr::from_str::(&manifest)?.r#type { ManifestType::Installer => { - Manifest::Installer(serde_yaml::from_str(&manifest)?) + Manifest::Installer(serde_saphyr::from_str(&manifest)?) } ManifestType::DefaultLocale => { - Manifest::DefaultLocale(serde_yaml::from_str(&manifest)?) + Manifest::DefaultLocale(serde_saphyr::from_str(&manifest)?) } - ManifestType::Locale => Manifest::Locale(serde_yaml::from_str(&manifest)?), - ManifestType::Version => Manifest::Version(serde_yaml::from_str(&manifest)?), + ManifestType::Locale => Manifest::Locale(serde_saphyr::from_str(&manifest)?), + ManifestType::Version => Manifest::Version(serde_saphyr::from_str(&manifest)?), }; Ok::(manifest) }) diff --git a/src/github/client.rs b/src/github/client.rs index 7fb8ef551..676dedcb1 100644 --- a/src/github/client.rs +++ b/src/github/client.rs @@ -78,7 +78,7 @@ impl GitHub { let version_manifest = content .iter() .find(|file| is_manifest_file::(&file.name, identifier, None)) - .map(|file| serde_yaml::from_str::(&file.text)) + .map(|file| serde_saphyr::from_str::(&file.text)) .ok_or_else(|| GitHubError::ManifestNotFound { r#type: ManifestType::Version, path: full_package_path.clone(), @@ -93,8 +93,8 @@ impl GitHub { Some(&version_manifest.default_locale), ) }) - .map(|file| serde_yaml::from_str::(&file.text)) - .collect::>()?; + .map(|file| serde_saphyr::from_str::(&file.text)) + .collect::>()?; let default_locale_manifest = content .iter() @@ -105,7 +105,7 @@ impl GitHub { Some(&version_manifest.default_locale), ) }) - .map(|file| serde_yaml::from_str::(&file.text)) + .map(|file| serde_saphyr::from_str::(&file.text)) .ok_or_else(|| GitHubError::ManifestNotFound { r#type: ManifestType::DefaultLocale, path: full_package_path.clone(), @@ -114,7 +114,7 @@ impl GitHub { let installer_manifest = content .into_iter() .find(|file| is_manifest_file::(&file.name, identifier, None)) - .map(|file| serde_yaml::from_str::(&file.text)) + .map(|file| serde_saphyr::from_str::(&file.text)) .ok_or_else(|| GitHubError::ManifestNotFound { r#type: ManifestType::Installer, path: full_package_path.clone(), @@ -165,7 +165,7 @@ impl GitHub { ) -> Result { let path = PackagePath::new(identifier, Some(version), Some(&manifest_type)); let content = self.get_file_content(MICROSOFT, WINGET_PKGS, &path).await?; - let manifest = serde_yaml::from_str::(&content)?; + let manifest = serde_saphyr::from_str::(&content)?; Ok(manifest) } diff --git a/src/github/error.rs b/src/github/error.rs index e2d5f57bc..1306b2514 100644 --- a/src/github/error.rs +++ b/src/github/error.rs @@ -25,7 +25,7 @@ pub enum GitHubError { #[error(transparent)] CynicRequest(#[from] CynicReqwestError), #[error(transparent)] - YamlError(#[from] serde_yaml::Error), + YamlError(#[from] serde_saphyr::Error), #[error(transparent)] UrlParseError(#[from] url::ParseError), } diff --git a/src/manifests/mod.rs b/src/manifests/mod.rs index 19a14bca4..49eaf6828 100644 --- a/src/manifests/mod.rs +++ b/src/manifests/mod.rs @@ -107,10 +107,23 @@ pub fn print_manifest(lock: &mut AutoStream>, manifest: &str } } +pub fn to_yaml_string(value: &T) -> std::result::Result +where + T: Serialize, +{ + serde_saphyr::to_string_with_options( + value, + serde_saphyr::ser_options! { + // Match serde-yaml behavior: only multiline strings should use block scalar styles. + folded_wrap_chars: usize::MAX, + }, + ) +} + pub fn build_manifest_string( manifest: &T, _created_with: Option<&str>, -) -> serde_yaml::Result +) -> std::result::Result where T: Manifest + Serialize, { @@ -118,7 +131,7 @@ where let _ = writeln!(result, "{} v{}", crate_name!(), crate_version!()); let _ = writeln!(result, "# yaml-language-server: $schema={}", T::SCHEMA); let _ = writeln!(result); - let _ = write!(result, "{}", serde_yaml::to_string(manifest)?); + let _ = write!(result, "{}", to_yaml_string(manifest)?); Ok(convert_to_crlf(&result).into_owned()) } diff --git a/src/traits/mod.rs b/src/traits/mod.rs index 18530741d..eeea83959 100644 --- a/src/traits/mod.rs +++ b/src/traits/mod.rs @@ -208,6 +208,7 @@ impl FromHtml for ReleaseNotes { let normalized_text = COMPARE_RANGE_LINE_REGEX.replace_all(&text, ""); let normalized_text = NEWLINE_REGEX.replace_all(&normalized_text, "\n"); let normalized_text = add_section_spacing(&normalized_text); + let normalized_text = normalized_text.replace("\\r\\n", "\n").replace("\\n", "\n"); Self::new(normalized_text.trim()).ok() }) } From 2e4af55e9b2b1c8589b4dcd936099fc6bb698a41 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Mon, 13 Apr 2026 23:31:21 -0400 Subject: [PATCH 11/31] Fix branch cleanup open PR detection GitHub keeps branch PR history stored even if the branch was deleted. This results in PRs which reuse the same branch name as other PRs being incorrectly deleted due to the currently open one being pushed of by pagination. This changes the query to explicitly fetch closed and merged PRs to avoid this. I also updated the query to sort descending to fix `komac clean` displaying an old PR due to the same problem. --- src/github/client.rs | 8 ++------ src/github/graphql/get_branches.rs | 20 +++++++++++++++++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/github/client.rs b/src/github/client.rs index 676dedcb1..0d2075abe 100644 --- a/src/github/client.rs +++ b/src/github/client.rs @@ -289,14 +289,10 @@ impl GitHub { for branch in branches.into_iter().filter(|branch| { branch.name != default_branch.name - && branch - .associated_pull_requests - .pull_requests - .iter() - .all(|pull_request| !pull_request.state.is_open()) + && branch.open_pull_requests.pull_requests.is_empty() }) { if let Some(pull_request) = branch - .associated_pull_requests + .closed_or_merged_pull_requests .pull_requests .into_iter() .filter(|pull_request| match merge_state { diff --git a/src/github/graphql/get_branches.rs b/src/github/graphql/get_branches.rs index 3bf468d83..99f7d169d 100644 --- a/src/github/graphql/get_branches.rs +++ b/src/github/graphql/get_branches.rs @@ -47,8 +47,12 @@ pub struct PageInfo { #[cynic(graphql_type = "Ref")] pub struct PullRequestBranchRef { pub name: String, - #[arguments(first: 5)] - pub associated_pull_requests: PullRequestConnection, + #[cynic(rename = "associatedPullRequests", alias)] + #[arguments(first: 1, states: [OPEN])] + pub open_pull_requests: PullRequestConnection, + #[cynic(rename = "associatedPullRequests", alias)] + #[arguments(first: 1, states: [CLOSED, MERGED], orderBy: { field: CREATED_AT, direction: DESC })] + pub closed_or_merged_pull_requests: PullRequestConnection, } /// @@ -107,7 +111,17 @@ mod tests { refs(first: 100, after: $cursor, refPrefix: "refs/heads/") { nodes { name - associatedPullRequests(first: 5) { + open_pull_requests: associatedPullRequests(first: 1, states: [OPEN]) { + nodes { + title + url + state + repository { + nameWithOwner + } + } + } + closed_or_merged_pull_requests: associatedPullRequests(first: 1, states: [CLOSED, MERGED], orderBy: {field: CREATED_AT, direction: DESC}) { nodes { title url From 035e4413dfe720634e4403cf924ea836989acaa4 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Wed, 15 Apr 2026 19:29:08 -0400 Subject: [PATCH 12/31] Check for existing PRs on `komac remove` --- src/commands/new_version.rs | 4 +++- src/commands/remove_version.rs | 2 ++ src/commands/update_version.rs | 30 ++++++++---------------------- src/commands/utils/mod.rs | 21 +++++++++++++++++++++ 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/src/commands/new_version.rs b/src/commands/new_version.rs index 32bb15262..0f2175118 100644 --- a/src/commands/new_version.rs +++ b/src/commands/new_version.rs @@ -33,7 +33,7 @@ use winget_types::{ use crate::{ commands::utils::{ - SPINNER_TICK_RATE, SubmitOption, prompt_existing_pull_request, write_changes_to_dir, + SPINNER_TICK_RATE, SubmitOption, should_abort_for_existing_pr, write_changes_to_dir, }, download::Downloader, download_file::process_files, @@ -176,6 +176,8 @@ impl NewVersion { let existing_pr = github .get_existing_pull_request(&package_identifier, &package_version, false) + let existing_pr = github + .get_existing_pull_request(&package_identifier, &package_version) .await?; if should_abort_for_existing_pr( diff --git a/src/commands/remove_version.rs b/src/commands/remove_version.rs index 87baaf0ef..8b74bd494 100644 --- a/src/commands/remove_version.rs +++ b/src/commands/remove_version.rs @@ -14,6 +14,7 @@ use tokio::try_join; use winget_types::{PackageIdentifier, PackageVersion}; use crate::{ + commands::utils::should_abort_for_existing_pr, github::{WINGET_PKGS_FULL_NAME, client::GitHub}, prompts::{handle_inquire_error, text::confirm_prompt}, token::TokenManager, @@ -95,6 +96,7 @@ impl RemoveVersion { let existing_pr = github .get_existing_pull_request(&self.package_identifier, &self.package_version, false) + .get_existing_pull_request(&self.package_identifier, &self.package_version) .await? .filter(|pull_request| pull_request.is_open()); diff --git a/src/commands/update_version.rs b/src/commands/update_version.rs index c58ba28ed..87c576584 100644 --- a/src/commands/update_version.rs +++ b/src/commands/update_version.rs @@ -25,14 +25,13 @@ use winget_types::{ use crate::{ analysis::installers::Zip, commands::utils::{ - SPINNER_TICK_RATE, SubmitOption, prompt_existing_pull_request, write_changes_to_dir, + SPINNER_TICK_RATE, SubmitOption, should_abort_for_existing_pr, write_changes_to_dir, }, download::Downloader, download_file::process_files, github::{ GITHUB_HOST, GitHubError, WINGET_PKGS_FULL_NAME, client::{GitHub, GitHubValues}, - graphql::get_existing_pull_request::PullRequest, utils::{PackagePath, pull_request::pr_changes}, }, manifests::Url, @@ -134,7 +133,13 @@ impl UpdateVersion { let replace_version = self.resolve_replace_version(&versions, latest_version)?; - if self.should_abort_for_existing_pr(existing_pr)? { + if should_abort_for_existing_pr( + &self.package_identifier, + &self.package_version, + existing_pr, + self.skip_pr_check, + self.dry_run, + )? { return Ok(()); } @@ -350,25 +355,6 @@ impl UpdateVersion { Ok(replace_version) } - fn should_abort_for_existing_pr(&self, existing_pr: T) -> Result - where - T: Into>, - { - if let Some(ref pull_request) = existing_pr.into() - && !self.skip_pr_check - && !self.dry_run - && !prompt_existing_pull_request( - &self.package_identifier, - &self.package_version, - pull_request, - )? - { - return Ok(true); - } - - Ok(false) - } - async fn fetch_github_values( &self, github: &GitHub, diff --git a/src/commands/utils/mod.rs b/src/commands/utils/mod.rs index 0a56c9cc8..c9d0c92ac 100644 --- a/src/commands/utils/mod.rs +++ b/src/commands/utils/mod.rs @@ -46,6 +46,27 @@ pub fn prompt_existing_pull_request( } } +pub fn should_abort_for_existing_pr( + identifier: &PackageIdentifier, + version: &PackageVersion, + existing_pr: T, + skip_pr_check: bool, + dry_run: bool, +) -> Result +where + T: Into>, +{ + if let Some(ref pull_request) = existing_pr.into() + && !skip_pr_check + && !dry_run + && !prompt_existing_pull_request(identifier, version, pull_request)? + { + return Ok(true); + } + + Ok(false) +} + pub async fn write_changes_to_dir(changes: &[(String, String)], output: &Utf8Path) -> Result<()> { fs::create_dir_all(output).await?; stream::iter(changes.iter()) From a58390de90fd31c2fe3c06d9aad91db04fa519ec Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Fri, 17 Apr 2026 14:44:13 -0400 Subject: [PATCH 13/31] Ignore und InstallerLocale --- src/analysis/installers/msi/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/analysis/installers/msi/mod.rs b/src/analysis/installers/msi/mod.rs index 01441b7f2..3ec14f0d8 100644 --- a/src/analysis/installers/msi/mod.rs +++ b/src/analysis/installers/msi/mod.rs @@ -11,6 +11,7 @@ use msi::{Language, Package, Select}; use property_table::PropertyTable; use winget_types::{ LanguageTag, + icu_locale::langid, installer::{ AppsAndFeaturesEntries, AppsAndFeaturesEntry, Architecture, InstallationMetadata, Installer, InstallerSwitches, InstallerType, Scope, @@ -220,6 +221,7 @@ impl Msi { .tag() .parse::() .ok() + .filter(|language_tag| language_tag != &LanguageTag::new(langid!("und"))) } fn wix_ui_install_dir(&self) -> Option<&str> { From 661f20e7d7fde2fd4a0383299060f12fbb5add3c Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Fri, 17 Apr 2026 17:23:35 -0400 Subject: [PATCH 14/31] Do not set user scope for MSI installers --- src/analysis/installers/burn/mod.rs | 5 ++++- src/analysis/installers/msi/mod.rs | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/analysis/installers/burn/mod.rs b/src/analysis/installers/burn/mod.rs index 51e31012a..b172d9ea6 100644 --- a/src/analysis/installers/burn/mod.rs +++ b/src/analysis/installers/burn/mod.rs @@ -234,11 +234,14 @@ impl Installers for Burn { vec![Installer { architecture: self.architecture, r#type: Some(InstallerType::Burn), + // User scope should not be set for MSI installers until the below issue is fixed + // https://github.com/microsoft/winget-cli/issues/3011 scope: match manifest.registration.scope() { WixBundleScope::Machine => Some(Scope::Machine), WixBundleScope::User => Some(Scope::User), WixBundleScope::MachineOrUser | WixBundleScope::UserOrMachine => None, - }, + } + .filter(|scope| !scope.is_user()), apps_and_features_entries, installation_metadata: manifest .variables diff --git a/src/analysis/installers/msi/mod.rs b/src/analysis/installers/msi/mod.rs index 3ec14f0d8..3eb5de7de 100644 --- a/src/analysis/installers/msi/mod.rs +++ b/src/analysis/installers/msi/mod.rs @@ -279,7 +279,9 @@ impl Installers for Msi { } else { InstallerType::Msi }), - scope: self.find_scope(), + // User scope should not be set for MSI installers until the below issue is fixed + // https://github.com/microsoft/winget-cli/issues/3011 + scope: self.find_scope().filter(|scope| !scope.is_user()), switches: self.install_location_switches(), product_code: product_code.map(str::to_owned), apps_and_features_entries: if product_name.is_some() From e019f9d73ef76fe799e5920c69f2ee6e68f20bd0 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Fri, 1 May 2026 21:56:48 -0400 Subject: [PATCH 15/31] Fix NSIS bzip2 compressed installers --- .../installers/nsis/header/decoder.rs | 5 + src/analysis/installers/nsis/header/mod.rs | 9 +- .../installers/nsis/header/nsis_bzip2.rs | 648 ++++++++++++++++++ src/analysis/installers/nsis/mod.rs | 36 +- 4 files changed, 695 insertions(+), 3 deletions(-) create mode 100644 src/analysis/installers/nsis/header/nsis_bzip2.rs diff --git a/src/analysis/installers/nsis/header/decoder.rs b/src/analysis/installers/nsis/header/decoder.rs index fa148036c..40efb8670 100644 --- a/src/analysis/installers/nsis/header/decoder.rs +++ b/src/analysis/installers/nsis/header/decoder.rs @@ -4,9 +4,12 @@ use bzip2::read::BzDecoder; use flate2::read::ZlibDecoder; use liblzma::read::XzDecoder; +use super::nsis_bzip2; + pub enum Decoder { Lzma(XzDecoder), BZip2(BzDecoder), + NsisBZip2(nsis_bzip2::Decoder), Zlib(ZlibDecoder), None(R), } @@ -16,6 +19,7 @@ impl Decoder { match self { Self::Lzma(reader) => reader.into_inner(), Self::BZip2(reader) => reader.into_inner(), + Self::NsisBZip2(reader) => reader.into_inner(), Self::Zlib(reader) => reader.into_inner(), Self::None(reader) => reader, } @@ -27,6 +31,7 @@ impl Read for Decoder { match self { Self::Lzma(reader) => reader.read(buf), Self::BZip2(reader) => reader.read(buf), + Self::NsisBZip2(reader) => reader.read(buf), Self::Zlib(reader) => reader.read(buf), Self::None(reader) => reader.read(buf), } diff --git a/src/analysis/installers/nsis/header/mod.rs b/src/analysis/installers/nsis/header/mod.rs index 23b28b48b..77dc83066 100644 --- a/src/analysis/installers/nsis/header/mod.rs +++ b/src/analysis/installers/nsis/header/mod.rs @@ -2,13 +2,13 @@ pub mod block; mod compression; mod decoder; pub mod flags; +pub(super) mod nsis_bzip2; use std::{ fmt, io, io::{Error, ErrorKind, Read, Seek}, }; -use bzip2::read::BzDecoder; pub use compression::Compression; pub use decoder::Decoder; use flate2::{Decompress, read::ZlibDecoder}; @@ -247,12 +247,17 @@ impl Header { debug!(?compression, is_solid, compressed_header_size); + let expected_size = first_header.length_of_header() as usize; let mut decoder = match compression { Compression::Lzma(_) => { let stream = LzmaStreamHeader::from_reader(&mut reader)?; Decoder::Lzma(XzDecoder::new_stream(reader, stream)) } - Compression::BZip2 => Decoder::BZip2(BzDecoder::new(reader)), + Compression::BZip2 => Decoder::NsisBZip2(nsis_bzip2::Decoder::new( + reader, + (!is_solid).then_some(compressed_header_size as usize), + expected_size + if is_solid { size_of::() } else { 0 }, + )?), Compression::Zlib => Decoder::Zlib(ZlibDecoder::new_with_decompress( reader, Decompress::new(false), diff --git a/src/analysis/installers/nsis/header/nsis_bzip2.rs b/src/analysis/installers/nsis/header/nsis_bzip2.rs new file mode 100644 index 000000000..c99967d75 --- /dev/null +++ b/src/analysis/installers/nsis/header/nsis_bzip2.rs @@ -0,0 +1,648 @@ +/* + * Adapted from https://github.com/BinFlip/nsis-rs/blob/main/src/decompress/bzip2.rs + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2025 Johann Kempter + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::io::{Cursor, Error, ErrorKind, Read, Result}; + +const BZ_MAX_ALPHA_SIZE: usize = 258; +const BZ_MAX_ALPHA_SIZE_I32: i32 = 258; +const BZ_MAX_CODE_LEN: usize = 23; +const BZ_N_GROUPS: usize = 6; +const BZ_G_SIZE_I32: i32 = 50; +const BZ_MAX_SELECTORS_I32: i32 = 18_002; +const MTFA_SIZE: usize = 4096; +const MTFL_SIZE: usize = 16; +const BLOCK_SIZE: usize = 900_000; +const BLOCK_SIZE_I32: i32 = 900_000; +const BZ_RUNA: i32 = 0; +const BZ_RUNB: i32 = 1; + +pub struct Decoder { + inner: R, + decompressed: Cursor>, +} + +impl Decoder { + pub fn new(mut reader: R, compressed_size: Option, max_output: usize) -> Result { + let compressed = if let Some(compressed_size) = compressed_size { + let mut compressed = vec![0; compressed_size]; + reader.read_exact(&mut compressed)?; + compressed + } else { + let mut compressed = Vec::new(); + reader.read_to_end(&mut compressed)?; + compressed + }; + + Ok(Self { + inner: reader, + decompressed: Cursor::new(decompress(&compressed, max_output)?), + }) + } + + pub fn into_inner(self) -> R { + self.inner + } +} + +impl Read for Decoder { + fn read(&mut self, buf: &mut [u8]) -> Result { + self.decompressed.read(buf) + } +} + +struct BitReader<'a> { + data: &'a [u8], + position: usize, + buffer: u32, + live: i32, +} + +impl<'a> BitReader<'a> { + const fn new(data: &'a [u8]) -> Self { + Self { + data, + position: 0, + buffer: 0, + live: 0, + } + } + + fn get_bits(&mut self, count: i32) -> Result { + loop { + if self.live >= count { + let value = (self.buffer >> (self.live - count)) & ((1 << count) - 1); + self.live -= count; + return i32::try_from(value) + .map_err(|_| invalid_data("NSIS bzip2 bit value out of range")); + } + + let byte = self + .data + .get(self.position) + .ok_or_else(|| invalid_data("Unexpected end of NSIS bzip2 input"))?; + self.buffer = (self.buffer << 8) | u32::from(*byte); + self.live += 8; + self.position += 1; + } + } + + #[inline] + fn get_bit(&mut self) -> Result { + self.get_bits(1) + } + + #[inline] + fn get_u8(&mut self) -> Result { + self.get_bits(8) + } +} + +struct HuffmanTables { + selector: Vec, + min_lens: [i32; BZ_N_GROUPS], + limit: [[i32; BZ_MAX_ALPHA_SIZE]; BZ_N_GROUPS], + perm: [[i32; BZ_MAX_ALPHA_SIZE]; BZ_N_GROUPS], + base: [[i32; BZ_MAX_ALPHA_SIZE]; BZ_N_GROUPS], + n_selectors: i32, + group_no: i32, + group_pos: i32, +} + +pub fn decompress(compressed: &[u8], max_output: usize) -> Result> { + if compressed.is_empty() { + return Err(invalid_data("Empty NSIS bzip2 input")); + } + + let mut reader = BitReader::new(compressed); + let mut output = Vec::with_capacity(max_output.min(BLOCK_SIZE)); + + loop { + match reader.get_u8()? { + 0x17 => break, + 0x31 => decompress_block(&mut reader, &mut output, max_output)?, + header => { + return Err(invalid_data(format!( + "Invalid NSIS bzip2 block header 0x{header:02X}", + ))); + } + } + + if output.len() >= max_output { + output.truncate(max_output); + break; + } + } + + Ok(output) +} + +#[allow(clippy::too_many_lines)] +fn decompress_block( + reader: &mut BitReader<'_>, + output: &mut Vec, + max_output: usize, +) -> Result<()> { + let b0 = reader.get_u8()?; + let b1 = reader.get_u8()?; + let b2 = reader.get_u8()?; + let orig_ptr = (b0 << 16) | (b1 << 8) | b2; + + if !(0..=10 + BLOCK_SIZE_I32).contains(&orig_ptr) { + return Err(invalid_data(format!( + "NSIS bzip2 origPtr out of range: {orig_ptr}", + ))); + } + + let mut in_use16 = [false; 16]; + for item in &mut in_use16 { + *item = reader.get_bit()? == 1; + } + + let mut in_use = [false; 256]; + for (i, group_used) in in_use16.into_iter().enumerate() { + if group_used { + for j in 0..16 { + in_use[i * 16 + j] = reader.get_bit()? == 1; + } + } + } + let mut seq_to_unseq = [0; 256]; + let mut n_in_use = 0; + for (symbol, used) in in_use.into_iter().enumerate() { + if used { + seq_to_unseq[n_in_use] = + u8::try_from(symbol).map_err(|_| invalid_data("NSIS bzip2 symbol out of range"))?; + n_in_use += 1; + } + } + if n_in_use == 0 { + return Err(invalid_data("NSIS bzip2 block has no symbols in use")); + } + + let alpha_size = n_in_use + 2; + let n_groups = reader.get_bits(3)?; + if !(2..=6).contains(&n_groups) { + return Err(invalid_data(format!( + "NSIS bzip2 nGroups out of range: {n_groups}", + ))); + } + let n_groups = + usize::try_from(n_groups).map_err(|_| invalid_data("NSIS bzip2 nGroups out of range"))?; + + let n_selectors = reader.get_bits(15)?; + if !(1..=BZ_MAX_SELECTORS_I32).contains(&n_selectors) { + return Err(invalid_data(format!( + "NSIS bzip2 nSelectors out of range: {n_selectors}", + ))); + } + let n_selectors = usize::try_from(n_selectors) + .map_err(|_| invalid_data("NSIS bzip2 nSelectors out of range"))?; + + let mut selector_mtf = vec![0; n_selectors]; + for selector in &mut selector_mtf { + let mut value = 0; + loop { + if reader.get_bit()? == 0 { + break; + } + value += 1; + if value >= n_groups { + return Err(invalid_data("NSIS bzip2 selector MTF value out of range")); + } + } + *selector = + u8::try_from(value).map_err(|_| invalid_data("NSIS bzip2 selector out of range"))?; + } + + let mut selector = vec![0; n_selectors]; + let mut positions = [0; BZ_N_GROUPS]; + for (value, position) in positions.iter_mut().enumerate().take(n_groups) { + *position = u8::try_from(value) + .map_err(|_| invalid_data("NSIS bzip2 selector position out of range"))?; + } + for (i, selector_mtf) in selector_mtf.into_iter().enumerate() { + let value = selector_mtf as usize; + let selected = positions[value]; + for index in (1..=value).rev() { + positions[index] = positions[index - 1]; + } + positions[0] = selected; + selector[i] = selected; + } + + let mut lengths = [[0; BZ_MAX_ALPHA_SIZE]; BZ_N_GROUPS]; + for table in lengths.iter_mut().take(n_groups) { + let mut current = reader.get_bits(5)?; + for slot in table.iter_mut().take(alpha_size) { + loop { + if !(1..=20).contains(¤t) { + return Err(invalid_data(format!( + "NSIS bzip2 code length out of range: {current}", + ))); + } + if reader.get_bit()? == 0 { + break; + } + if reader.get_bit()? == 0 { + current += 1; + } else { + current -= 1; + } + } + *slot = u8::try_from(current) + .map_err(|_| invalid_data("NSIS bzip2 code length out of range"))?; + } + } + + let mut huffman = HuffmanTables { + selector, + min_lens: [0; BZ_N_GROUPS], + limit: [[0; BZ_MAX_ALPHA_SIZE]; BZ_N_GROUPS], + perm: [[0; BZ_MAX_ALPHA_SIZE]; BZ_N_GROUPS], + base: [[0; BZ_MAX_ALPHA_SIZE]; BZ_N_GROUPS], + n_selectors: i32::try_from(n_selectors) + .map_err(|_| invalid_data("NSIS bzip2 nSelectors out of range"))?, + group_no: -1, + group_pos: 0, + }; + + for (table_index, table_lengths) in lengths.iter().enumerate().take(n_groups) { + let mut min_len = 32; + let mut max_len = 0; + for &length in table_lengths.iter().take(alpha_size) { + let length = i32::from(length); + min_len = min_len.min(length); + max_len = max_len.max(length); + } + create_decode_tables( + &mut huffman.limit[table_index], + &mut huffman.base[table_index], + &mut huffman.perm[table_index], + table_lengths, + min_len, + max_len, + alpha_size, + ); + huffman.min_lens[table_index] = min_len; + } + + let eob = i32::try_from(n_in_use + 1) + .map_err(|_| invalid_data("NSIS bzip2 EOB symbol out of range"))?; + let mut unzftab = [0; 256]; + let mut mtfa = [0; MTFA_SIZE]; + let mut mtfbase = [0; 256 / MTFL_SIZE]; + + let mut kk = MTFA_SIZE - 1; + for ii in (0..(256 / MTFL_SIZE)).rev() { + for jj in (0..MTFL_SIZE).rev() { + mtfa[kk] = u8::try_from(ii * MTFL_SIZE + jj) + .map_err(|_| invalid_data("NSIS bzip2 MTF value out of range"))?; + kk = kk.wrapping_sub(1); + } + mtfbase[ii] = kk.wrapping_add(1); + } + + let mut tt = vec![0; BLOCK_SIZE]; + let mut nblock = 0; + let mut next_sym = get_mtf_val(reader, &mut huffman)?; + + loop { + if next_sym == eob { + break; + } + + if next_sym == BZ_RUNA || next_sym == BZ_RUNB { + let mut es = -1; + let mut power = 1; + while next_sym == BZ_RUNA || next_sym == BZ_RUNB { + if next_sym == BZ_RUNA { + es += power; + } + power <<= 1; + if next_sym == BZ_RUNB { + es += power; + } + next_sym = get_mtf_val(reader, &mut huffman)?; + } + + es += 1; + let symbol = seq_to_unseq[mtfa[mtfbase[0]] as usize]; + unzftab[symbol as usize] += es; + + let es = usize::try_from(es) + .map_err(|_| invalid_data("NSIS bzip2 run length out of range"))?; + if nblock + es > BLOCK_SIZE { + return Err(invalid_data("NSIS bzip2 block overflow during RLE")); + } + for _ in 0..es { + tt[nblock] = u32::from(symbol); + nblock += 1; + } + continue; + } + + if nblock >= BLOCK_SIZE { + return Err(invalid_data("NSIS bzip2 block overflow")); + } + + let symbol = mtf_decode(next_sym, &mut mtfa, &mut mtfbase); + let unseq = seq_to_unseq[symbol as usize]; + unzftab[unseq as usize] += 1; + tt[nblock] = u32::from(unseq); + nblock += 1; + next_sym = get_mtf_val(reader, &mut huffman)?; + } + + let orig_ptr = + usize::try_from(orig_ptr).map_err(|_| invalid_data("NSIS bzip2 origPtr out of range"))?; + if orig_ptr >= nblock { + return Err(invalid_data(format!( + "NSIS bzip2 origPtr {orig_ptr} out of range for nblock {nblock}", + ))); + } + + let mut cftab = [0; 257]; + for i in 1..=256 { + cftab[i] = unzftab[i - 1] + cftab[i - 1]; + } + if cftab[256] + != i32::try_from(nblock).map_err(|_| invalid_data("NSIS bzip2 block is too large"))? + { + return Err(invalid_data("NSIS bzip2 cftab consistency check failed")); + } + + for i in 0..nblock { + let symbol = (tt[i] & 0xff) as usize; + let destination = usize::try_from(cftab[symbol]) + .map_err(|_| invalid_data("NSIS bzip2 cftab index out of range"))?; + tt[destination] |= + u32::try_from(i).map_err(|_| invalid_data("NSIS bzip2 block index out of range"))? << 8; + cftab[symbol] += 1; + } + + decode_bwt_output(&tt, orig_ptr, nblock, output, max_output) +} + +fn create_decode_tables( + limit: &mut [i32], + base: &mut [i32], + perm: &mut [i32], + length: &[u8], + min_len: i32, + max_len: i32, + alpha_size: usize, +) { + let mut pp = 0; + for i in min_len..=max_len { + for (j, &len_j) in length.iter().enumerate().take(alpha_size) { + if i32::from(len_j) == i { + perm[pp] = i32::try_from(j).expect("NSIS bzip2 alpha size fits in i32"); + pp += 1; + } + } + } + + base.iter_mut() + .take(BZ_MAX_CODE_LEN) + .for_each(|item| *item = 0); + for &len_j in length.iter().take(alpha_size) { + let index = len_j as usize + 1; + if index < BZ_MAX_CODE_LEN { + base[index] += 1; + } + } + for i in 1..BZ_MAX_CODE_LEN { + base[i] += base[i - 1]; + } + + limit + .iter_mut() + .take(BZ_MAX_CODE_LEN) + .for_each(|item| *item = 0); + let mut value = 0; + for i in min_len..=max_len { + let index = usize::try_from(i).expect("NSIS bzip2 code length is positive"); + value += base[index + 1] - base[index]; + limit[index] = value - 1; + value <<= 1; + } + for i in (min_len + 1)..=max_len { + let index = usize::try_from(i).expect("NSIS bzip2 code length is positive"); + base[index] = ((limit[index - 1] + 1) << 1) - base[index]; + } +} + +fn get_mtf_val(reader: &mut BitReader<'_>, huffman: &mut HuffmanTables) -> Result { + if huffman.group_pos == 0 { + huffman.group_no += 1; + if huffman.group_no >= huffman.n_selectors { + return Err(invalid_data("NSIS bzip2 ran out of selectors")); + } + huffman.group_pos = BZ_G_SIZE_I32; + } + huffman.group_pos -= 1; + + let group_no = usize::try_from(huffman.group_no) + .map_err(|_| invalid_data("NSIS bzip2 group index out of range"))?; + let selected = usize::from(huffman.selector[group_no]); + let mut zn = huffman.min_lens[selected]; + let mut zvec = reader.get_bits(zn)?; + + loop { + if zn > 20 { + return Err(invalid_data("NSIS bzip2 Huffman code length exceeds 20")); + } + let zn_index = + usize::try_from(zn).map_err(|_| invalid_data("NSIS bzip2 code length out of range"))?; + if zvec <= huffman.limit[selected][zn_index] { + break; + } + zn += 1; + zvec = (zvec << 1) | reader.get_bit()?; + } + + let zn_index = + usize::try_from(zn).map_err(|_| invalid_data("NSIS bzip2 code length out of range"))?; + let index = zvec - huffman.base[selected][zn_index]; + if !(0..BZ_MAX_ALPHA_SIZE_I32).contains(&index) { + return Err(invalid_data("NSIS bzip2 Huffman index out of range")); + } + Ok(huffman.perm[selected][usize::try_from(index) + .map_err(|_| invalid_data("NSIS bzip2 Huffman index out of range"))?]) +} + +fn mtf_decode( + next_sym: i32, + mtfa: &mut [u8; MTFA_SIZE], + mtfbase: &mut [usize; 256 / MTFL_SIZE], +) -> u8 { + let nn = usize::try_from(next_sym - 1).expect("NSIS bzip2 MTF symbol is positive"); + + if nn < MTFL_SIZE { + let position = mtfbase[0]; + let symbol = mtfa[position + nn]; + for index in (1..=nn).rev() { + mtfa[position + index] = mtfa[position + index - 1]; + } + mtfa[position] = symbol; + return symbol; + } + + let list = nn / MTFL_SIZE; + let offset = nn % MTFL_SIZE; + let mut position = mtfbase[list] + offset; + let symbol = mtfa[position]; + + while position > mtfbase[list] { + mtfa[position] = mtfa[position - 1]; + position -= 1; + } + mtfbase[list] += 1; + + for current_list in (1..=list).rev() { + mtfbase[current_list] -= 1; + mtfa[mtfbase[current_list]] = mtfa[mtfbase[current_list - 1] + MTFL_SIZE - 1]; + } + mtfbase[0] -= 1; + mtfa[mtfbase[0]] = symbol; + + if mtfbase[0] == 0 { + let mut kk = MTFA_SIZE - 1; + for ii in (0..(256 / MTFL_SIZE)).rev() { + for jj in (0..MTFL_SIZE).rev() { + mtfa[kk] = mtfa[mtfbase[ii] + jj]; + kk = kk.wrapping_sub(1); + } + mtfbase[ii] = kk.wrapping_add(1); + } + } + + symbol +} + +fn decode_bwt_output( + tt: &[u32], + orig_ptr: usize, + nblock: usize, + output: &mut Vec, + max_output: usize, +) -> Result<()> { + let mut t_pos = tt[orig_ptr] >> 8; + let mut nblock_used = 0; + let mut k0 = bz_get_fast(tt, &mut t_pos)?; + nblock_used += 1; + + let mut state_out_len = 0; + let mut state_out_ch = 0; + + while nblock_used <= nblock { + if output.len() >= max_output { + return Ok(()); + } + + if state_out_len > 0 { + let emit_count = usize::try_from(state_out_len) + .map_err(|_| invalid_data("NSIS bzip2 output run length out of range"))? + .min(max_output - output.len()); + output.extend(std::iter::repeat_n(state_out_ch, emit_count)); + state_out_len -= i32::try_from(emit_count) + .map_err(|_| invalid_data("NSIS bzip2 output run length out of range"))?; + if state_out_len > 0 || output.len() >= max_output { + return Ok(()); + } + continue; + } + + state_out_ch = k0; + let mut count = 1_usize; + + if nblock_used < nblock { + k0 = bz_get_fast(tt, &mut t_pos)?; + nblock_used += 1; + if k0 != state_out_ch { + output.push(state_out_ch); + continue; + } + count = 2; + + if nblock_used < nblock { + k0 = bz_get_fast(tt, &mut t_pos)?; + nblock_used += 1; + if k0 != state_out_ch { + push_repeat(output, state_out_ch, 2, max_output); + continue; + } + count = 3; + + if nblock_used < nblock { + k0 = bz_get_fast(tt, &mut t_pos)?; + nblock_used += 1; + if k0 != state_out_ch { + push_repeat(output, state_out_ch, 3, max_output); + continue; + } + count = 4; + + if nblock_used < nblock { + k0 = bz_get_fast(tt, &mut t_pos)?; + nblock_used += 1; + state_out_len = i32::from(k0) + + i32::try_from(count) + .expect("NSIS bzip2 literal repeat count fits in i32"); + if nblock_used < nblock { + k0 = bz_get_fast(tt, &mut t_pos)?; + nblock_used += 1; + } + continue; + } + } + } + } + + push_repeat(output, state_out_ch, count, max_output); + } + + if state_out_len > 0 { + let emit_count = usize::try_from(state_out_len) + .map_err(|_| invalid_data("NSIS bzip2 output run length out of range"))? + .min(max_output - output.len()); + output.extend(std::iter::repeat_n(state_out_ch, emit_count)); + } + + Ok(()) +} + +fn bz_get_fast(tt: &[u32], t_pos: &mut u32) -> Result { + let entry = *tt + .get(*t_pos as usize) + .ok_or_else(|| invalid_data("NSIS bzip2 BWT position out of range"))?; + *t_pos = entry >> 8; + Ok((entry & 0xff) as u8) +} + +fn push_repeat(output: &mut Vec, byte: u8, count: usize, max_output: usize) { + let count = count.min(max_output.saturating_sub(output.len())); + output.extend(std::iter::repeat_n(byte, count)); +} + +fn invalid_data(message: impl Into) -> Error { + Error::new(ErrorKind::InvalidData, message.into()) +} diff --git a/src/analysis/installers/nsis/mod.rs b/src/analysis/installers/nsis/mod.rs index 1855ae548..2cf3b3ab2 100644 --- a/src/analysis/installers/nsis/mod.rs +++ b/src/analysis/installers/nsis/mod.rs @@ -35,6 +35,7 @@ use winget_types::{ Installer, InstallerType, Scope, }, }; +use zerocopy::LE; use super::{ super::extensions::EXE, @@ -42,13 +43,14 @@ use super::{ entry::{Entry, EntryError}, file_system::FsEntry, first_header::FirstHeader, - header::{Compression, Decoder, Decompressed, Header}, + header::{Compression, Decoder, Decompressed, Header, nsis_bzip2}, }, pe::{PE, utils::machine_from_exe_reader}, utils::{LzmaStreamHeader, RELATIVE_PROGRAM_FILES_64, RELATIVE_TEMP_FOLDER}, }; use crate::{ analysis::Installers, + read::ReadBytesExt, traits::{FromMachine, IntoWingetArchitecture}, }; @@ -145,6 +147,22 @@ impl Nsis { debug!(%state.registry, %state.file_system); architecture = architecture + .or_else(|| { + let mut has_32_bit_section = false; + let mut has_64_bit_section = false; + + for section in header.blocks().sections(&decompressed_data) { + let name = state.get_string(section.name_offset()); + has_32_bit_section |= name.contains("32Bit") || name.contains("32-bit"); + has_64_bit_section |= name.contains("64Bit") || name.contains("64-bit"); + } + + match (has_32_bit_section, has_64_bit_section) { + (true, true) => Some(Architecture::X86), + (false, true) => Some(Architecture::X64), + _ => None, + } + }) .or_else(|| { state .variables @@ -171,6 +189,22 @@ impl Nsis { + size_of::() as u64; } + if !is_solid && compression == Compression::BZip2 { + let reader = decoder.into_inner(); + reader + .seek(SeekFrom::Start(position - size_of::() as u64)) + .ok()?; + let compressed_size = reader.read_u32::().ok()? & !0x8000_0000; + let decoder = nsis_bzip2::Decoder::new( + reader, + Some(compressed_size as usize), + 1 << 20, + ) + .ok()?; + let machine = machine_from_exe_reader(decoder).ok()?; + return Some(Architecture::from_machine(machine)); + } + let mut decoder = if is_solid { let decoder = decoder.into_inner(); decoder.seek(SeekFrom::Start(position)).ok()?; From ad252142ec4a0e84f148274e243227e034f1811b Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Thu, 7 May 2026 15:50:55 -0400 Subject: [PATCH 16/31] Do not set scope for NSIS installers with mismatched registry and install location scope --- src/analysis/installers/nsis/mod.rs | 60 +++++++++++++++++-- src/analysis/installers/nsis/registry/mod.rs | 17 ++++++ src/analysis/installers/nsis/registry/root.rs | 14 +++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/analysis/installers/nsis/mod.rs b/src/analysis/installers/nsis/mod.rs index 2cf3b3ab2..86c955ee1 100644 --- a/src/analysis/installers/nsis/mod.rs +++ b/src/analysis/installers/nsis/mod.rs @@ -263,6 +263,19 @@ impl Nsis { impl Installers for Nsis { fn installers(&self) -> Vec { let product_code = self.registry.product_code(); + let detected_scope = self.registry.product_code_scope(); + let install_directory_scope = self + .install_directory + .as_deref() + .and_then(Scope::from_install_directory); + let scope = match (detected_scope, install_directory_scope) { + (Some(detected_scope), Some(install_directory_scope)) + if detected_scope != install_directory_scope => + { + None + } + (detected_scope, install_directory_scope) => detected_scope.or(install_directory_scope), + }; let display_name = self.display_name(); let publisher = self.registry.get_value_by_name("Publisher"); let display_version = self.registry.get_value_by_name("DisplayVersion"); @@ -278,10 +291,7 @@ impl Installers for Nsis { } else { Some(InstallerType::Nullsoft) }, - scope: self - .install_directory - .as_deref() - .and_then(Scope::from_install_directory), + scope, product_code: product_code.map(str::to_owned), apps_and_features_entries: if display_name.is_some() || publisher.is_some() @@ -317,3 +327,45 @@ impl Installers for Nsis { vec![installer] } } + +#[cfg(test)] +mod tests { + use registry::RegRoot; + + use super::*; + + const UNINSTALL_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\Test.App"; + + fn nsis_with_scope_signals(root: RegRoot, install_directory: &str) -> Nsis { + let mut registry = Registry::new(); + registry.insert_value(root, UNINSTALL_KEY, "DisplayName", "Test App"); + + Nsis { + architecture: Architecture::X64, + is_portable: false, + registry, + primary_language_id: 1033, + install_directory: Some(Utf8WindowsPathBuf::from(install_directory)), + } + } + + #[test] + fn keeps_nsis_scope_when_detected_scope_matches_install_location_scope() { + let installer = + nsis_with_scope_signals(RegRoot::HKEY_LOCAL_MACHINE, r"%ProgramFiles%\Test App") + .installers() + .remove(0); + + assert_eq!(installer.scope, Some(Scope::Machine)); + } + + #[test] + fn does_not_set_nsis_scope_when_detected_scope_conflicts_with_install_location_scope() { + let installer = + nsis_with_scope_signals(RegRoot::HKEY_CURRENT_USER, r"%ProgramFiles%\Test App") + .installers() + .remove(0); + + assert_eq!(installer.scope, None); + } +} diff --git a/src/analysis/installers/nsis/registry/mod.rs b/src/analysis/installers/nsis/registry/mod.rs index 849d9ed70..41728b79e 100644 --- a/src/analysis/installers/nsis/registry/mod.rs +++ b/src/analysis/installers/nsis/registry/mod.rs @@ -6,6 +6,7 @@ use std::{borrow::Borrow, collections::BTreeMap, fmt}; use itertools::{Itertools, Position}; pub use root::RegRoot; pub use r#type::RegType; +use winget_types::installer::Scope; type Key = String; @@ -55,6 +56,22 @@ impl Registry { }) } + pub fn product_code_scope(&self) -> Option { + self.0.iter().find_map(|(root, keys)| { + keys.keys() + .any(|key| { + key.rsplit_once('\\') + .is_some_and(|(parent, _product_code)| parent == CURRENT_VERSION_UNINSTALL) + }) + .then(|| match root { + root if root.is_current_user() => Some(Scope::User), + root if root.is_local_machine() => Some(Scope::Machine), + _ => None, + }) + .flatten() + }) + } + /// Inserts the value into the registry. /// /// If the registry did not have this value name present, [`None`] is returned. diff --git a/src/analysis/installers/nsis/registry/root.rs b/src/analysis/installers/nsis/registry/root.rs index e0920dc6a..1f6a23a5a 100644 --- a/src/analysis/installers/nsis/registry/root.rs +++ b/src/analysis/installers/nsis/registry/root.rs @@ -61,6 +61,20 @@ impl RegRoot { self.0.get() } + pub fn is_current_user(self) -> bool { + matches!( + self, + Self::HKEY_CURRENT_USER | Self::HKEY_CURRENT_USER32 | Self::HKEY_CURRENT_USER64 + ) + } + + pub fn is_local_machine(self) -> bool { + matches!( + self, + Self::HKEY_LOCAL_MACHINE | Self::HKEY_LOCAL_MACHINE32 | Self::HKEY_LOCAL_MACHINE64 + ) + } + /// Returns the registry root as a static string slice if it's known, or `None` otherwise. const fn as_str(self) -> Option<&'static str> { match self { From 1191ea51e761a8a3b12e24856dbb6859ce6c2b92 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Sat, 9 May 2026 22:13:33 -0400 Subject: [PATCH 17/31] Model NSISdl calls and mocked Execute exit code This models successful NSISdl download calls and mocked Execute exit codes so NSIS analysis can continue past prerequisite installers. --- src/analysis/installers/nsis/entry/mod.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/analysis/installers/nsis/entry/mod.rs b/src/analysis/installers/nsis/entry/mod.rs index 08eab732a..3008c6007 100644 --- a/src/analysis/installers/nsis/entry/mod.rs +++ b/src/analysis/installers/nsis/entry/mod.rs @@ -256,8 +256,8 @@ pub enum Entry { } = 40u32.to_le(), Execute { complete_command_line: I32, - wait_flag: I32, output_error_code: I32, + wait_flag: I32, } = 41u32.to_le(), GetFileTime { file: I32, @@ -1080,10 +1080,17 @@ impl Entry { } Self::Execute { complete_command_line, - wait_flag, output_error_code, + wait_flag, } => { debug!("Execute: {complete_command_line} {wait_flag} {output_error_code}"); + if *wait_flag != I32::ZERO + && let Ok(output_error_code) = usize::try_from(output_error_code.get()) + { + state + .variables + .insert(output_error_code, Cow::Borrowed("0")); + } } Self::GetFileTime { file, @@ -1117,6 +1124,18 @@ impl Entry { && let Some(call) = state.stack.pop() { state.mock_caller.call(&call); + } else if dll_file_name.ends_with("NSISdl.dll") && function == "download" { + // NSISdl::download consumes file, URL, and optional switches, then pushes + // "success" or an error message. Treat downloads as successful so analysis + // can continue past prerequisite bootstrapper steps. + while state.stack.last().is_some_and(|arg| arg.starts_with('/')) { + state.stack.pop(); + } + if state.stack.len() >= 2 { + state.stack.pop(); + state.stack.pop(); + } + state.stack.push(Cow::Borrowed("success")); } debug!( "CallInstDLL: {dll_file_name} {function}{}", From 710126482ab2811dcca48016ce7239fc113541f3 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Sat, 16 May 2026 19:13:53 -0400 Subject: [PATCH 18/31] Remove extra InstallerType from burn ARP entries --- src/analysis/installers/burn/mod.rs | 50 +++++++++++++++-------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/analysis/installers/burn/mod.rs b/src/analysis/installers/burn/mod.rs index b172d9ea6..c3c670c26 100644 --- a/src/analysis/installers/burn/mod.rs +++ b/src/analysis/installers/burn/mod.rs @@ -151,28 +151,6 @@ impl Installers for Burn { let manifest = self.manifest.as_ref().unwrap_or_else(|| unreachable!()); - let mut apps_and_features_entries = AppsAndFeaturesEntries::from( - AppsAndFeaturesEntry::builder() - .display_name(manifest.registration.arp.display_name()) - .maybe_publisher(manifest.registration.arp.publisher()) - .display_version(manifest.registration.arp.display_version().clone()) - .product_code( - manifest - .registration - .code() - .encode_upper(&mut Uuid::encode_buffer()) - .to_string(), - ) - .maybe_upgrade_code(manifest.related_bundles.first().map(|bundle| { - bundle - .code() - .encode_upper(&mut Uuid::encode_buffer()) - .to_string() - })) - .installer_type(InstallerType::Burn) - .build(), - ); - let variables = manifest .variables .iter() @@ -191,7 +169,7 @@ impl Installers for Burn { ]) .collect::>(); - for msi_package in manifest + let msi_packages = manifest .chain .packages .iter() @@ -204,7 +182,31 @@ impl Installers for Burn { !msi_package.is_arp_system_component() }) .filter(|msi_package| msi_package.evaluate_install_condition(&variables)) - { + .collect::>(); + + let mut apps_and_features_entries = AppsAndFeaturesEntries::from( + AppsAndFeaturesEntry::builder() + .display_name(manifest.registration.arp.display_name()) + .maybe_publisher(manifest.registration.arp.publisher()) + .display_version(manifest.registration.arp.display_version().clone()) + .product_code( + manifest + .registration + .code() + .encode_upper(&mut Uuid::encode_buffer()) + .to_string(), + ) + .maybe_upgrade_code(manifest.related_bundles.first().map(|bundle| { + bundle + .code() + .encode_upper(&mut Uuid::encode_buffer()) + .to_string() + })) + .maybe_installer_type((!msi_packages.is_empty()).then_some(InstallerType::Burn)) + .build(), + ); + + for msi_package in msi_packages { apps_and_features_entries.push( AppsAndFeaturesEntry::builder() .maybe_display_name( From 163c7b09839ff656174e3fc4b5116ad2837f3f24 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Thu, 21 May 2026 15:16:39 -0400 Subject: [PATCH 19/31] Fix duplicate installers with ElevationRequirement scoped installers --- src/anthelion/update_version.rs | 2 +- src/commands/update_version.rs | 2 +- src/match_installers.rs | 109 +++++++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/anthelion/update_version.rs b/src/anthelion/update_version.rs index c148b832a..3fbbe2385 100644 --- a/src/anthelion/update_version.rs +++ b/src/anthelion/update_version.rs @@ -328,7 +328,7 @@ pub async fn update_version(options: UpdateVersionOptions) -> napi::Result>(); manifests.default_locale.package_version = package_version.clone(); - let matched_installers = match_installers(previous_installers, &installer_results); + let matched_installers = match_installers(&previous_installers, &installer_results); let mut installers = matched_installers .into_iter() .map(|(previous_installer, new_installer)| { diff --git a/src/commands/update_version.rs b/src/commands/update_version.rs index 87c576584..6f083294e 100644 --- a/src/commands/update_version.rs +++ b/src/commands/update_version.rs @@ -180,7 +180,7 @@ impl UpdateVersion { .collect::>(); manifests.default_locale.package_version = self.package_version.clone(); - let matched_installers = match_installers(previous_installers, &installer_results); + let matched_installers = match_installers(&previous_installers, &installer_results); let mut installers = matched_installers .into_iter() .map(|(previous_installer, new_installer)| { diff --git a/src/match_installers.rs b/src/match_installers.rs index 4acc16f85..566f60efc 100644 --- a/src/match_installers.rs +++ b/src/match_installers.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use camino::Utf8Path; -use winget_types::installer::{Architecture, Installer, Scope, VALID_FILE_EXTENSIONS}; +use winget_types::installer::{ + Architecture, ElevationRequirement, Installer, Scope, VALID_FILE_EXTENSIONS, +}; fn locale_score(previous_installer: &Installer, new_installer: &Installer) -> f64 { let Some(previous_locale) = previous_installer.locale.as_ref() else { @@ -51,8 +53,40 @@ fn url_similarity_score(previous_url: &str, new_url: &str) -> f64 { strsim::jaro_winkler(&previous_url, &new_url) * 3.0 } +fn duplicate_elevation_scope(installer: &Installer, installers: &[Installer]) -> Option { + let mut matching_installers = installers.iter().filter(|candidate| { + candidate.url == installer.url && candidate.architecture == installer.architecture + }); + let first_installer = matching_installers.next()?; + let second_installer = matching_installers.next()?; + + if matching_installers.next().is_some() + || first_installer.scope.is_some() + || second_installer.scope.is_some() + { + return None; + } + + let elevation_requirements = [ + first_installer.elevation_requirement, + second_installer.elevation_requirement, + ]; + + if !elevation_requirements.contains(&None) + || !elevation_requirements.contains(&Some(ElevationRequirement::ElevationRequired)) + { + return None; + } + + match installer.elevation_requirement { + None => Some(Scope::User), + Some(ElevationRequirement::ElevationRequired) => Some(Scope::Machine), + _ => None, + } +} + pub fn match_installers( - previous_installers: Vec, + previous_installers: &[Installer], new_installers: &[Installer], ) -> HashMap { let found_architectures = new_installers @@ -72,8 +106,14 @@ pub fn match_installers( .collect::>(); previous_installers - .into_iter() - .map(|previous_installer| { + .iter() + .cloned() + .map(|mut previous_installer| { + if previous_installer.scope.is_none() { + previous_installer.scope = + duplicate_elevation_scope(&previous_installer, previous_installers); + } + let mut max_score = 0.0; let mut best_match = None; @@ -140,7 +180,7 @@ mod tests { use rstest::rstest; use winget_types::{ - installer::{Architecture, Installer, Scope}, + installer::{Architecture, ElevationRequirement, Installer, Scope}, url::DecodedUrl, }; @@ -195,11 +235,64 @@ mod tests { (previous_machine_x64, installer_x64), ]); assert_eq!( - match_installers(previous_installers, &new_installers), + match_installers(&previous_installers, &new_installers), expected ); } + #[rstest] + #[case::x64( + Architecture::X64, + "https://example.com/installer-1.0.exe", + "https://example.com/installer-2.0.exe" + )] + #[case::arm64( + Architecture::Arm64, + "https://example.com/installer-1.0-arm64.exe", + "https://example.com/installer-2.0-arm64.exe" + )] + fn old_scope_regression_shape_generates_scope_values_after_matching( + #[case] architecture: Architecture, + #[case] previous_url: &str, + #[case] new_url: &str, + ) { + let previous_url = DecodedUrl::from_str(previous_url).unwrap(); + let previous_installers = vec![ + Installer { + architecture, + url: previous_url.clone(), + ..Installer::default() + }, + Installer { + architecture, + url: previous_url, + elevation_requirement: Some(ElevationRequirement::ElevationRequired), + ..Installer::default() + }, + ]; + let new_installers = vec![Installer { + architecture, + url: DecodedUrl::from_str(new_url).unwrap(), + ..Installer::default() + }]; + + let mut installers = match_installers(&previous_installers, &new_installers) + .into_iter() + .map(|(previous_installer, new_installer)| { + new_installer.clone().merge_with(previous_installer) + }) + .collect::>(); + installers.sort_unstable(); + + assert_eq!( + installers + .iter() + .map(|installer| installer.scope) + .collect::>(), + vec![Some(Scope::User), Some(Scope::Machine)] + ); + } + #[rstest] #[case::exact_english( "en-US", @@ -259,7 +352,7 @@ mod tests { assert_eq!( match_installers( - vec![previous_installer.clone()], + std::slice::from_ref(&previous_installer), &[competing_installer, expected_installer.clone()], ), HashMap::from([(previous_installer, expected_installer)]) @@ -363,7 +456,7 @@ mod tests { }; assert_eq!( match_installers( - vec![previous_installer.clone()], + std::slice::from_ref(&previous_installer), &[competing_installer, expected_installer.clone()], ), HashMap::from([(previous_installer, expected_installer)]) From 30288bf40e4dae23cc5ec9a728af339728c594ec Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Sat, 23 May 2026 19:55:55 -0400 Subject: [PATCH 20/31] Add request retrying --- Cargo.lock | 77 ++++++ Cargo.toml | 2 + src/commands/token/update.rs | 15 +- src/github/client.rs | 40 ++- src/github/error.rs | 2 + src/github/graphql/create_commit.rs | 8 +- src/github/graphql/create_pull_request.rs | 7 +- src/github/graphql/get_current_user.rs | 8 +- src/github/graphql/get_directory_content.rs | 18 +- .../graphql/get_existing_pull_request.rs | 6 +- src/github/mod.rs | 1 + src/github/rest/tree/mod.rs | 2 +- src/github/retry.rs | 236 ++++++++++++++++++ src/token.rs | 25 +- 14 files changed, 381 insertions(+), 66 deletions(-) create mode 100644 src/github/retry.rs diff --git a/Cargo.lock b/Cargo.lock index be0b84ae0..869eefce1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,17 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic" version = "0.6.1" @@ -2302,6 +2313,8 @@ dependencies = [ "ratatui-textarea", "regex", "reqwest", + "reqwest-middleware", + "reqwest-retry", "rstest", "secrecy", "serde", @@ -3244,6 +3257,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.33" @@ -3763,6 +3782,50 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest-middleware" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest", + "serde", + "thiserror 2.0.18", + "tower-service", +] + +[[package]] +name = "reqwest-retry" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2412db2af7d2268e7a5406be0431f37d9eb67ff390f35b395716f5f06c2eaa" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "getrandom 0.2.17", + "http", + "hyper", + "reqwest", + "reqwest-middleware", + "retry-policies", + "thiserror 2.0.18", + "tokio", + "wasmtimer", +] + +[[package]] +name = "retry-policies" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc05fbf560421a0357a750cbe78c7ca19d4923918490daabba313d5dbc871e47" +dependencies = [ + "rand 0.10.1", +] + [[package]] name = "ring" version = "0.17.14" @@ -5248,6 +5311,20 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + [[package]] name = "web-sys" version = "0.3.99" diff --git a/Cargo.toml b/Cargo.toml index a22a56036..1475dbb85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,8 @@ ratatui = "0.30.1" ratatui-textarea = { version = "0.9.1", features = ["search"] } regex = "1.12.4" reqwest = { version = "0.13.4", default-features = false, features = ["charset", "http2", "stream", "system-proxy"] } +reqwest-middleware = { version = "0.5.2", default-features = false, features = ["json"] } +reqwest-retry = { version = "0.9.1", default-features = false } secrecy = "0.10.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" diff --git a/src/commands/token/update.rs b/src/commands/token/update.rs index 70d7b88ae..36612c3d9 100644 --- a/src/commands/token/update.rs +++ b/src/commands/token/update.rs @@ -2,10 +2,13 @@ use anstream::println; use clap::Parser; use color_eyre::eyre::Result; use owo_colors::OwoColorize; -use reqwest::Client; +use reqwest::Client as ReqwestClient; use secrecy::{ExposeSecret, SecretString}; -use crate::token::{TokenManager, default_headers}; +use crate::{ + github::retry::client as retrying_client, + token::{TokenManager, default_headers}, +}; /// Update the stored token #[derive(Parser)] @@ -20,9 +23,11 @@ impl UpdateToken { pub async fn run(self) -> Result<()> { let credential = TokenManager::credential()?; - let client = Client::builder() - .default_headers(default_headers(None)) - .build()?; + let client = retrying_client( + ReqwestClient::builder() + .default_headers(default_headers(None)) + .build()?, + ); let token = match self.token { Some(token) => { diff --git a/src/github/client.rs b/src/github/client.rs index 0d2075abe..8de281abf 100644 --- a/src/github/client.rs +++ b/src/github/client.rs @@ -2,12 +2,13 @@ use std::{borrow::Cow, collections::BTreeSet, num::NonZeroU32, str::FromStr}; use bon::bon; use color_eyre::eyre::eyre; -use cynic::{GraphQlResponse, Id, MutationBuilder, QueryBuilder, http::ReqwestExt}; +use cynic::{GraphQlResponse, Id, MutationBuilder, QueryBuilder}; use futures_util::future::OptionFuture; use indexmap::IndexMap; use indicatif::ProgressBar; use itertools::Itertools; use reqwest::Client; +use reqwest_middleware::ClientWithMiddleware; use secrecy::SecretString; use serde::de::DeserializeOwned; use url::Url; @@ -25,7 +26,6 @@ use crate::{ github::{ MICROSOFT, WINGET_PKGS, WINGET_PKGS_FULL_NAME, graphql::{ - GRAPHQL_URL, create_commit::{FileAddition, FileDeletion}, create_ref::{CreateRef, CreateRefVariables, Ref as CreateBranchRef}, get_all_values::{GetAllValues, GetAllValuesGitObject, GetAllValuesVariables, Tree}, @@ -49,7 +49,7 @@ use crate::{ #[derive(Clone)] #[repr(transparent)] -pub struct GitHub(pub(super) Client); +pub struct GitHub(pub(super) ClientWithMiddleware); #[bon] impl GitHub { @@ -57,11 +57,11 @@ impl GitHub { where T: AsRef, { - Ok(Self( + Ok(Self(super::retry::client( Client::builder() .default_headers(default_headers(Some(token.as_ref()))) .build()?, - )) + ))) } pub async fn get_manifests( @@ -134,11 +134,10 @@ impl GitHub { repo: &str, path: &PackagePath, ) -> Result, GitHubError> { + let expression = format!("HEAD:{path}"); let GraphQlResponse { data, errors } = self - .0 - .post(GRAPHQL_URL) - .run_graphql(GetDirectoryContentWithText::build( - GetDirectoryContentVariables::new(&owner, &repo, &format!("HEAD:{path}")), + .run_graphql_with_retry(&GetDirectoryContentWithText::build( + GetDirectoryContentVariables::new(&owner, &repo, &expression), )) .await?; @@ -184,9 +183,7 @@ impl GitHub { name: &str, ) -> Result { let GraphQlResponse { data, errors } = self - .0 - .post(GRAPHQL_URL) - .run_graphql(GetRepositoryInfo::build(RepositoryVariables::new( + .run_graphql_with_retry(&GetRepositoryInfo::build(RepositoryVariables::new( owner, name, ))) .await?; @@ -231,12 +228,11 @@ impl GitHub { branch_name: &str, oid: GitObjectId, ) -> Result { + let ref_name = format!("refs/heads/{branch_name}"); let GraphQlResponse { data, errors } = self - .0 - .post(GRAPHQL_URL) - .run_graphql(CreateRef::build( + .run_graphql_with_retry(&CreateRef::build( CreateRefVariables::builder() - .name(&format!("refs/heads/{branch_name}")) + .name(&ref_name) .oid(oid) .repository_id(fork_id) .build(), @@ -260,9 +256,7 @@ impl GitHub { loop { let GraphQlResponse { data, errors } = self - .0 - .post(GRAPHQL_URL) - .run_graphql(GetBranches::build(GetBranchesVariables { + .run_graphql_with_retry(&GetBranches::build(GetBranchesVariables { owner: user, name: WINGET_PKGS, cursor: cursor.as_deref(), @@ -326,9 +320,7 @@ impl GitHub { T: Into, { let GraphQlResponse { data, errors } = self - .0 - .post(GRAPHQL_URL) - .run_graphql(UpdateRefs::build(UpdateRefsInput::new( + .run_graphql_with_retry(&UpdateRefs::build(UpdateRefsInput::new( RefUpdate::delete_branches(branch_names), repository_id, ))) @@ -370,9 +362,7 @@ impl GitHub { #[builder(into)] tag_name: Cow<'a, str>, ) -> Result { let GraphQlResponse { data, errors } = self - .0 - .post(GRAPHQL_URL) - .run_graphql(GetAllValues::build(GetAllValuesVariables { + .run_graphql_with_retry(&GetAllValues::build(GetAllValuesVariables { name: &repo, owner: &owner, tag_name: &tag_name, diff --git a/src/github/error.rs b/src/github/error.rs index 1306b2514..fdd5868ca 100644 --- a/src/github/error.rs +++ b/src/github/error.rs @@ -23,6 +23,8 @@ pub enum GitHubError { #[error(transparent)] Reqwest(#[from] reqwest::Error), #[error(transparent)] + ReqwestMiddleware(#[from] reqwest_middleware::Error), + #[error(transparent)] CynicRequest(#[from] CynicReqwestError), #[error(transparent)] YamlError(#[from] serde_saphyr::Error), diff --git a/src/github/graphql/create_commit.rs b/src/github/graphql/create_commit.rs index d5c4fd37b..ca52ceb0a 100644 --- a/src/github/graphql/create_commit.rs +++ b/src/github/graphql/create_commit.rs @@ -2,12 +2,12 @@ use std::borrow::Cow; use bon::{Builder, bon}; use color_eyre::eyre::eyre; -use cynic::{GraphQlResponse, Id, MutationBuilder, http::ReqwestExt}; +use cynic::{GraphQlResponse, Id, MutationBuilder}; use url::Url; use super::{ super::{GitHubError, client::GitHub}, - GRAPHQL_URL, github_schema as schema, + github_schema as schema, types::{Base64String, GitObjectId}, }; @@ -140,9 +140,7 @@ impl GitHub { #[builder(default)] deletions: Vec>, ) -> Result { let GraphQlResponse { data, errors } = self - .0 - .post(GRAPHQL_URL) - .run_graphql(CreateCommit::build(CreateCommitVariables { + .run_graphql_with_retry(&CreateCommit::build(CreateCommitVariables { input: CreateCommitOnBranchInput::builder() .branch(CommittableBranch::new(branch_id)) .expected_head_oid(head_sha) diff --git a/src/github/graphql/create_pull_request.rs b/src/github/graphql/create_pull_request.rs index 028302b0a..db8b9c2a9 100644 --- a/src/github/graphql/create_pull_request.rs +++ b/src/github/graphql/create_pull_request.rs @@ -2,13 +2,13 @@ use std::io::Write; use bon::Builder; use color_eyre::eyre::eyre; -use cynic::{GraphQlResponse, MutationBuilder, http::ReqwestExt}; +use cynic::{GraphQlResponse, MutationBuilder}; use owo_colors::OwoColorize; use url::Url; use super::{ super::{GitHubError, client::GitHub}, - GRAPHQL_URL, github_schema as schema, + github_schema as schema, }; use crate::terminal::{Hyperlinkable, SUPPORTS_HYPERLINKS}; @@ -118,8 +118,7 @@ impl GitHub { .build(), }); - let GraphQlResponse { data, errors } = - self.0.post(GRAPHQL_URL).run_graphql(operation).await?; + let GraphQlResponse { data, errors } = self.run_graphql_with_retry(&operation).await?; data.and_then(|data| data.create_pull_request?.pull_request) .ok_or_else(|| { diff --git a/src/github/graphql/get_current_user.rs b/src/github/graphql/get_current_user.rs index c0fdf9011..5efe6dc8e 100644 --- a/src/github/graphql/get_current_user.rs +++ b/src/github/graphql/get_current_user.rs @@ -1,11 +1,11 @@ use std::env; use color_eyre::eyre::eyre; -use cynic::{GraphQlResponse, QueryBuilder, http::ReqwestExt}; +use cynic::{GraphQlResponse, QueryBuilder}; use super::{ super::{GitHubError, client::GitHub}, - GRAPHQL_URL, github_schema as schema, + github_schema as schema, }; /// @@ -41,9 +41,7 @@ impl GitHub { Ok(login) } else { let GraphQlResponse { data, errors } = self - .0 - .post(GRAPHQL_URL) - .run_graphql(GetCurrentUserLogin::build(())) + .run_graphql_with_retry(&GetCurrentUserLogin::build(())) .await?; let Some(data) = data else { diff --git a/src/github/graphql/get_directory_content.rs b/src/github/graphql/get_directory_content.rs index b65dcf2fb..0824b3fb8 100644 --- a/src/github/graphql/get_directory_content.rs +++ b/src/github/graphql/get_directory_content.rs @@ -2,11 +2,11 @@ use std::fmt; use bon::bon; use color_eyre::eyre::eyre; -use cynic::{GraphQlResponse, QueryBuilder, http::ReqwestExt}; +use cynic::{GraphQlResponse, QueryBuilder}; use super::{ super::{GitHubError, MICROSOFT, WINGET_PKGS, client::GitHub, utils::PackagePath}, - GRAPHQL_URL, GetFileContent, github_schema as schema, + GetFileContent, github_schema as schema, }; #[derive(cynic::QueryVariables)] @@ -86,13 +86,12 @@ impl GitHub { R: AsRef, P: fmt::Display, { + let expression = format!("HEAD:{path}"); let GraphQlResponse { data, errors } = self - .0 - .post(GRAPHQL_URL) - .run_graphql(GetFileContent::build(GetDirectoryContentVariables::new( + .run_graphql_with_retry(&GetFileContent::build(GetDirectoryContentVariables::new( &owner, &repo, - &format!("HEAD:{path}"), + &expression, ))) .await?; @@ -108,11 +107,10 @@ impl GitHub { #[builder(default = "HEAD")] branch_name: &str, path: &PackagePath, ) -> Result, GitHubError> { + let expression = format!("{branch_name}:{path}"); let GraphQlResponse { data, errors } = self - .0 - .post(GRAPHQL_URL) - .run_graphql(GetDirectoryContent::build( - GetDirectoryContentVariables::new(&owner, &repo, &format!("{branch_name}:{path}")), + .run_graphql_with_retry(&GetDirectoryContent::build( + GetDirectoryContentVariables::new(&owner, &repo, &expression), )) .await?; let entries = data diff --git a/src/github/graphql/get_existing_pull_request.rs b/src/github/graphql/get_existing_pull_request.rs index e20f4831f..c266e2922 100644 --- a/src/github/graphql/get_existing_pull_request.rs +++ b/src/github/graphql/get_existing_pull_request.rs @@ -1,11 +1,11 @@ use chrono::{DateTime, Utc}; -use cynic::{QueryBuilder, http::ReqwestExt}; +use cynic::QueryBuilder; use url::Url; use winget_types::{PackageIdentifier, PackageVersion}; use super::{ super::{GitHubError, WINGET_PKGS_FULL_NAME, client::GitHub}, - GRAPHQL_URL, github_schema as schema, + github_schema as schema, types::PullRequestState, }; @@ -141,7 +141,7 @@ impl GitHub { }) }) }) - .map_err(GitHubError::CynicRequest) + })) } } diff --git a/src/github/mod.rs b/src/github/mod.rs index 06a6c76d4..dddd3d206 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -2,6 +2,7 @@ pub mod client; mod error; pub mod graphql; mod rest; +pub(crate) mod retry; pub mod utils; use std::{ diff --git a/src/github/rest/tree/mod.rs b/src/github/rest/tree/mod.rs index e956f504d..c6c52e1c2 100644 --- a/src/github/rest/tree/mod.rs +++ b/src/github/rest/tree/mod.rs @@ -60,7 +60,7 @@ impl GitHub { let response = self .0 - .get(endpoint) + .get(&endpoint) .header(ACCEPT, GITHUB_JSON_MIME) .header(X_GITHUB_API_VERSION, REST_API_VERSION) .send() diff --git a/src/github/retry.rs b/src/github/retry.rs new file mode 100644 index 000000000..03d189be0 --- /dev/null +++ b/src/github/retry.rs @@ -0,0 +1,236 @@ +use std::{error::Error as _, time::Duration}; + +use cynic::{GraphQlError, GraphQlResponse, Operation, http::CynicReqwestError}; +use reqwest::{ + Client, Response, StatusCode, + header::{HeaderMap, RETRY_AFTER}, +}; +use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; +use reqwest_retry::{ + Jitter, RetryTransientMiddleware, Retryable, RetryableStrategy, default_on_request_failure, + default_on_request_success, policies::ExponentialBackoff, +}; +use serde::{Serialize, de::DeserializeOwned}; + +use super::{GitHubError, client::GitHub, graphql::GRAPHQL_URL}; + +const MAX_GITHUB_REQUEST_RETRIES: u32 = 3; +const MIN_RETRY_INTERVAL: Duration = Duration::from_secs(1); +const MAX_RETRY_INTERVAL: Duration = Duration::from_secs(30); + +pub(crate) fn client(client: Client) -> ClientWithMiddleware { + let retry_policy = ExponentialBackoff::builder() + .retry_bounds(MIN_RETRY_INTERVAL, MAX_RETRY_INTERVAL) + .jitter(Jitter::Bounded) + .build_with_max_retries(MAX_GITHUB_REQUEST_RETRIES); + + ClientBuilder::new(client) + .with(RetryTransientMiddleware::new_with_policy_and_strategy( + retry_policy, + GitHubRetryableStrategy, + )) + .build() +} + +pub(crate) fn is_connect_error(error: &reqwest_middleware::Error) -> bool { + if error.is_connect() { + return true; + } + + let mut source = error.source(); + while let Some(error) = source { + if error + .downcast_ref::() + .is_some_and(reqwest::Error::is_connect) + { + return true; + } + + source = error.source(); + } + + false +} + +impl GitHub { + pub(super) async fn run_graphql_with_retry( + &self, + operation: &Operation, + ) -> Result, GitHubError> + where + ResponseData: DeserializeOwned + 'static, + Vars: Serialize, + { + // TEMPORARY TEST EXIT 1: remove this flag after validating workflow retries. + let mut retried_transient_graphql_error = false; + + for retry in 0..=MAX_GITHUB_REQUEST_RETRIES { + let response = + deserialize_graphql_response(self.0.post(GRAPHQL_URL).json(operation).send().await) + .await?; + + if retry == MAX_GITHUB_REQUEST_RETRIES || !is_retryable_graphql_response(&response) { + // TEMPORARY TEST EXIT 1: remove this block after validating workflow retries. + if retried_transient_graphql_error { + return Err(GitHubError::GraphQL(color_eyre::eyre::eyre!( + "temporary forced failure after GitHub GraphQL retry for workflow retry validation" + ))); + } + + return Ok(response); + } + + // TEMPORARY TEST EXIT 1: remove this assignment after validating workflow retries. + retried_transient_graphql_error = true; + + let delay = graphql_retry_delay(retry); + tracing::info!( + retry = retry + 1, + max_retries = MAX_GITHUB_REQUEST_RETRIES, + delay_secs = delay.as_secs(), + "Retrying GitHub GraphQL request after transient error" + ); + tokio::time::sleep(delay).await; + } + + unreachable!("GraphQL retry loop must return before exceeding max retries"); + } +} + +struct GitHubRetryableStrategy; + +impl RetryableStrategy for GitHubRetryableStrategy { + fn handle(&self, result: &Result) -> Option { + match result { + Ok(response) if is_retryable_response(response) => Some(Retryable::Transient), + Ok(response) => default_on_request_success(response), + Err(error) => default_on_request_failure(error), + } + } +} + +async fn deserialize_graphql_response( + response: reqwest_middleware::Result, +) -> Result, GitHubError> +where + ResponseData: DeserializeOwned, +{ + let response = response?; + + let status = response.status(); + if status.is_success() { + Ok(response + .json() + .await + .map_err(CynicReqwestError::ReqwestError)?) + } else { + let text = response.text().await?; + + if let Ok(response) = serde_json::from_str(&text) { + Ok(response) + } else { + Err(CynicReqwestError::ErrorResponse(status, text).into()) + } + } +} + +fn is_retryable_response(response: &Response) -> bool { + is_retryable_status(response.status(), response.headers()) +} + +fn is_retryable_graphql_response(response: &GraphQlResponse) -> bool { + response + .errors + .as_ref() + .is_some_and(|errors| errors.iter().any(|error| is_retryable_graphql_error(error))) +} + +fn is_retryable_graphql_error(error: &GraphQlError) -> bool { + error + .message + .starts_with("Something went wrong while executing your query") +} + +fn is_retryable_status(status: StatusCode, headers: &HeaderMap) -> bool { + status == StatusCode::REQUEST_TIMEOUT + || status == StatusCode::TOO_MANY_REQUESTS + || status.is_server_error() + || (status == StatusCode::FORBIDDEN && headers.contains_key(RETRY_AFTER)) +} + +fn graphql_retry_delay(retry: u32) -> Duration { + MIN_RETRY_INTERVAL + .saturating_mul(2_u32.saturating_pow(retry)) + .min(MAX_RETRY_INTERVAL) +} + +#[cfg(test)] +mod tests { + use reqwest::header::HeaderValue; + + use super::*; + + #[test] + fn retryable_status_codes() { + let headers = HeaderMap::new(); + + assert!(is_retryable_status(StatusCode::REQUEST_TIMEOUT, &headers)); + assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS, &headers)); + assert!(is_retryable_status(StatusCode::BAD_GATEWAY, &headers)); + assert!(is_retryable_status( + StatusCode::SERVICE_UNAVAILABLE, + &headers + )); + assert!(is_retryable_status(StatusCode::GATEWAY_TIMEOUT, &headers)); + + assert!(!is_retryable_status(StatusCode::UNAUTHORIZED, &headers)); + assert!(!is_retryable_status(StatusCode::FORBIDDEN, &headers)); + assert!(!is_retryable_status(StatusCode::NOT_FOUND, &headers)); + } + + #[test] + fn forbidden_with_retry_after_is_retryable() { + let mut headers = HeaderMap::new(); + headers.insert(RETRY_AFTER, HeaderValue::from_static("1")); + + assert!(is_retryable_status(StatusCode::FORBIDDEN, &headers)); + } + + #[test] + fn github_internal_graphql_error_is_retryable() { + let response = GraphQlResponse::<()> { + data: None, + errors: Some(vec![GraphQlError::new( + "Something went wrong while executing your query on 2026-05-30T14:08:34Z. Please include `1601:106F03:698CB68:1920E102:6A1AEF61` when reporting this issue.".to_owned(), + None, + None, + None, + )]), + }; + + assert!(is_retryable_graphql_response(&response)); + } + + #[test] + fn validation_graphql_error_is_not_retryable() { + let response = GraphQlResponse::<()> { + data: None, + errors: Some(vec![GraphQlError::new( + "Could not resolve to a Repository with the name 'owner/repo'.".to_owned(), + None, + None, + None, + )]), + }; + + assert!(!is_retryable_graphql_response(&response)); + } + + #[test] + fn graphql_retry_delay_uses_exponential_backoff_bounds() { + assert_eq!(graphql_retry_delay(0), Duration::from_secs(1)); + assert_eq!(graphql_retry_delay(1), Duration::from_secs(2)); + assert_eq!(graphql_retry_delay(2), Duration::from_secs(4)); + assert_eq!(graphql_retry_delay(u32::MAX), MAX_RETRY_INTERVAL); + } +} diff --git a/src/token.rs b/src/token.rs index 5c83c3556..b90259162 100644 --- a/src/token.rs +++ b/src/token.rs @@ -5,14 +5,19 @@ use color_eyre::eyre::Result; use inquire::{InquireError, Password, error::InquireResult, validator::Validation}; use keyring_core::Entry; use reqwest::{ - Client, StatusCode, + Client as ReqwestClient, StatusCode, header::{AUTHORIZATION, DNT, HeaderMap, HeaderValue, USER_AGENT}, }; +use reqwest_middleware::ClientWithMiddleware; use secrecy::{ExposeSecret, SecretString}; use thiserror::Error; use tokio::runtime::Handle; -use crate::{commands::utils::environment::CI, prompts::handle_inquire_error}; +use crate::{ + commands::utils::environment::CI, + github::retry::{client as retrying_client, is_connect_error}, + prompts::handle_inquire_error, +}; const GITHUB_API_ENDPOINT: &str = "https://api.github.com/octocat"; @@ -33,6 +38,8 @@ pub enum TokenError { #[error(transparent)] Request(#[from] reqwest::Error), #[error(transparent)] + RequestMiddleware(#[from] reqwest_middleware::Error), + #[error(transparent)] Inquire(#[from] InquireError), } @@ -49,9 +56,11 @@ impl TokenManager { // * In CI: if no token or if stored token is invalid -> error (never prompt). // * Interactive: if no stored token or stored token is invalid -> prompt and store. - let client = Client::builder() - .default_headers(default_headers(None)) - .build()?; + let client = retrying_client( + ReqwestClient::builder() + .default_headers(default_headers(None)) + .build()?, + ); let token_passed = token.is_some(); @@ -102,7 +111,7 @@ impl TokenManager { #[builder] pub fn prompt( - client: &Client, + client: &ClientWithMiddleware, #[builder(default = "Enter a GitHub token")] message: &str, ) -> InquireResult { tokio::task::block_in_place(|| { @@ -124,7 +133,7 @@ impl TokenManager { }) } - pub async fn validate(client: &Client, token: &str) -> Result<(), TokenError> { + pub async fn validate(client: &ClientWithMiddleware, token: &str) -> Result<(), TokenError> { match client .get(GITHUB_API_ENDPOINT) .bearer_auth(token) @@ -136,7 +145,7 @@ impl TokenManager { _ => Ok(()), }, Err(error) => { - if error.is_connect() { + if is_connect_error(&error) { Err(TokenError::FailedToConnect) } else { Err(error.into()) From 90d82179e8665c2641dbd85efdb636adeb559ed4 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Fri, 29 May 2026 16:03:17 -0400 Subject: [PATCH 21/31] Expose PE info in analyze command --- src/analysis/analyzer.rs | 8 ++- src/analysis/installers/exe.rs | 35 ++++++++----- src/analysis/mod.rs | 2 + src/analysis/pe_info.rs | 94 ++++++++++++++++++++++++++++++++++ src/commands/analyze.rs | 51 ++++++++++++++---- 5 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 src/analysis/pe_info.rs diff --git a/src/analysis/analyzer.rs b/src/analysis/analyzer.rs index 7bd1facab..610590d5c 100644 --- a/src/analysis/analyzer.rs +++ b/src/analysis/analyzer.rs @@ -10,7 +10,10 @@ use winget_types::{ locale::{Copyright, PackageName, Publisher}, }; -use super::extensions::{APPX, APPX_BUNDLE, EXE, MSI, MSIX, MSIX_BUNDLE, ZIP}; +use super::{ + PeInfo, + extensions::{APPX, APPX_BUNDLE, EXE, MSI, MSIX, MSIX_BUNDLE, ZIP}, +}; use crate::analysis::{ Installers, installers::{ @@ -28,6 +31,7 @@ pub struct Analyzer<'reader, R: Read + Seek> { pub file_version: Option, #[allow(dead_code)] pub product_version: Option, + pub pe_info: Option, pub installers: Vec, pub zip: Option>, } @@ -70,6 +74,7 @@ impl<'reader, R: Read + Seek> Analyzer<'reader, R> { .and_then(|company_name| Publisher::new(company_name).ok()), file_version: exe.file_version.take(), product_version: exe.product_version.take(), + pe_info: exe.pe_info.take(), ..Self::default() }); } @@ -91,6 +96,7 @@ impl Default for Analyzer<'_, R> { publisher: None, file_version: None, product_version: None, + pe_info: None, installers: Vec::default(), zip: None, } diff --git a/src/analysis/installers/exe.rs b/src/analysis/installers/exe.rs index e4cb139aa..3bb01b530 100644 --- a/src/analysis/installers/exe.rs +++ b/src/analysis/installers/exe.rs @@ -6,12 +6,15 @@ use winget_types::installer::{Installer, InstallerType}; use super::{super::Installers, AdvancedInstaller, Burn, Nsis, Squirrel}; use crate::{ - analysis::installers::{ - advanced::AdvancedInstallerError, - burn::BurnError, - nsis::NsisError, - pe::{PE, VSVersionInfo}, - squirrel::SquirrelError, + analysis::{ + PeInfo, + installers::{ + advanced::AdvancedInstallerError, + burn::BurnError, + nsis::NsisError, + pe::{PE, VSVersionInfo}, + squirrel::SquirrelError, + }, }, traits::IntoWingetArchitecture, }; @@ -27,6 +30,7 @@ pub struct Exe { pub company_name: Option, pub file_version: Option, pub product_version: Option, + pub pe_info: Option, } pub enum ExeType { @@ -46,6 +50,7 @@ impl Exe { let vs_version_info = vs_version_info_bytes .as_deref() .and_then(|version_info_bytes| VSVersionInfo::read_from(version_info_bytes).ok()); + let pe_info = vs_version_info.as_ref().map(PeInfo::from_version_info); let mut string_table = vs_version_info.as_ref().map(VSVersionInfo::string_table); let legal_copyright = string_table .as_mut() @@ -59,13 +64,13 @@ impl Exe { .as_mut() .and_then(|table| table.swap_remove("CompanyName")) .map(str::to_owned); - let file_version = string_table - .as_mut() - .and_then(|table| table.swap_remove("FileVersion")) + let file_version = pe_info + .as_ref() + .and_then(PeInfo::file_version) .map(str::to_owned); - let product_version = string_table - .as_mut() - .and_then(|table| table.swap_remove("ProductVersion")) + let product_version = pe_info + .as_ref() + .and_then(PeInfo::product_version) .map(str::to_owned); match AdvancedInstaller::new(&mut reader) { @@ -77,6 +82,7 @@ impl Exe { company_name, file_version, product_version, + pe_info, }); } Err(AdvancedInstallerError::NotAdvancedInstallerFile) => {} @@ -92,6 +98,7 @@ impl Exe { company_name, file_version, product_version, + pe_info, }); } Err(BurnError::NotBurnFile) => {} @@ -107,6 +114,7 @@ impl Exe { company_name, file_version, product_version, + pe_info, }); } Err(InnoError::NotInnoFile) => {} @@ -122,6 +130,7 @@ impl Exe { company_name, file_version, product_version, + pe_info, }); } Err(NsisError::NotNsisFile) => {} @@ -137,6 +146,7 @@ impl Exe { company_name, file_version, product_version, + pe_info, }); } Err(SquirrelError::NotSquirrelFile) => {} @@ -167,6 +177,7 @@ impl Exe { company_name, file_version, product_version, + pe_info, }) } } diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs index b85e010fc..18a0887c5 100644 --- a/src/analysis/mod.rs +++ b/src/analysis/mod.rs @@ -1,7 +1,9 @@ mod analyzer; mod extensions; pub mod installers; +mod pe_info; mod r#trait; pub use analyzer::Analyzer; +pub use pe_info::PeInfo; pub use r#trait::Installers; diff --git a/src/analysis/pe_info.rs b/src/analysis/pe_info.rs new file mode 100644 index 000000000..cd98a13c9 --- /dev/null +++ b/src/analysis/pe_info.rs @@ -0,0 +1,94 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::analysis::installers::pe::VSVersionInfo; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct PeInfo { + pub fixed_file_info: PeFixedFileInfo, + + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub string_file_info: BTreeMap, +} + +impl PeInfo { + pub fn from_version_info(version_info: &VSVersionInfo<'_>) -> Self { + Self { + fixed_file_info: PeFixedFileInfo::from(version_info), + string_file_info: string_file_info_from_entries(version_info.string_table()), + } + } + + pub fn file_version(&self) -> Option<&str> { + self.string_file_info.get("FileVersion").map(String::as_str) + } + + pub fn product_version(&self) -> Option<&str> { + self.string_file_info + .get("ProductVersion") + .map(String::as_str) + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct PeFixedFileInfo { + pub file_version: String, + pub product_version: String, + #[serde(rename = "FileOS")] + pub file_os: u32, + pub file_type: u32, + pub file_subtype: u32, +} + +impl From<&VSVersionInfo<'_>> for PeFixedFileInfo { + fn from(version_info: &VSVersionInfo<'_>) -> Self { + let fixed_file_info = version_info.fixed; + + Self { + file_version: format_version(fixed_file_info.file_version_raw()), + product_version: format_version(fixed_file_info.product_version_raw()), + file_os: fixed_file_info.file_os(), + file_type: fixed_file_info.file_type(), + file_subtype: fixed_file_info.file_subtype(), + } + } +} + +fn format_version((major, minor, patch, build): (u16, u16, u16, u16)) -> String { + format!("{major}.{minor}.{patch}.{build}") +} + +fn string_file_info_from_entries<'a>( + entries: impl IntoIterator, +) -> BTreeMap { + entries + .into_iter() + .filter(|(_, value)| !value.trim().is_empty()) + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::string_file_info_from_entries; + + #[test] + fn empty_string_file_info_values_are_omitted() { + let string_file_info = string_file_info_from_entries([ + ("FileVersion", "1.2.3"), + ("ProductName", ""), + ("CompanyName", " "), + ]); + + assert_eq!(string_file_info.len(), 1); + assert_eq!( + string_file_info.get("FileVersion").map(String::as_str), + Some("1.2.3") + ); + assert!(!string_file_info.contains_key("ProductName")); + assert!(!string_file_info.contains_key("CompanyName")); + } +} diff --git a/src/commands/analyze.rs b/src/commands/analyze.rs index b9e09e190..cf4e0c24f 100644 --- a/src/commands/analyze.rs +++ b/src/commands/analyze.rs @@ -8,11 +8,12 @@ use anstream::stdout; use camino::{Utf8Path, Utf8PathBuf}; use clap::Parser; use color_eyre::{Result, eyre::ensure}; +use serde::Serialize; use sha2::{Digest, Sha256, digest::Output}; -use winget_types::Sha256String; +use winget_types::{Sha256String, installer::Installer}; use crate::{ - analysis::Analyzer, + analysis::{Analyzer, PeInfo}, manifests::{print_manifest, to_yaml_string}, }; @@ -51,17 +52,31 @@ impl Analyze { .file_path .file_name() .unwrap_or_else(|| self.file_path.as_str()); - let mut installers = Analyzer::new(&mut file, file_name)?.installers; - if self.hash { - file.seek(SeekFrom::Start(0))?; - let sha_256 = Sha256String::from_digest(&sha256_digest(file)?); - for installer in &mut installers { + let sha_256 = self + .hash + .then(|| { + let sha_256 = Sha256String::from_digest(&sha256_digest(&mut file)?); + file.seek(SeekFrom::Start(0))?; + Ok::<_, io::Error>(sha_256) + }) + .transpose()?; + + let mut analyzer = Analyzer::new(&mut file, file_name)?; + if let Some(sha_256) = sha_256 { + for installer in &mut analyzer.installers { installer.sha_256 = sha_256.clone(); } } - let yaml = match installers.as_slice() { - [installer] => to_yaml_string(installer)?, - installers => to_yaml_string(&installers)?, + let yaml = match (analyzer.pe_info.as_ref(), analyzer.installers.as_slice()) { + (Some(pe_info), [installer]) => { + to_yaml_string(&AnalyzeSingleOutput { pe_info, installer })? + } + (Some(pe_info), installers) => to_yaml_string(&AnalyzeMultiOutput { + pe_info, + installers, + })?, + (None, [installer]) => to_yaml_string(installer)?, + (None, installers) => to_yaml_string(&installers)?, }; let mut lock = stdout().lock(); print_manifest(&mut lock, &yaml); @@ -69,6 +84,22 @@ impl Analyze { } } +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct AnalyzeSingleOutput<'a> { + #[serde(rename = "PEInfo")] + pe_info: &'a PeInfo, + installer: &'a Installer, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct AnalyzeMultiOutput<'a> { + #[serde(rename = "PEInfo")] + pe_info: &'a PeInfo, + installers: &'a [Installer], +} + fn sha256_digest(mut reader: R) -> io::Result> { let mut digest = Sha256::new(); let mut buffer = [0; 1 << 13]; From ab9ccf4d5e4c4a5cb57a82beaf8e502efe26152a Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Wed, 3 Jun 2026 17:31:46 -0400 Subject: [PATCH 22/31] Add new-locale command --- src/commands/mod.rs | 1 + src/commands/new_locale.rs | 370 +++++++++++++++++++++++++++++++++++++ src/main.rs | 3 + src/prompts/list.rs | 11 +- src/prompts/text.rs | 29 ++- 5 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 src/commands/new_locale.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f989af20b..907241574 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod analyze; pub mod cleanup; pub mod complete; pub mod list_versions; +pub mod new_locale; pub mod new_version; pub mod remove_dead_versions; pub mod remove_version; diff --git a/src/commands/new_locale.rs b/src/commands/new_locale.rs new file mode 100644 index 000000000..110035edf --- /dev/null +++ b/src/commands/new_locale.rs @@ -0,0 +1,370 @@ +use std::{collections::BTreeSet, mem, num::NonZeroU32}; + +use anstream::println; +use camino::Utf8PathBuf; +use clap::Parser; +use color_eyre::eyre::{Result, bail}; +use indicatif::ProgressBar; +use owo_colors::OwoColorize; +use secrecy::SecretString; +use winget_types::{ + LanguageTag, ManifestType, ManifestVersion, PackageIdentifier, PackageVersion, + locale::{ + Author, Copyright, Description, License, LocaleManifest, PackageName, Publisher, + ShortDescription, Tag, + }, + url::{ + CopyrightUrl, DecodedUrl, LicenseUrl, PackageUrl, PublisherSupportUrl, PublisherUrl, + ReleaseNotesUrl, + }, +}; + +use crate::{ + commands::utils::{SPINNER_TICK_RATE, SubmitOption, write_changes_to_dir}, + github::{WINGET_PKGS_FULL_NAME, client::GitHub, utils::PackagePath}, + manifests::{Manifests, build_manifest_string}, + prompts::{ + list::list_prompt_with_help, + text::{optional_prompt_with_help, required_prompt}, + }, + token::TokenManager, +}; + +/// Create a new locale manifest for an existing package version +#[expect(clippy::struct_excessive_bools, reason = "CLI flags")] +#[derive(Parser)] +#[clap(visible_alias = "add-locale")] +pub struct NewLocale { + /// The package's unique identifier + #[arg()] + package_identifier: Option, + + /// The package locale to create + #[arg()] + package_locale: Option, + + /// The package's version + #[arg(short = 'v', long = "version")] + package_version: Option, + + #[arg(long)] + publisher: Option, + + #[arg(long, value_hint = clap::ValueHint::Url)] + publisher_url: Option, + + #[arg(long, value_hint = clap::ValueHint::Url)] + publisher_support_url: Option, + + #[arg(long)] + author: Option, + + #[arg(long)] + package_name: Option, + + #[arg(long, value_hint = clap::ValueHint::Url)] + package_url: Option, + + #[arg(long)] + license: Option, + + #[arg(long, value_hint = clap::ValueHint::Url)] + license_url: Option, + + #[arg(long)] + copyright: Option, + + #[arg(long, value_hint = clap::ValueHint::Url)] + copyright_url: Option, + + #[arg(long)] + short_description: Option, + + #[arg(long)] + description: Option, + + #[arg(long = "tag", value_name = "TAG")] + tags: Vec, + + #[arg(long, value_hint = clap::ValueHint::Url)] + release_notes_url: Option, + + /// List of issues that adding this locale would resolve + #[arg(long)] + resolves: Vec, + + /// Automatically submit a pull request + #[arg(short, long)] + submit: bool, + + /// Name of external tool that invoked Komac + #[arg(long, env = "KOMAC_CREATED_WITH")] + created_with: Option, + + /// URL to external tool that invoked Komac + #[arg(long, env = "KOMAC_CREATED_WITH_URL", value_hint = clap::ValueHint::Url)] + created_with_url: Option, + + /// Directory to output the manifest to + #[arg(short, long, env = "OUTPUT_DIRECTORY", value_hint = clap::ValueHint::DirPath)] + output: Option, + + /// Open pull request link automatically + #[arg(long, env = "OPEN_PR")] + open_pr: bool, + + /// Run without submitting + #[arg(long, env = "DRY_RUN")] + dry_run: bool, + + /// GitHub personal access token with the `public_repo` scope + #[arg(short, long, env = "GITHUB_TOKEN", hide_env_values = true)] + token: Option, +} + +impl NewLocale { + pub async fn run(mut self) -> Result<()> { + let token_manager = TokenManager::handle(self.token.take()).await?; + let github = GitHub::new(&token_manager)?; + + let package_identifier = required_prompt(self.package_identifier.take(), None::<&str>)?; + let versions = github.get_versions(&package_identifier).await?; + let latest_version = versions.last().unwrap_or_else(|| unreachable!()); + + println!("Latest version of {package_identifier}: {latest_version}"); + + let package_version = + required_prompt(self.package_version.take(), Some(latest_version.as_str()))?; + if !versions.contains(&package_version) { + if let Some(closest) = package_version.closest(&versions) { + bail!( + "{} version {} does not exist in {WINGET_PKGS_FULL_NAME}. The closest version is {closest}", + package_identifier, + package_version, + ); + } + + bail!( + "{} version {} does not exist in {WINGET_PKGS_FULL_NAME}", + package_identifier, + package_version, + ); + } + + let package_locale = required_prompt(self.package_locale.take(), None::<&str>)?; + + let mut manifests = github + .get_manifests(&package_identifier, &package_version) + .await?; + + validate_new_locale(&manifests, &package_locale)?; + + let locale_manifest = self.create_locale_manifest( + &manifests, + package_identifier.clone(), + package_version.clone(), + package_locale, + )?; + manifests.locales.push(locale_manifest); + + let package_path = PackagePath::new(&package_identifier, Some(&package_version), None); + let mut changes = new_locale_changes( + &package_identifier, + &manifests, + &package_path, + self.created_with.as_deref(), + )?; + + let submit_option = SubmitOption::prompt( + &mut changes, + &package_identifier, + &package_version, + self.submit, + self.dry_run, + )?; + + if let Some(output) = self + .output + .as_ref() + .map(|out| out.join(package_path.as_str())) + { + write_changes_to_dir(&changes, output.as_path()).await?; + println!( + "{} written locale manifest to {output}", + "Successfully".green() + ); + } + + if submit_option.is_exit() { + return Ok(()); + } + + let pr_progress = ProgressBar::new_spinner().with_message(format!( + "Creating a pull request for {package_identifier} {package_version}" + )); + pr_progress.enable_steady_tick(SPINNER_TICK_RATE); + + let pull_request = github + .add_version() + .identifier(&package_identifier) + .version(&package_version) + .versions(&versions) + .changes(changes) + .issue_resolves(&self.resolves) + .maybe_created_with(self.created_with.as_deref()) + .maybe_created_with_url(self.created_with_url.as_ref()) + .send() + .await?; + + pr_progress.finish_and_clear(); + + pull_request.print_success(); + + if self.open_pr { + open::that(pull_request.url().as_str())?; + } + + Ok(()) + } + + fn create_locale_manifest( + &mut self, + manifests: &Manifests, + package_identifier: PackageIdentifier, + package_version: PackageVersion, + package_locale: LanguageTag, + ) -> Result { + let default_locale = &manifests.default_locale; + + Ok(LocaleManifest { + package_identifier, + package_version, + package_locale, + publisher: optional_prompt_with_help( + self.publisher.take(), + default_locale_help(Some(&default_locale.publisher)), + )?, + publisher_url: optional_prompt_with_help( + self.publisher_url.take(), + default_locale_help(default_locale.publisher_url.as_ref()), + )?, + publisher_support_url: optional_prompt_with_help( + self.publisher_support_url.take(), + default_locale_help(default_locale.publisher_support_url.as_ref()), + )?, + privacy_url: None, + author: optional_prompt_with_help( + self.author.take(), + default_locale_help(default_locale.author.as_ref()), + )?, + package_name: optional_prompt_with_help( + self.package_name.take(), + default_locale_help(Some(&default_locale.package_name)), + )?, + package_url: optional_prompt_with_help( + self.package_url.take(), + default_locale_help(default_locale.package_url.as_ref()), + )?, + license: optional_prompt_with_help( + self.license.take(), + default_locale_help(Some(&default_locale.license)), + )?, + license_url: optional_prompt_with_help( + self.license_url.take(), + default_locale_help(default_locale.license_url.as_ref()), + )?, + copyright: optional_prompt_with_help( + self.copyright.take(), + default_locale_help(default_locale.copyright.as_ref()), + )?, + copyright_url: optional_prompt_with_help( + self.copyright_url.take(), + default_locale_help(default_locale.copyright_url.as_ref()), + )?, + short_description: optional_prompt_with_help( + self.short_description.take(), + default_locale_help(Some(&default_locale.short_description)), + )?, + description: optional_prompt_with_help( + self.description.take(), + default_locale_help(default_locale.description.as_ref()), + )?, + tags: if self.tags.is_empty() { + list_prompt_with_help::(default_locale_tags_help(&default_locale.tags))? + } else { + mem::take(&mut self.tags) + .into_iter() + .collect::>() + }, + agreements: BTreeSet::new(), + release_notes: None, + release_notes_url: optional_prompt_with_help( + self.release_notes_url.take(), + default_locale_help(default_locale.release_notes_url.as_ref()), + )?, + purchase_url: None, + installation_notes: None, + documentations: BTreeSet::new(), + icons: BTreeSet::new(), + manifest_type: ManifestType::Locale, + manifest_version: ManifestVersion::default(), + }) + } +} + +fn default_locale_help(value: Option) -> Option +where + T: AsRef, +{ + value.map(|value| format!("Default locale: {}", value.as_ref())) +} + +fn default_locale_tags_help(tags: &BTreeSet) -> String { + if tags.is_empty() { + String::from("Default locale: (none)") + } else { + format!( + "Default locale: {}", + tags.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + ) + } +} + +fn validate_new_locale(manifests: &Manifests, package_locale: &LanguageTag) -> Result<()> { + if package_locale == &manifests.version.default_locale { + bail!("{package_locale} is already the default locale for {manifests}"); + } + + if manifests + .locales + .iter() + .any(|locale| &locale.package_locale == package_locale) + { + bail!("{package_locale} already exists for {manifests}"); + } + + Ok(()) +} + +fn new_locale_changes( + package_identifier: &PackageIdentifier, + manifests: &Manifests, + package_path: &PackagePath, + created_with: Option<&str>, +) -> Result> { + let locale_manifest = manifests + .locales + .last() + .unwrap_or_else(|| unreachable!("new locale should have been pushed")); + + Ok(vec![( + format!( + "{package_path}/{package_identifier}.locale.{}.yaml", + locale_manifest.package_locale + ), + build_manifest_string(locale_manifest, created_with)?, + )]) +} diff --git a/src/main.rs b/src/main.rs index 0779bc940..20c96e253 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use crate::{ cleanup::Cleanup, complete::Complete, list_versions::ListVersions, + new_locale::NewLocale, new_version::NewVersion, remove_dead_versions::RemoveDeadVersions, remove_version::RemoveVersion, @@ -50,6 +51,7 @@ async fn main() -> Result<()> { match Cli::parse().command { Commands::New(new_version) => new_version.run().await, + Commands::NewLocale(new_locale) => new_locale.run().await, Commands::Update(update_version) => update_version.run().await, Commands::Cleanup(cleanup) => cleanup.run().await, Commands::Remove(remove_version) => remove_version.run().await, @@ -108,6 +110,7 @@ struct Cli { #[derive(Subcommand)] enum Commands { New(Box), // Comparatively large so boxed to store on the heap + NewLocale(Box), // Comparatively large so boxed to store on the heap Update(Box), // Comparatively large so boxed to store on the heap Remove(RemoveVersion), Cleanup(Cleanup), diff --git a/src/prompts/list.rs b/src/prompts/list.rs index a353f8eb2..45fab180f 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -48,10 +48,19 @@ pub fn list_prompt() -> color_eyre::Result> where T: FromStr + ListPrompt + Ord, ::Err: Display, +{ + list_prompt_with_help::(T::HELP_MESSAGE) +} + +pub fn list_prompt_with_help(help_message: U) -> color_eyre::Result> +where + T: FromStr + ListPrompt + Ord, + ::Err: Display, + U: AsRef, { const DELIMITERS: [char; 2] = [' ', ',']; let items = Text::new(&format!("{}:", T::PLURAL_NAME)) - .with_help_message(T::HELP_MESSAGE) + .with_help_message(help_message.as_ref()) .with_validator(|input: &str| { let items = input .split(|char| DELIMITERS.contains(&char)) diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 2459c0b12..ae26061c2 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -99,6 +99,32 @@ where T: FromStr + TextPrompt, ::Err: Display + Debug + Sync + Send + 'static, U: AsRef, +{ + let default = default.as_ref().map(U::as_ref); + optional_prompt_inner(parameter, default, None) +} + +pub fn optional_prompt_with_help( + parameter: Option, + help_message: Option, +) -> InquireResult> +where + T: FromStr + TextPrompt, + ::Err: Display + Debug + Sync + Send + 'static, + U: AsRef, +{ + let help_message = help_message.as_ref().map(U::as_ref); + optional_prompt_inner(parameter, None, help_message) +} + +fn optional_prompt_inner( + parameter: Option, + default: Option<&str>, + help_message: Option<&str>, +) -> InquireResult> +where + T: FromStr + TextPrompt, + ::Err: Display + Debug + Sync + Send + 'static, { if let Some(value) = parameter { Ok(Some(value)) @@ -114,13 +140,12 @@ where } } }); - if let Some(help_message) = T::HELP_MESSAGE { + if let Some(help_message) = help_message.or(T::HELP_MESSAGE) { prompt = prompt.with_help_message(help_message); } if let Some(placeholder) = T::PLACEHOLDER { prompt = prompt.with_placeholder(placeholder); } - let default = default.as_ref().map(U::as_ref); if let Some(default) = default { prompt = prompt.with_default(default); } From d9c54ee7f48555505c0dd496d0e212bc73793f61 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Thu, 11 Jun 2026 03:04:50 -0400 Subject: [PATCH 23/31] Do not set scope for privileges_required = None --- src/analysis/installers/inno/mod.rs | 30 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/analysis/installers/inno/mod.rs b/src/analysis/installers/inno/mod.rs index c5d7b2e0f..7fc72716b 100644 --- a/src/analysis/installers/inno/mod.rs +++ b/src/analysis/installers/inno/mod.rs @@ -29,12 +29,13 @@ impl Installers for Inno { fn installers(&self) -> Vec { let scope = self.header.privileges_required().to_scope(); - let install_dir = self - .header - .default_dir_name() - .map(str::to_owned) - .map(|install_dir| to_relative_install_dir(install_dir, scope)) - .filter(|dir| !dir.contains(['{', '}'])); + let install_dir = scope.and_then(|scope| { + self.header + .default_dir_name() + .map(str::to_owned) + .map(|install_dir| to_relative_install_dir(install_dir, scope)) + .filter(|dir| !dir.contains(['{', '}'])) + }); let product_code = self .header @@ -87,7 +88,7 @@ impl Installers for Inno { }), architecture: WingetArchitecture::from_inno(self.header.architectures_allowed()), r#type: Some(InstallerType::Inno), - scope: Some(scope), + scope, url: DecodedUrl::default(), sha_256: Sha256String::default(), unsupported_os_architectures: UnsupportedOSArchitecture::from_inno( @@ -126,6 +127,10 @@ impl Installers for Inno { { vec![installer] } else { + let Some(scope) = scope else { + return vec![installer]; + }; + let has_scope_switch = self .header .privileges_required_overrides_allowed() @@ -175,7 +180,7 @@ trait PrivilegeLevelExt { overrides: PrivilegesRequiredOverrides, ) -> Option; - fn to_scope(&self) -> Scope; + fn to_scope(&self) -> Option; } impl PrivilegeLevelExt for inno::header::PrivilegeLevel { @@ -184,16 +189,17 @@ impl PrivilegeLevelExt for inno::header::PrivilegeLevel { overrides: PrivilegesRequiredOverrides, ) -> Option { match self { - Self::Admin | Self::PowerUser => Some(ElevationRequirement::ElevatesSelf), + Self::None | Self::PowerUser | Self::Admin => Some(ElevationRequirement::ElevatesSelf), _ if !overrides.is_empty() => Some(ElevationRequirement::ElevatesSelf), _ => None, } } - fn to_scope(&self) -> Scope { + fn to_scope(&self) -> Option { match self { - Self::Lowest | Self::None => Scope::User, - Self::Admin | Self::PowerUser => Scope::Machine, + Self::Lowest => Some(Scope::User), + Self::PowerUser | Self::Admin => Some(Scope::Machine), + Self::None => None, } } } From b5011b2b635ff51a0341835df4d9e6307d00dbf5 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Sun, 14 Jun 2026 14:52:06 -0400 Subject: [PATCH 24/31] Fixed NSIS StrCpy empty assignments --- src/analysis/installers/nsis/entry/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/analysis/installers/nsis/entry/mod.rs b/src/analysis/installers/nsis/entry/mod.rs index 3008c6007..400a3a6c8 100644 --- a/src/analysis/installers/nsis/entry/mod.rs +++ b/src/analysis/installers/nsis/entry/mod.rs @@ -788,10 +788,10 @@ impl Entry { } } - if !source.is_empty() { - state - .variables - .insert(variable.get().unsigned_abs() as usize, result); + if result.is_empty() { + state.variables.remove(&index); + } else { + state.variables.insert(index, result); } } Self::StrCmp { From d834afaf704f6d0b044b7450adc811e61b9be16c Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Sat, 20 Jun 2026 13:40:12 -0400 Subject: [PATCH 25/31] Update package family name --- src/analysis/installers/msix_family/bundle/mod.rs | 8 +++----- src/analysis/installers/msix_family/mod.rs | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/analysis/installers/msix_family/bundle/mod.rs b/src/analysis/installers/msix_family/bundle/mod.rs index 0d37ee573..744bac91b 100644 --- a/src/analysis/installers/msix_family/bundle/mod.rs +++ b/src/analysis/installers/msix_family/bundle/mod.rs @@ -24,7 +24,7 @@ use crate::analysis::Installers; pub struct MsixBundle { pub signature_sha_256: Sha256String, - pub package_family_name: PackageFamilyName<'static>, + pub package_family_name: PackageFamilyName, pub msix_files: Vec, } @@ -59,10 +59,8 @@ impl MsixBundle { } } - let package_family_name = PackageFamilyName::new( - bundle.identity.name().to_owned(), - bundle.identity.publisher(), - ); + let package_family_name = + PackageFamilyName::new(bundle.identity.name(), bundle.identity.publisher()); Ok(Self { msix_files: bundle diff --git a/src/analysis/installers/msix_family/mod.rs b/src/analysis/installers/msix_family/mod.rs index 06b126776..7f23ae6c0 100644 --- a/src/analysis/installers/msix_family/mod.rs +++ b/src/analysis/installers/msix_family/mod.rs @@ -218,7 +218,7 @@ impl Installers for Msix { .supported_file_types .clone(), package_family_name: Some(PackageFamilyName::new( - self.manifest.identity.name.clone(), + &self.manifest.identity.name, &self.manifest.identity.publisher, )), capabilities: self.manifest.capabilities.unrestricted.clone(), From 5c253fe9e25239f3f4d522c3dd8c0d851f14c060 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Sat, 20 Jun 2026 13:49:04 -0400 Subject: [PATCH 26/31] Add comments to custom exe types --- src/analysis/installers/advanced/mod.rs | 12 +++++------- src/analysis/installers/squirrel/mod.rs | 6 +++++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/analysis/installers/advanced/mod.rs b/src/analysis/installers/advanced/mod.rs index 28aed50e1..d3bafcc5a 100644 --- a/src/analysis/installers/advanced/mod.rs +++ b/src/analysis/installers/advanced/mod.rs @@ -15,7 +15,7 @@ use sevenz_rust2::{ArchiveReader, Password}; use thiserror::Error; use tracing::{debug, warn}; use winget_types::installer::{ - AppsAndFeaturesEntry, ExpectedReturnCodes, Installer, InstallerReturnCode, InstallerSwitches, + AppsAndFeaturesEntry, ExpectedReturnCode, Installer, InstallerReturnCode, InstallerSwitches, InstallerType, ReturnResponse, }; use zerocopy::IntoBytes; @@ -110,7 +110,7 @@ impl Installers for AdvancedInstaller { .iter() .map(|msi| { let mut installer = msi.installers().into_iter().next().unwrap_or_default(); - installer.r#type = Some(InstallerType::Exe); + installer.r#type = Some(InstallerType::AdvancedInstaller); // https://www.advancedinstaller.com/user-guide/exe-setup-file.html#proprietary-command-line-switches-for-the-exe-setup installer.switches = InstallerSwitches::builder() @@ -158,7 +158,7 @@ impl Installers for AdvancedInstaller { } } -fn expected_return_codes() -> BTreeSet { +fn expected_return_codes() -> BTreeSet { use ReturnResponse::{ AlreadyInstalled, BlockedByPolicy, CancelledByUser, ContactSupport, InstallInProgress, InvalidParameter, RebootInitiated, RebootRequiredToFinish, SystemNotSupported, @@ -187,10 +187,8 @@ fn expected_return_codes() -> BTreeSet { (3010, RebootRequiredToFinish), ] .into_iter() - .map(|(code, response)| ExpectedReturnCodes { - installer_return_code: InstallerReturnCode::new(code), - return_response: response, - return_response_url: None, + .map(|(code, response)| { + ExpectedReturnCode::new(InstallerReturnCode::new(code).unwrap(), response) }) .collect() } diff --git a/src/analysis/installers/squirrel/mod.rs b/src/analysis/installers/squirrel/mod.rs index c4c2c3add..84bcc3371 100644 --- a/src/analysis/installers/squirrel/mod.rs +++ b/src/analysis/installers/squirrel/mod.rs @@ -154,7 +154,11 @@ impl Installers for Squirrel { vec![Installer { architecture: self.architecture, - r#type: Some(InstallerType::Exe), + r#type: Some(if self.is_velopack { + InstallerType::Velopack + } else { + InstallerType::Squirrel + }), scope: Some(Scope::User), product_code: Some(nuspec.id().to_owned()), apps_and_features_entries: AppsAndFeaturesEntry::builder() From e90f28173cbc170e04ccfd11b666fec37e43a6e9 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Tue, 23 Dec 2025 15:49:30 -0500 Subject: [PATCH 27/31] Use ValidFileExtensions enum --- Cargo.lock | 2 +- src/analysis/analyzer.rs | 31 +++++++++++----------- src/analysis/extensions.rs | 7 ----- src/analysis/installers/msix_family/mod.rs | 10 +++---- src/analysis/installers/nsis/mod.rs | 7 +++-- src/analysis/installers/zip.rs | 19 +++++-------- src/analysis/mod.rs | 1 - src/download/mod.rs | 6 ++--- src/match_installers.rs | 16 ++++------- 9 files changed, 38 insertions(+), 61 deletions(-) delete mode 100644 src/analysis/extensions.rs diff --git a/Cargo.lock b/Cargo.lock index 869eefce1..1abf63df3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5729,7 +5729,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winget-types" version = "0.4.3" -source = "git+https://github.com/UnownPlain/winget-types.git?branch=anthelion#8c7035e549725897dbf05a43057ac48398ddd9e5" +source = "git+https://github.com/UnownPlain/winget-types.git?branch=anthelion#9009288f5ef6df9c939ad8bdef62b9c939572ce4" dependencies = [ "base16ct", "bitflags 2.13.0", diff --git a/src/analysis/analyzer.rs b/src/analysis/analyzer.rs index 610590d5c..4f7606337 100644 --- a/src/analysis/analyzer.rs +++ b/src/analysis/analyzer.rs @@ -4,16 +4,14 @@ use std::{ }; use camino::Utf8Path; -use color_eyre::eyre::{Result, bail}; +use color_eyre::eyre::Result; use winget_types::{ installer::Installer, locale::{Copyright, PackageName, Publisher}, + utils::ValidFileExtensions, }; -use super::{ - PeInfo, - extensions::{APPX, APPX_BUNDLE, EXE, MSI, MSIX, MSIX_BUNDLE, ZIP}, -}; +use super::PeInfo; use crate::analysis::{ Installers, installers::{ @@ -38,16 +36,17 @@ pub struct Analyzer<'reader, R: Read + Seek> { impl<'reader, R: Read + Seek> Analyzer<'reader, R> { pub fn new(reader: &'reader mut R, file_name: &str) -> Result { - let extension = Utf8Path::new(file_name) - .extension() - .unwrap_or_default() - .to_ascii_lowercase(); + let extension = ValidFileExtensions::from_path(Utf8Path::new(file_name))?; - let installers = match extension.as_str() { - MSI => Msi::new(reader)?.installers(), - MSIX | APPX => Msix::new(reader)?.installers(), - MSIX_BUNDLE | APPX_BUNDLE => MsixBundle::new(reader)?.installers(), - ZIP => { + let installers = match extension { + ValidFileExtensions::Msi => Msi::new(reader)?.installers(), + ValidFileExtensions::Msix | ValidFileExtensions::Appx => { + Msix::new(reader)?.installers() + } + ValidFileExtensions::MsixBundle | ValidFileExtensions::AppxBundle => { + MsixBundle::new(reader)?.installers() + } + ValidFileExtensions::Zip => { let mut scoped_zip = Zip::new(reader)?; let installers = mem::take(&mut scoped_zip.installers); return Ok(Self { @@ -56,7 +55,7 @@ impl<'reader, R: Read + Seek> Analyzer<'reader, R> { ..Self::default() }); } - EXE => { + ValidFileExtensions::Exe => { let mut exe = Exe::new(reader)?; return Ok(Self { installers: exe.installers(), @@ -78,7 +77,7 @@ impl<'reader, R: Read + Seek> Analyzer<'reader, R> { ..Self::default() }); } - _ => bail!(r#"Unsupported file extension: "{extension}""#), + _ => unreachable!(), }; Ok(Self { installers, diff --git a/src/analysis/extensions.rs b/src/analysis/extensions.rs deleted file mode 100644 index 1770c97c2..000000000 --- a/src/analysis/extensions.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub const EXE: &str = "exe"; -pub const MSI: &str = "msi"; -pub const MSIX: &str = "msix"; -pub const APPX: &str = "appx"; -pub const MSIX_BUNDLE: &str = "msixbundle"; -pub const APPX_BUNDLE: &str = "appxbundle"; -pub const ZIP: &str = "zip"; diff --git a/src/analysis/installers/msix_family/mod.rs b/src/analysis/installers/msix_family/mod.rs index 7f23ae6c0..87be4d808 100644 --- a/src/analysis/installers/msix_family/mod.rs +++ b/src/analysis/installers/msix_family/mod.rs @@ -15,14 +15,12 @@ use winget_types::{ Installer, InstallerType, MinimumOSVersion, PackageFamilyName, Platform, RestrictedCapability, UpgradeBehavior, }, + utils::ValidFileExtensions, }; use zip::ZipArchive; use super::msix_family::utils::{get_install_location, hash_signature, read_manifest}; -use crate::{ - analysis::{Installers, extensions::MSIX}, - traits::AsciiExt, -}; +use crate::{analysis::Installers, traits::AsciiExt}; pub struct Msix { appx_manifest: String, @@ -182,7 +180,9 @@ impl Installers for Msix { .target_device_family .iter() .all(|target_device_family| target_device_family.min_version < MSIX_MIN_VERSION) - && !self.appx_manifest.contains_ignore_ascii_case(MSIX); + && !self + .appx_manifest + .contains_ignore_ascii_case(ValidFileExtensions::Msix.as_str()); vec![Installer { platform: self diff --git a/src/analysis/installers/nsis/mod.rs b/src/analysis/installers/nsis/mod.rs index 86c955ee1..cfbb6c1cd 100644 --- a/src/analysis/installers/nsis/mod.rs +++ b/src/analysis/installers/nsis/mod.rs @@ -34,11 +34,11 @@ use winget_types::{ AppsAndFeaturesEntries, AppsAndFeaturesEntry, Architecture, InstallationMetadata, Installer, InstallerType, Scope, }, + utils::ValidFileExtensions, }; use zerocopy::LE; use super::{ - super::extensions::EXE, nsis::{ entry::{Entry, EntryError}, file_system::FsEntry, @@ -176,9 +176,8 @@ impl Nsis { .file_system .files() .filter(|file| { - Utf8Path::new(file.name()) - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case(EXE)) + ValidFileExtensions::from_path(Utf8Path::new(file.name())) + .is_ok_and(|extension| extension == ValidFileExtensions::Exe) }) .min_by_key(|file| levenshtein(file.name(), &app_name)) .and_then(|file| { diff --git a/src/analysis/installers/zip.rs b/src/analysis/installers/zip.rs index 7589b6144..6748f4405 100644 --- a/src/analysis/installers/zip.rs +++ b/src/analysis/installers/zip.rs @@ -10,17 +10,15 @@ use color_eyre::eyre::Result; use inquire::{CustomType, MultiSelect, min_length}; use regex::Regex; use tracing::debug; -use winget_types::installer::{ - Installer, InstallerType, NestedInstallerFiles, PortableCommandAlias, +use winget_types::{ + installer::{Installer, InstallerType, NestedInstallerFiles, PortableCommandAlias}, + utils::ValidFileExtensions, }; use zip::ZipArchive; use super::super::Analyzer; use crate::{prompts::handle_inquire_error, traits::path::LowercaseExtension}; -const VALID_NESTED_FILE_EXTENSIONS: [&str; 6] = - ["msix", "msi", "appx", "exe", "msixbundle", "appxbundle"]; - const IGNORABLE_FOLDERS: [&str; 2] = ["__MACOSX", "resources"]; enum NestedFileMatch { @@ -116,11 +114,8 @@ impl Zip { .file_names() .map(Utf8Path::new) .filter(|file_name| { - VALID_NESTED_FILE_EXTENSIONS.iter().any(|file_extension| { - file_name - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case(file_extension)) - }) + ValidFileExtensions::from_path(file_name) + .is_ok_and(ValidFileExtensions::is_valid_nested_installer) }) .filter(|file_name| { // Ignore folders that the main executable is unlikely to be in @@ -135,7 +130,7 @@ impl Zip { debug!(?possible_installer_files); - let installer_type_counts = VALID_NESTED_FILE_EXTENSIONS + let installer_type_counts = ValidFileExtensions::ALL .iter() .map(|file_extension| { ( @@ -144,7 +139,7 @@ impl Zip { .iter() .filter(|file_name| { file_name.extension().is_some_and(|extension| { - extension.eq_ignore_ascii_case(file_extension) + extension.eq_ignore_ascii_case(file_extension.as_str()) }) }) .count(), diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs index 18a0887c5..0f4ba0469 100644 --- a/src/analysis/mod.rs +++ b/src/analysis/mod.rs @@ -1,5 +1,4 @@ mod analyzer; -mod extensions; pub mod installers; mod pe_info; mod r#trait; diff --git a/src/download/mod.rs b/src/download/mod.rs index f9afe070b..5fc044311 100644 --- a/src/download/mod.rs +++ b/src/download/mod.rs @@ -9,7 +9,7 @@ pub use downloader::Downloader; pub use file::DownloadedFile; use reqwest::{Client, ClientBuilder, Response, header::HeaderValue, redirect::Policy}; use uuid::Uuid; -use winget_types::installer::VALID_FILE_EXTENSIONS; +use winget_types::utils::ValidFileExtensions; use crate::{github::GITHUB_HOST, manifests::Url}; @@ -83,9 +83,7 @@ impl Download { .path_segments() .and_then(|mut segments| segments.next_back()) .filter(|last_segment| { - Utf8Path::new(last_segment) - .extension() - .is_some_and(|extension| VALID_FILE_EXTENSIONS.contains(&extension)) + ValidFileExtensions::from_path(Utf8Path::new(last_segment)).is_ok() }) .or_else(|| { final_url diff --git a/src/match_installers.rs b/src/match_installers.rs index 566f60efc..8bff69186 100644 --- a/src/match_installers.rs +++ b/src/match_installers.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; -use camino::Utf8Path; -use winget_types::installer::{ - Architecture, ElevationRequirement, Installer, Scope, VALID_FILE_EXTENSIONS, +use winget_types::{ + installer::{Architecture, ElevationRequirement, Installer, Scope}, + utils::ValidFileExtensions, }; fn locale_score(previous_installer: &Installer, new_installer: &Installer) -> f64 { @@ -142,14 +142,8 @@ pub fn match_installers( new_installer.url.as_str(), ); - let new_extension = Utf8Path::new(new_installer.url.as_str()) - .extension() - .filter(|extension| VALID_FILE_EXTENSIONS.contains(extension)) - .unwrap_or_default(); - let previous_extension = Utf8Path::new(previous_installer.url.as_str()) - .extension() - .filter(|extension| VALID_FILE_EXTENSIONS.contains(extension)) - .unwrap_or_default(); + let new_extension = ValidFileExtensions::from_url(&new_installer.url); + let previous_extension = ValidFileExtensions::from_url(&previous_installer.url); if new_extension != previous_extension { score = 0.0; } From 54d005c5dd5e4e84c38e97e1bd915dc6e368275e Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Tue, 23 Dec 2025 15:53:47 -0500 Subject: [PATCH 28/31] Add font support --- src/analysis/analyzer.rs | 8 +++-- src/analysis/installers/font.rs | 24 +++++++++++++ src/analysis/installers/mod.rs | 2 ++ src/anthelion/update_version.rs | 6 ++-- src/commands/list_versions.rs | 8 ++++- src/commands/new_locale.rs | 7 ++-- src/commands/new_version.rs | 51 ++++++++++++++++++---------- src/commands/remove_dead_versions.rs | 10 ++++-- src/commands/remove_version.rs | 10 ++++-- src/commands/show_version.rs | 9 ++++- src/commands/submit.rs | 17 ++++++++-- src/commands/update_version.rs | 37 +++++++++----------- src/commands/utils/mod.rs | 29 ++++++++++++++-- src/github/client.rs | 12 ++++--- src/github/rest/tree/mod.rs | 37 ++++++++++++++++++-- src/github/utils/package_path.rs | 16 +++++++-- src/traits/mod.rs | 23 ++++++++++++- 17 files changed, 236 insertions(+), 70 deletions(-) create mode 100644 src/analysis/installers/font.rs diff --git a/src/analysis/analyzer.rs b/src/analysis/analyzer.rs index 4f7606337..3ea105195 100644 --- a/src/analysis/analyzer.rs +++ b/src/analysis/analyzer.rs @@ -15,7 +15,7 @@ use super::PeInfo; use crate::analysis::{ Installers, installers::{ - Exe, Msi, Zip, + Exe, Font, Msi, Zip, msix_family::{Msix, bundle::MsixBundle}, }, }; @@ -77,7 +77,11 @@ impl<'reader, R: Read + Seek> Analyzer<'reader, R> { ..Self::default() }); } - _ => unreachable!(), + ValidFileExtensions::Fnt + | ValidFileExtensions::Otc + | ValidFileExtensions::Otf + | ValidFileExtensions::Ttc + | ValidFileExtensions::Ttf => Font::new(reader)?.installers(), }; Ok(Self { installers, diff --git a/src/analysis/installers/font.rs b/src/analysis/installers/font.rs new file mode 100644 index 000000000..befb05e49 --- /dev/null +++ b/src/analysis/installers/font.rs @@ -0,0 +1,24 @@ +use std::io::{Read, Seek}; + +use color_eyre::eyre::Result; +use winget_types::installer::{Architecture, Installer, InstallerType}; + +use super::super::Installers; + +pub struct Font; + +impl Font { + pub fn new(_reader: R) -> Result { + Ok(Self) + } +} + +impl Installers for Font { + fn installers(&self) -> Vec { + vec![Installer { + r#type: Some(InstallerType::Font), + architecture: Architecture::Neutral, + ..Installer::default() + }] + } +} diff --git a/src/analysis/installers/mod.rs b/src/analysis/installers/mod.rs index e9867f168..9b0371319 100644 --- a/src/analysis/installers/mod.rs +++ b/src/analysis/installers/mod.rs @@ -1,6 +1,7 @@ mod advanced; pub mod burn; mod exe; +mod font; pub mod inno; mod msi; pub mod msix_family; @@ -13,6 +14,7 @@ mod zip; pub use advanced::AdvancedInstaller; pub use burn::Burn; pub use exe::Exe; +pub use font::Font; pub use msi::Msi; pub use nsis::Nsis; pub use squirrel::Squirrel; diff --git a/src/anthelion/update_version.rs b/src/anthelion/update_version.rs index 3fbbe2385..1b17256b7 100644 --- a/src/anthelion/update_version.rs +++ b/src/anthelion/update_version.rs @@ -124,8 +124,6 @@ pub async fn update_version(options: UpdateVersionOptions) -> napi::Result napi::Result napi::Result, @@ -48,7 +52,9 @@ impl ListVersions { let token_manager = TokenManager::handle(self.token).await?; let github = GitHub::new(token_manager)?; - let versions = github.get_versions(&self.package_identifier).await?; + let (versions, _) = github + .get_versions(&self.package_identifier, self.font.then_some(true)) + .await?; let mut stdout_lock = anstream::stdout().lock(); match self.output_type { diff --git a/src/commands/new_locale.rs b/src/commands/new_locale.rs index 110035edf..032e5de13 100644 --- a/src/commands/new_locale.rs +++ b/src/commands/new_locale.rs @@ -128,7 +128,7 @@ impl NewLocale { let github = GitHub::new(&token_manager)?; let package_identifier = required_prompt(self.package_identifier.take(), None::<&str>)?; - let versions = github.get_versions(&package_identifier).await?; + let (versions, font) = github.get_versions(&package_identifier, None).await?; let latest_version = versions.last().unwrap_or_else(|| unreachable!()); println!("Latest version of {package_identifier}: {latest_version}"); @@ -154,7 +154,7 @@ impl NewLocale { let package_locale = required_prompt(self.package_locale.take(), None::<&str>)?; let mut manifests = github - .get_manifests(&package_identifier, &package_version) + .get_manifests(&package_identifier, &package_version, font) .await?; validate_new_locale(&manifests, &package_locale)?; @@ -167,7 +167,8 @@ impl NewLocale { )?; manifests.locales.push(locale_manifest); - let package_path = PackagePath::new(&package_identifier, Some(&package_version), None); + let package_path = + PackagePath::new(&package_identifier, Some(&package_version), None, font); let mut changes = new_locale_changes( &package_identifier, &manifests, diff --git a/src/commands/new_version.rs b/src/commands/new_version.rs index 0f2175118..30fd7fc9b 100644 --- a/src/commands/new_version.rs +++ b/src/commands/new_version.rs @@ -33,7 +33,8 @@ use winget_types::{ use crate::{ commands::utils::{ - SPINNER_TICK_RATE, SubmitOption, should_abort_for_existing_pr, write_changes_to_dir, + SPINNER_TICK_RATE, SubmitOption, check_package_type, should_abort_for_existing_pr, + write_changes_to_dir, }, download::Downloader, download_file::process_files, @@ -149,6 +150,10 @@ pub struct NewVersion { #[arg(long, env)] skip_pr_check: bool, + /// Look for the package under fonts instead of probing manifests first + #[arg(long)] + font: bool, + /// GitHub personal access token with the `public_repo` scope #[arg(short, long, env = "GITHUB_TOKEN", hide_env_values = true)] token: Option, @@ -161,7 +166,11 @@ impl NewVersion { let package_identifier = required_prompt(self.package_identifier, None::<&str>)?; - let versions = github.get_versions(&package_identifier).await.ok(); + let (versions, font) = github + .get_versions(&package_identifier, self.font.then_some(true)) + .await + .ok() + .map_or((None, false), |(versions, font)| (Some(versions), font)); let latest_version = versions.as_ref().and_then(BTreeSet::last); @@ -170,14 +179,12 @@ impl NewVersion { } let manifests = - latest_version.map(|version| github.get_manifests(&package_identifier, version)); + latest_version.map(|version| github.get_manifests(&package_identifier, version, font)); let package_version = required_prompt(self.package_version, None::<&str>)?; let existing_pr = github .get_existing_pull_request(&package_identifier, &package_version, false) - let existing_pr = github - .get_existing_pull_request(&package_identifier, &package_version) .await?; if should_abort_for_existing_pr( @@ -286,30 +293,37 @@ impl NewVersion { let mut installer_manifest = InstallerManifest { package_identifier: package_identifier.clone(), package_version: package_version.clone(), - install_modes: if installers + installers, + manifest_type: ManifestType::Installer, + ..InstallerManifest::default() + }; + + let is_font = check_package_type(&installer_manifest)?; + + if !is_font { + installer_manifest.install_modes = if installer_manifest + .installers .iter() .any(|installer| installer.r#type == Some(InstallerType::Inno)) { InstallModes::all() } else { check_prompt::()? - }, - success_codes: list_prompt::()?, - upgrade_behavior: Some(radio_prompt::()?), - commands: list_prompt::()?, - protocols: list_prompt::()?, - file_extensions: if installers + }; + installer_manifest.success_codes = list_prompt::()?; + installer_manifest.upgrade_behavior = Some(radio_prompt::()?); + installer_manifest.commands = list_prompt::()?; + installer_manifest.protocols = list_prompt::()?; + installer_manifest.file_extensions = if installer_manifest + .installers .iter() .all(|installer| installer.file_extensions.is_empty()) { list_prompt::()? } else { BTreeSet::new() - }, - installers, - manifest_type: ManifestType::Installer, - ..InstallerManifest::default() - }; + }; + } let mut github_values = match github_values.await? { Some(future) => Some(future?), @@ -432,7 +446,8 @@ impl NewVersion { version: version_manifest, }; - let package_path = PackagePath::new(&package_identifier, Some(&package_version), None); + let package_path = + PackagePath::new(&package_identifier, Some(&package_version), None, is_font); let mut changes = pr_changes() .package_identifier(&package_identifier) .manifests(&manifests) diff --git a/src/commands/remove_dead_versions.rs b/src/commands/remove_dead_versions.rs index 7a09076e6..0c216b4c7 100644 --- a/src/commands/remove_dead_versions.rs +++ b/src/commands/remove_dead_versions.rs @@ -52,6 +52,10 @@ pub struct RemoveDeadVersions { #[arg(long, hide = true, env = "CI")] auto: bool, + /// Look for the package under fonts instead of probing manifests first + #[arg(long)] + font: bool, + /// Number of versions to check concurrently #[arg(short, long, default_value_t = NonZeroUsize::new(num_cpus::get()).unwrap())] concurrent: NonZeroUsize, @@ -66,12 +70,12 @@ impl RemoveDeadVersions { let token_manager = TokenManager::handle(self.token).await?; let github = GitHub::new(token_manager)?; - let (fork, winget_pkgs, versions) = try_join!( + let (fork, winget_pkgs, (versions, font)) = try_join!( github .get_username() .and_then(|current_user| github.get_winget_pkgs().owner(current_user).send()), github.get_winget_pkgs().send(), - github.get_versions(&self.package_identifier) + github.get_versions(&self.package_identifier, self.font.then_some(true)) )?; let client = Client::builder() @@ -147,6 +151,7 @@ impl RemoveDeadVersions { .reason(&deletion_reason) .fork(&fork) .winget_pkgs(&winget_pkgs) + .font(font) .send() .await?; @@ -178,6 +183,7 @@ impl RemoveDeadVersions { package_identifier, &version, ManifestTypeWithLocale::Installer, + font, ) .await? .installers diff --git a/src/commands/remove_version.rs b/src/commands/remove_version.rs index 8b74bd494..699eeac76 100644 --- a/src/commands/remove_version.rs +++ b/src/commands/remove_version.rs @@ -51,6 +51,10 @@ pub struct RemoveVersion { #[arg(long, env = "OPEN_PR")] open_pr: bool, + /// Look for the package under fonts instead of probing manifests first + #[arg(long)] + font: bool, + /// GitHub personal access token with the `public_repo` scope #[arg(short, long, env = "GITHUB_TOKEN", hide_env_values = true)] token: Option, @@ -72,12 +76,12 @@ impl RemoveVersion { let github = GitHub::new(&token_manager)?; - let (fork, winget_pkgs, versions) = try_join!( + let (fork, winget_pkgs, (versions, font)) = try_join!( github .get_username() .and_then(|current_user| github.get_winget_pkgs().owner(current_user).send()), github.get_winget_pkgs().send(), - github.get_versions(&self.package_identifier) + github.get_versions(&self.package_identifier, self.font.then_some(true)) )?; if !versions.contains(&self.package_version) { @@ -96,7 +100,6 @@ impl RemoveVersion { let existing_pr = github .get_existing_pull_request(&self.package_identifier, &self.package_version, false) - .get_existing_pull_request(&self.package_identifier, &self.package_version) .await? .filter(|pull_request| pull_request.is_open()); @@ -138,6 +141,7 @@ impl RemoveVersion { .reason(&deletion_reason) .fork(&fork) .winget_pkgs(&winget_pkgs) + .font(font) .issue_resolves(&self.resolves) .send() .await?; diff --git a/src/commands/show_version.rs b/src/commands/show_version.rs index 8123ef8e9..fc4c9ceca 100644 --- a/src/commands/show_version.rs +++ b/src/commands/show_version.rs @@ -37,6 +37,10 @@ pub struct ShowVersion { #[arg(long)] version_manifest: bool, + /// Look for the package under fonts instead of probing manifests first + #[arg(long)] + font: bool, + /// GitHub personal access token with the `public_repo` scope #[arg(short, long, env = "GITHUB_TOKEN", hide_env_values = true)] token: Option, @@ -48,7 +52,9 @@ impl ShowVersion { let github = GitHub::new(&token_manager)?; // Get a list of all versions for the given package - let mut versions = github.get_versions(&self.package_identifier).await?; + let (mut versions, font) = github + .get_versions(&self.package_identifier, self.font.then_some(true)) + .await?; // Get the manifests for the latest or specified version let manifests = github @@ -57,6 +63,7 @@ impl ShowVersion { &self .package_version .unwrap_or_else(|| versions.pop_last().unwrap_or_else(|| unreachable!())), + font, ) .await?; diff --git a/src/commands/submit.rs b/src/commands/submit.rs index e90e6c45d..c76d5e304 100644 --- a/src/commands/submit.rs +++ b/src/commands/submit.rs @@ -13,7 +13,7 @@ use walkdir::WalkDir; use winget_types::{GenericManifest, ManifestType, ManifestVersion}; use crate::{ - commands::utils::{RateLimit, SPINNER_TICK_RATE, SubmitOption}, + commands::utils::{RateLimit, SPINNER_TICK_RATE, SubmitOption, check_package_type}, github::{ client::GitHub, utils::{PackagePath, pull_request::pr_changes}, @@ -155,7 +155,14 @@ impl Submit { locale_manifest.manifest_version = ManifestVersion::default(); } - let package_path = PackagePath::new(identifier, Some(version), None); + manifest.default_locale.manifest_version = ManifestVersion::default(); + manifest.version.manifest_version = ManifestVersion::default(); + for locale_manifest in &mut manifest.locales { + locale_manifest.manifest_version = ManifestVersion::default(); + } + + let is_font = check_package_type(&manifest.installer)?; + let package_path = PackagePath::new(identifier, Some(version), None, is_font); let mut changes = pr_changes() .package_identifier(identifier) .manifests(&manifest) @@ -174,7 +181,11 @@ impl Submit { continue; } - let versions = github.get_versions(identifier).await.ok(); + let (versions, _) = github + .get_versions(identifier, Some(is_font)) + .await + .ok() + .unzip(); rate_limit.wait().await; diff --git a/src/commands/update_version.rs b/src/commands/update_version.rs index 6f083294e..ea71b5349 100644 --- a/src/commands/update_version.rs +++ b/src/commands/update_version.rs @@ -38,7 +38,7 @@ use crate::{ match_installers::match_installers, token::TokenManager, traits::{ - LocaleExt, + InstallerManifestExt, LocaleExt, path::{LowercaseExtension, NormalizePath}, }, }; @@ -103,6 +103,10 @@ pub struct UpdateVersion { #[arg(long, env)] skip_pr_check: bool, + /// Look for the package under fonts instead of probing manifests first + #[arg(long)] + font: bool, + /// GitHub personal access token with the `public_repo` scope #[arg(short, long, env = "GITHUB_TOKEN", hide_env_values = true)] token: Option, @@ -113,9 +117,6 @@ impl UpdateVersion { let token_manager = TokenManager::handle(self.token.take()).await?; let github = GitHub::new(&token_manager)?; - let (versions, existing_pr) = try_join!( - github.get_versions(&self.package_identifier), - github.get_existing_pull_request(&self.package_identifier, &self.package_version), let ((versions, font), existing_pr) = try_join!( github.get_versions(&self.package_identifier, self.font.then_some(true)), github.get_existing_pull_request( @@ -146,7 +147,7 @@ impl UpdateVersion { let downloader = Downloader::new_with_concurrent(self.concurrent_downloads)?; let (mut manifests, mut github_values, mut files) = try_join!( github - .get_manifests(&self.package_identifier, latest_version) + .get_manifests(&self.package_identifier, latest_version, font) .map_err(Error::new), self.fetch_github_values(&github).map_err(Error::new), downloader.download(self.urls.iter().cloned()), @@ -157,21 +158,11 @@ impl UpdateVersion { .iter_mut() .flat_map(|(_url, analyzer)| mem::take(&mut analyzer.installers)) .collect::>(); - let previous_installers = mem::take(&mut manifests.installer.installers) - .into_iter() - .map(|mut installer| { - if manifests.installer.r#type.is_some() { - installer.r#type = manifests.installer.r#type; - } - if manifests.installer.nested_installer_type.is_some() { - installer.nested_installer_type = manifests.installer.nested_installer_type; - } - if manifests.installer.scope.is_some() { - installer.scope = manifests.installer.scope; - } - installer - }) + let previous_installers = manifests + .installer + .inherit_manifest_properties() .collect::>(); + manifests.installer.installers.clear(); let duplicate_urls = previous_installers .iter() @@ -262,8 +253,12 @@ impl UpdateVersion { manifests.version.update(&self.package_version); - let package_path = - PackagePath::new(&self.package_identifier, Some(&self.package_version), None); + let package_path = PackagePath::new( + &self.package_identifier, + Some(&self.package_version), + None, + font, + ); let mut changes = pr_changes() .package_identifier(&self.package_identifier) .manifests(&manifests) diff --git a/src/commands/utils/mod.rs b/src/commands/utils/mod.rs index c9d0c92ac..f78b7138f 100644 --- a/src/commands/utils/mod.rs +++ b/src/commands/utils/mod.rs @@ -7,18 +7,21 @@ use std::time::Duration; use anstream::println; use camino::Utf8Path; use chrono::Local; -use color_eyre::Result; +use color_eyre::{Result, eyre::bail}; use futures_util::{StreamExt, TryStreamExt, stream}; use inquire::error::InquireResult; use owo_colors::OwoColorize; pub use rate_limit::RateLimit; pub use submit_option::SubmitOption; use tokio::{fs, fs::File, io::AsyncWriteExt}; -use winget_types::{PackageIdentifier, PackageVersion}; +use winget_types::{ + PackageIdentifier, PackageVersion, + installer::{InstallerManifest, InstallerType, NestedInstallerType}, +}; use crate::{ commands::utils::environment::CI, github::graphql::get_existing_pull_request::PullRequest, - prompts::text::confirm_prompt, + prompts::text::confirm_prompt, traits::InstallerManifestExt, }; pub const SPINNER_TICK_RATE: Duration = Duration::from_millis(50); @@ -81,3 +84,23 @@ pub async fn write_changes_to_dir(changes: &[(String, String)], output: &Utf8Pat .try_collect() .await } + +pub fn check_package_type(manifest: &InstallerManifest) -> Result { + let (mut has_font, mut has_installer) = (false, false); + + for installer in manifest.inherit_manifest_properties() { + if installer.r#type == Some(InstallerType::Font) + || installer.nested_installer_type == Some(NestedInstallerType::Font) + { + has_font = true; + } else { + has_installer = true; + } + + if has_font && has_installer { + bail!("Application and font installers cannot be mixed in the same manifest"); + } + } + + Ok(has_font) +} diff --git a/src/github/client.rs b/src/github/client.rs index 8de281abf..a20a4f5d7 100644 --- a/src/github/client.rs +++ b/src/github/client.rs @@ -68,8 +68,9 @@ impl GitHub { &self, identifier: &PackageIdentifier, latest_version: &PackageVersion, + font: bool, ) -> Result { - let full_package_path = PackagePath::new(identifier, Some(latest_version), None); + let full_package_path = PackagePath::new(identifier, Some(latest_version), None, font); let content = self .get_directory_content_with_text(MICROSOFT, WINGET_PKGS, &full_package_path) .await? @@ -161,8 +162,9 @@ impl GitHub { identifier: &PackageIdentifier, version: &PackageVersion, manifest_type: ManifestTypeWithLocale, + font: bool, ) -> Result { - let path = PackagePath::new(identifier, Some(version), Some(&manifest_type)); + let path = PackagePath::new(identifier, Some(version), Some(&manifest_type), font); let content = self.get_file_content(MICROSOFT, WINGET_PKGS, &path).await?; let manifest = serde_saphyr::from_str::(&content)?; Ok(manifest) @@ -448,6 +450,7 @@ impl GitHub { reason: &str, fork: &RepositoryData, winget_pkgs: &RepositoryData, + font: bool, #[builder(default)] issue_resolves: &[NonZeroU32], ) -> Result { // Create an indeterminate progress bar to show as a pull request is being created @@ -469,7 +472,7 @@ impl GitHub { .get_directory_content() .owner(&fork.owner) .branch_name(&branch_name) - .path(&PackagePath::new(identifier, Some(version), None)) + .path(&PackagePath::new(identifier, Some(version), None, font)) .call() .await? .map(FileDeletion::new) @@ -515,6 +518,7 @@ impl GitHub { created_with: Option<&str>, created_with_url: Option<&DecodedUrl>, ) -> Result { + let font = changes.iter().any(|(path, _)| path.starts_with("fonts/")); let (current_user, winget_pkgs) = tokio::try_join!(self.get_username(), self.get_winget_pkgs().send())?; let fork = self.get_winget_pkgs().owner(¤t_user).send().await?; @@ -531,7 +535,7 @@ impl GitHub { self.get_directory_content() .owner(¤t_user) .branch_name(&branch_name) - .path(&PackagePath::new(identifier, replace_version, None)) + .path(&PackagePath::new(identifier, replace_version, None, font)) .call() .await? .map(FileDeletion::new) diff --git a/src/github/rest/tree/mod.rs b/src/github/rest/tree/mod.rs index c6c52e1c2..c7359e254 100644 --- a/src/github/rest/tree/mod.rs +++ b/src/github/rest/tree/mod.rs @@ -27,16 +27,49 @@ pub struct GitTree { } impl GitHub { + /// Retrieves all available versions for a given package identifier. + /// + /// If `font` is `None`, this function first attempts to fetch versions from the manifest path. + /// If that fails, it tries the font path. + /// + /// Returns a tuple containing: + /// * `BTreeSet` - A set of all available package versions + /// * `bool` - `true` if the package is a font (found in the font path) pub async fn get_versions( &self, package_identifier: &PackageIdentifier, - ) -> Result, GitHubError> { + font: Option, + ) -> Result<(BTreeSet, bool), GitHubError> { + if let Some(font) = font { + return self + .get_all_versions( + MICROSOFT, + WINGET_PKGS, + PackagePath::new(package_identifier, None, None, font), + ) + .await + .map(|versions| (versions, font)) + .map_err(|_| GitHubError::PackageNonExistent(package_identifier.clone())); + } + + if let Ok(versions) = self + .get_all_versions( + MICROSOFT, + WINGET_PKGS, + PackagePath::new(package_identifier, None, None, false), + ) + .await + { + return Ok((versions, false)); + } + self.get_all_versions( MICROSOFT, WINGET_PKGS, - PackagePath::new(package_identifier, None, None), + PackagePath::new(package_identifier, None, None, true), ) .await + .map(|versions| (versions, true)) .map_err(|_| GitHubError::PackageNonExistent(package_identifier.clone())) } diff --git a/src/github/utils/package_path.rs b/src/github/utils/package_path.rs index 3dead372c..621214486 100644 --- a/src/github/utils/package_path.rs +++ b/src/github/utils/package_path.rs @@ -13,6 +13,7 @@ impl PackagePath { identifier: &PackageIdentifier, version: Option<&PackageVersion>, manifest_type: Option<&ManifestTypeWithLocale>, + font: bool, ) -> Self { let first_character = identifier.as_str().chars().next().map_or_else( || unreachable!("Package identifiers cannot be empty"), @@ -23,7 +24,8 @@ impl PackagePath { ); // manifests/p - let mut result = format!("manifests/{first_character}"); + let root = if font { "fonts" } else { "manifests" }; + let mut result = format!("{root}/{first_character}"); // manifests/p/Package/Identifier for part in identifier.as_str().split('.') { @@ -109,8 +111,18 @@ mod tests { let identifier = identifier.parse::().unwrap(); let version = version.and_then(|version| version.parse().ok()); assert_eq!( - PackagePath::new(&identifier, version.as_ref(), manifest_type.as_ref()).as_str(), + PackagePath::new(&identifier, version.as_ref(), manifest_type.as_ref(), false).as_str(), expected ) } + + #[test] + fn font_package_path_uses_fonts_root() { + let identifier = "Package.Font".parse::().unwrap(); + + assert_eq!( + PackagePath::new(&identifier, None, None, true).as_str(), + "fonts/p/Package/Font" + ); + } } diff --git a/src/traits/mod.rs b/src/traits/mod.rs index eeea83959..268c70f38 100644 --- a/src/traits/mod.rs +++ b/src/traits/mod.rs @@ -9,7 +9,7 @@ pub use name::Name; use regex::Regex; use winget_types::{ Manifest, ManifestVersion, PackageVersion, - installer::Architecture, + installer::{Architecture, Installer, InstallerManifest}, locale::{DefaultLocaleManifest, LocaleManifest, ReleaseNotes}, url::ReleaseNotesUrl, }; @@ -69,6 +69,27 @@ impl IntoWingetArchitecture for PE { } } +pub trait InstallerManifestExt { + fn inherit_manifest_properties(&self) -> impl Iterator + '_; +} + +impl InstallerManifestExt for InstallerManifest { + fn inherit_manifest_properties(&self) -> impl Iterator + '_ { + self.installers.iter().cloned().map(|mut installer| { + if self.r#type.is_some() { + installer.r#type = self.r#type; + } + if self.nested_installer_type.is_some() { + installer.nested_installer_type = self.nested_installer_type; + } + if self.scope.is_some() { + installer.scope = self.scope; + } + installer + }) + } +} + #[derive(Default)] struct GitHubHtmlDecorator { seen_header: Cell, From 5e114ecc22abc50cf5015d492a91a655e9963e31 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Wed, 17 Dec 2025 19:37:56 +0000 Subject: [PATCH 29/31] Check font magic bytes --- src/analysis/analyzer.rs | 2 +- src/analysis/installers/font.rs | 47 ++++++++++++++++++++++++++++++--- src/analysis/installers/mod.rs | 2 +- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/analysis/analyzer.rs b/src/analysis/analyzer.rs index 3ea105195..7646a08bc 100644 --- a/src/analysis/analyzer.rs +++ b/src/analysis/analyzer.rs @@ -81,7 +81,7 @@ impl<'reader, R: Read + Seek> Analyzer<'reader, R> { | ValidFileExtensions::Otc | ValidFileExtensions::Otf | ValidFileExtensions::Ttc - | ValidFileExtensions::Ttf => Font::new(reader)?.installers(), + | ValidFileExtensions::Ttf => Font::new(reader, file_name)?.installers(), }; Ok(Self { installers, diff --git a/src/analysis/installers/font.rs b/src/analysis/installers/font.rs index befb05e49..e1337b0d1 100644 --- a/src/analysis/installers/font.rs +++ b/src/analysis/installers/font.rs @@ -1,15 +1,54 @@ -use std::io::{Read, Seek}; +use std::io::{self, Read, Seek, SeekFrom}; -use color_eyre::eyre::Result; +use camino::Utf8PathBuf; +use thiserror::Error; use winget_types::installer::{Architecture, Installer, InstallerType}; use super::super::Installers; +/// https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font +const TRUETYPE_SIGNATURE: [u8; 4] = [0x00, 0x01, 0x00, 0x00]; +const OPENTYPE_SIGNATURE: [u8; 4] = *b"OTTO"; +/// https://learn.microsoft.com/en-us/typography/opentype/spec/otff#ttc-header +const TRUETYPE_COLLECTION_SIGNATURE: [u8; 4] = *b"ttcf"; +/// First 2 bytes are the little-endian FNT version (0x0200 or 0x0300). +const WINDOWS_FNT_SIGNATURES: [[u8; 2]; 2] = [[0x00, 0x02], [0x00, 0x03]]; + +const FONT_SIGNATURES: [[u8; 4]; 3] = [ + TRUETYPE_SIGNATURE, + OPENTYPE_SIGNATURE, + TRUETYPE_COLLECTION_SIGNATURE, +]; + +#[derive(Error, Debug)] +pub enum FontError { + #[error("{path} is not a valid font file")] + NotFontFile { path: Utf8PathBuf }, + #[error(transparent)] + Io(#[from] io::Error), +} + pub struct Font; impl Font { - pub fn new(_reader: R) -> Result { - Ok(Self) + pub fn new(mut reader: R, path: &str) -> Result { + reader.seek(SeekFrom::Start(0))?; + + let mut signature = [0u8; 4]; + reader.read_exact(&mut signature)?; + + if FONT_SIGNATURES.contains(&signature) { + return Ok(Self); + } + + let fnt_version = [signature[0], signature[1]]; + if WINDOWS_FNT_SIGNATURES.contains(&fnt_version) { + return Ok(Self); + } + + Err(FontError::NotFontFile { + path: Utf8PathBuf::from(path), + }) } } diff --git a/src/analysis/installers/mod.rs b/src/analysis/installers/mod.rs index 9b0371319..60a28b599 100644 --- a/src/analysis/installers/mod.rs +++ b/src/analysis/installers/mod.rs @@ -1,7 +1,7 @@ mod advanced; pub mod burn; mod exe; -mod font; +pub mod font; pub mod inno; mod msi; pub mod msix_family; From ee0977bd6b25b3844bbc0182d78aca4deecf9774 Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Fri, 19 Jun 2026 20:55:52 -0400 Subject: [PATCH 30/31] Validate selected zip files --- src/analysis/installers/zip.rs | 224 ++++++++++++++++++++++----------- 1 file changed, 153 insertions(+), 71 deletions(-) diff --git a/src/analysis/installers/zip.rs b/src/analysis/installers/zip.rs index 6748f4405..5d766fc2c 100644 --- a/src/analysis/installers/zip.rs +++ b/src/analysis/installers/zip.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeSet, HashMap}, + collections::BTreeSet, io, io::{Read, Seek, SeekFrom}, mem, @@ -9,6 +9,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use color_eyre::eyre::Result; use inquire::{CustomType, MultiSelect, min_length}; use regex::Regex; +use thiserror::Error; use tracing::debug; use winget_types::{ installer::{Installer, InstallerType, NestedInstallerFiles, PortableCommandAlias}, @@ -21,6 +22,14 @@ use crate::{prompts::handle_inquire_error, traits::path::LowercaseExtension}; const IGNORABLE_FOLDERS: [&str; 2] = ["__MACOSX", "resources"]; +#[derive(Debug, Error)] +#[error("{path} is not a valid nested installer file")] +struct InvalidNestedInstallerError { + path: Utf8PathBuf, + #[source] + source: Box, +} + enum NestedFileMatch { Contains(String), Glob(Regex), @@ -130,75 +139,41 @@ impl Zip { debug!(?possible_installer_files); - let installer_type_counts = ValidFileExtensions::ALL - .iter() - .map(|file_extension| { - ( - file_extension, - possible_installer_files - .iter() - .filter(|file_name| { - file_name.extension().is_some_and(|extension| { - extension.eq_ignore_ascii_case(file_extension.as_str()) - }) - }) - .count(), - ) - }) - .collect::>(); - - let mut nested_installer_files = BTreeSet::new(); - let mut installers = None; - // If there's only one valid file in the zip, extract and analyze it - if installer_type_counts - .values() - .filter(|&&count| count == 1) - .count() - == 1 - { - let chosen_file_name = &possible_installer_files[0]; - nested_installer_files = BTreeSet::from([NestedInstallerFiles { + let installers = if let [chosen_file_name] = possible_installer_files.as_slice() { + let nested_installer_files = BTreeSet::from([NestedInstallerFiles { relative_file_path: chosen_file_name.lowercase_extension(), portable_command_alias: None, }]); - if let Ok(mut chosen_file) = zip.by_name(chosen_file_name.as_str()) { - let mut temp_file = tempfile::tempfile()?; - io::copy(&mut chosen_file, &mut temp_file)?; - temp_file.seek(SeekFrom::Start(0))?; - let file_analyzer = Analyzer::new(&mut temp_file, chosen_file_name.as_str())?; - installers = Some( - file_analyzer - .installers - .into_iter() - .map(|installer| Installer { - r#type: Some(InstallerType::Zip), - nested_installer_type: installer - .r#type - .and_then(|installer_type| installer_type.try_into().ok()), - nested_installer_files: nested_installer_files.clone(), - ..installer - }) - .collect::>(), - ); - } - } + let file_installers = Self::analyze_nested_file_in_archive(&mut zip, chosen_file_name)?; + + file_installers + .into_iter() + .map(|installer| Installer { + r#type: Some(InstallerType::Zip), + nested_installer_type: installer + .r#type + .and_then(|installer_type| installer_type.try_into().ok()), + nested_installer_files: nested_installer_files.clone(), + ..installer + }) + .collect() + } else { + vec![Installer { + r#type: Some(InstallerType::Zip), + ..Installer::default() + }] + }; Ok(Self { archive: zip, possible_installer_files, - installers: installers.unwrap_or_else(|| { - vec![Installer { - r#type: Some(InstallerType::Zip), - nested_installer_files, - ..Installer::default() - }] - }), + installers, }) } pub fn prompt(&mut self) -> Result<()> { - if !&self.possible_installer_files.is_empty() { + if !self.possible_installer_files.is_empty() { let chosen = MultiSelect::new( "Select the nested files", mem::take(&mut self.possible_installer_files), @@ -206,21 +181,22 @@ impl Zip { .with_validator(min_length!(1)) .prompt() .map_err(handle_inquire_error)?; - let first_choice = chosen.first().unwrap(); - let mut temp_file = tempfile::tempfile()?; - io::copy( - &mut self.archive.by_name(first_choice.as_str())?, - &mut temp_file, + let mut chosen_paths = chosen.iter(); + let first_file_installers = Self::analyze_nested_file_in_archive( + &mut self.archive, + chosen_paths.next().unwrap(), )?; - temp_file.seek(SeekFrom::Start(0))?; - let file_analyzer = Analyzer::new(&mut temp_file, first_choice.file_name().unwrap())?; + for path in chosen_paths { + Self::analyze_nested_file_in_archive(&mut self.archive, path)?; + } + let first_file_is_portable = first_file_installers + .first() + .is_some_and(|installer| installer.r#type == Some(InstallerType::Portable)); let nested_installer_files = chosen .into_iter() .map(|path| { Ok(NestedInstallerFiles { - portable_command_alias: if file_analyzer.installers[0].r#type - == Some(InstallerType::Portable) - { + portable_command_alias: if first_file_is_portable { CustomType::::new(&format!( "Portable command alias for {path}:", )) @@ -233,10 +209,10 @@ impl Zip { }) }) .collect::>>()?; - self.installers = file_analyzer - .installers + self.installers = first_file_installers .into_iter() .map(|installer| Installer { + r#type: Some(InstallerType::Zip), nested_installer_type: installer .r#type .and_then(|installer_type| installer_type.try_into().ok()), @@ -276,7 +252,13 @@ impl Zip { io::copy(&mut nested_file, &mut temp_file)?; temp_file.seek(SeekFrom::Start(0))?; - let nested_analyzer = Analyzer::new(&mut temp_file, path.as_str())?; + let nested_analyzer = + Analyzer::new(&mut temp_file, path.as_str()).map_err(|source| { + InvalidNestedInstallerError { + path: path.clone(), + source: source.into(), + } + })?; let nested_installer_files = BTreeSet::from([NestedInstallerFiles { relative_file_path: path.lowercase_extension(), portable_command_alias: None, @@ -312,4 +294,104 @@ impl Zip { Ok(installers) } + + fn analyze_nested_file_in_archive( + archive: &mut ZipArchive, + path: &Utf8Path, + ) -> Result> { + let mut chosen_file = archive.by_name(path.as_str())?; + let mut temp_file = tempfile::tempfile()?; + io::copy(&mut chosen_file, &mut temp_file)?; + temp_file.seek(SeekFrom::Start(0))?; + let analyzer = Analyzer::new(&mut temp_file, path.as_str()).map_err(|source| { + InvalidNestedInstallerError { + path: path.to_owned(), + source: source.into(), + } + })?; + Ok(analyzer.installers) + } +} + +#[cfg(test)] +mod tests { + use std::io::{Cursor, Write}; + + use color_eyre::eyre::Result; + use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; + + use super::*; + + const TTF_SIGNATURE: [u8; 4] = [0x00, 0x01, 0x00, 0x00]; + + fn zip_with_files(files: &[(&str, &[u8])]) -> Result> { + let mut buffer = Cursor::new(Vec::new()); + { + let mut writer = ZipWriter::new(&mut buffer); + let options = + SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + + for (path, contents) in files { + writer.start_file(path, options)?; + writer.write_all(contents)?; + } + + writer.finish()?; + } + + Ok(buffer.into_inner()) + } + + #[test] + fn selected_nested_files_reject_invalid_file_with_valid_extension() -> Result<()> { + let zip_bytes = zip_with_files(&[("valid.ttf", &TTF_SIGNATURE), ("invalid.ttf", b"nope")])?; + let mut zip = Zip::new(Cursor::new(zip_bytes))?; + let selected_files = vec![ + Utf8PathBuf::from("valid.ttf"), + Utf8PathBuf::from("invalid.ttf"), + ]; + + let error = selected_files + .iter() + .map(|path| Zip::analyze_nested_file_in_archive(&mut zip.archive, path)) + .collect::>>() + .unwrap_err(); + + assert_eq!( + error.to_string(), + "invalid.ttf is not a valid nested installer file" + ); + Ok(()) + } + + #[test] + fn selected_nested_file_accepts_valid_file() -> Result<()> { + let zip_bytes = zip_with_files(&[ + ("valid.ttf", &TTF_SIGNATURE), + ("ignored.txt", b"not an installer"), + ])?; + let mut zip = Zip::new(Cursor::new(zip_bytes))?; + let selected_file = Utf8PathBuf::from("valid.ttf"); + + let installers = Zip::analyze_nested_file_in_archive(&mut zip.archive, &selected_file)?; + + assert_eq!(installers[0].r#type, Some(InstallerType::Font)); + Ok(()) + } + + #[test] + fn multiple_nested_candidates_do_not_infer_nested_installer() -> Result<()> { + let zip_bytes = zip_with_files(&[ + ("first.exe", b"not an exe"), + ("second.exe", b"not an exe"), + ("valid.ttf", &TTF_SIGNATURE), + ])?; + + let zip = Zip::new(Cursor::new(zip_bytes))?; + + assert_eq!(zip.installers[0].r#type, Some(InstallerType::Zip)); + assert_eq!(zip.installers[0].nested_installer_type, None); + assert!(zip.installers[0].nested_installer_files.is_empty()); + Ok(()) + } } From 0ea6bd304c3de030f50bc3d69983cb4f5190903a Mon Sep 17 00:00:00 2001 From: UnownPlain Date: Fri, 3 Jul 2026 00:22:15 -0400 Subject: [PATCH 31/31] Allow downloads without a Content-Type header --- src/download/downloader.rs | 42 ++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/src/download/downloader.rs b/src/download/downloader.rs index e7fd02e78..97763be65 100644 --- a/src/download/downloader.rs +++ b/src/download/downloader.rs @@ -132,12 +132,15 @@ impl Downloader { download: &Download, content_types: GetAll, ) -> Result<(), ContentTypeError> { - if content_types.iter().all(|content_type| { - content_type != Self::OCTET_STREAM - && !content_type - .as_bytes() - .starts_with(Self::APPLICATION.as_bytes()) - }) { + // Some download servers omit Content-Type, so only reject explicitly invalid values. + if content_types.iter().next().is_some() + && content_types.iter().all(|content_type| { + content_type != Self::OCTET_STREAM + && !content_type + .as_bytes() + .starts_with(Self::APPLICATION.as_bytes()) + }) + { return Err(ContentTypeError::new(download.clone(), content_types)); } @@ -289,3 +292,30 @@ impl fmt::Display for ContentTypeError { ) } } + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::manifests::Url; + + #[rstest] + #[case::missing(&[], true)] + #[case::application(&["application/octet-stream"], true)] + #[case::binary_octet_stream(&["binary/octet-stream"], true)] + #[case::non_application(&["text/html"], false)] + #[case::one_valid(&["text/html", "application/octet-stream"], true)] + fn checks_content_types(#[case] content_types: &[&str], #[case] expected: bool) { + let download = Download::new("https://example.com/installer.exe".parse::().unwrap()); + let mut headers = HeaderMap::new(); + for content_type in content_types { + headers.append(CONTENT_TYPE, content_type.parse().unwrap()); + } + + assert_eq!( + Downloader::check_content_types(&download, headers.get_all(CONTENT_TYPE)).is_ok(), + expected + ); + } +}