├── .gitignore ├── rust-toolchain.toml ├── axoupdater ├── src │ ├── test │ │ ├── mod.rs │ │ └── helpers.rs │ ├── release │ │ ├── axodotdev.rs │ │ ├── mod.rs │ │ └── github.rs │ ├── receipt.rs │ ├── errors.rs │ └── lib.rs └── Cargo.toml ├── .github ├── dependabot.yml └── workflows │ ├── publish-crates.yml │ ├── ci.yml │ └── release.yml ├── Cargo.toml ├── SECURITY.md ├── CODE_OF_CONDUCT.md ├── dist-workspace.toml ├── LICENSE-MIT ├── axoupdater-cli ├── Cargo.toml ├── src │ └── bin │ │ └── axoupdater │ │ └── main.rs └── tests │ └── integration.rs ├── CONTRIBUTING.md ├── README.md ├── LICENSE-APACHE └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.87" 3 | components = ["rustc", "cargo", "rust-std", "clippy", "rustfmt"] 4 | -------------------------------------------------------------------------------- /axoupdater/src/test/mod.rs: -------------------------------------------------------------------------------- 1 | //! Tests and Testing Accessories 2 | 3 | /// Test helpers to simplify runtests for custom updaters 4 | pub mod helpers; 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["axoupdater", "axoupdater-cli"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | version = "0.9.1" 7 | edition = "2021" 8 | license = "MIT OR Apache-2.0" 9 | homepage = "https://github.com/axodotdev/axoupdater" 10 | repository = "https://github.com/axodotdev/axoupdater" 11 | 12 | # The profile that 'cargo dist' will build with 13 | [profile.dist] 14 | inherits = "release" 15 | lto = "thin" 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Axo Developer Co. takes the security of our software products and services seriously. If you believe you have found a security vulnerability in this open source repository, please report the issue to us directly using GitHub private vulnerability reporting or email ashley@axo.dev. If you aren't sure you have found a security vulnerability but have a suspicion or concern, feel free to message anyways; we prefer over-communication :) 2 | 3 | Please do not report security vulnerabilities publicly, such as via GitHub issues, Twitter, or other social media. 4 | 5 | Thanks for helping make software safe for everyone! 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | The repositories within the @axodotdev GitHub organization are formally owned 4 | by Axo Developer Co. which is a for-profit C-Corporation incorporated in 5 | Delaware, USA and operated by its global, distributed, team of employees. 6 | 7 | To the extent it is possible, reasonable, and legal, external contributors and 8 | employees are held to the same behavior standards, as defined by the 9 | [Contributor Covenant]'s Pledge and Standards. 10 | 11 | Enforcement will necessarily be different depending on the employment status 12 | of involved parties. 13 | 14 | All decisions are made by [Ashley Williams](mailto:ashley@axo.dev) through a 15 | consensus-seeking process with the [Axo team](https://www.axo.dev/team) and 16 | to the extent it is possible, reasonable, and legal, any involved external 17 | participants. 18 | 19 | [Contributor Covenant]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/ 20 | -------------------------------------------------------------------------------- /.github/workflows/publish-crates.yml: -------------------------------------------------------------------------------- 1 | # Publishes a release to crates.io 2 | # 3 | # To trigger this: 4 | # 5 | # - go to Actions > PublishRelease 6 | # - click the Run Workflow dropdown in the top-right 7 | # - enter the tag of the release as “Release Tag” (e.g. v0.3.18) 8 | name: PublishRelease 9 | 10 | on: 11 | workflow_call: 12 | inputs: 13 | plan: 14 | required: true 15 | type: string 16 | 17 | jobs: 18 | # publish the current repo state to crates.io 19 | cargo-publish: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout sources 23 | uses: actions/checkout@v4 24 | with: 25 | submodules: recursive 26 | - run: cargo publish -p axoupdater --token ${CRATES_TOKEN} 27 | env: 28 | CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} 29 | - run: cargo publish -p axoupdater-cli --token ${CRATES_TOKEN} 30 | env: 31 | CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} 32 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.28.1" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = [] 12 | # Target platforms to build apps for (Rust target-triple syntax) 13 | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] 14 | # Which actions to run on pull requests 15 | pr-run-mode = "plan" 16 | # Publish jobs to run in CI 17 | publish-jobs = ["./publish-crates"] 18 | 19 | [dist.github-custom-runners.aarch64-unknown-linux-gnu.container] 20 | image = "quay.io/pypa/manylinux_2_28_x86_64" 21 | host = "x86_64-unknown-linux-musl" 22 | 23 | [dist.github-custom-runners.aarch64-unknown-linux-musl.container] 24 | image = "quay.io/pypa/manylinux_2_28_x86_64" 25 | host = "x86_64-unknown-linux-musl" 26 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Axo Developer Co. 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /axoupdater-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axoupdater-cli" 3 | description = "Self-updater executable for use with cargo-dist" 4 | version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | readme = "../README.md" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | [features] 13 | tls_native_roots = ["axoupdater/tls_native_roots"] 14 | 15 | [dependencies] 16 | axocli = "0.3.0" 17 | axoupdater = { version = "=0.9.1", path = "../axoupdater", features = ["blocking"] } 18 | clap = { version = "4.5.48", features = ["derive"] } 19 | 20 | # errors 21 | miette = "7.4.0" 22 | 23 | [dev-dependencies] 24 | axoasset = { version = "1.4.0", default-features = false, features = [ 25 | "compression", "compression-tar", "compression-zip", "remote" 26 | ] } 27 | axoprocess = "0.2.0" 28 | camino = { version = "1.1.10", features = ["serde1"] } 29 | tempfile = "3.23.0" 30 | tokio = { version = "1.47.1", features = ["full"] } 31 | 32 | [[bin]] 33 | name = "axoupdater" 34 | 35 | [package.metadata.dist] 36 | features = ["tls_native_roots"] 37 | -------------------------------------------------------------------------------- /axoupdater/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axoupdater" 3 | description = "Self-updater library for use with cargo-dist" 4 | version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | readme = "../README.md" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [features] 14 | default = ["axo_releases", "github_releases"] 15 | axo_releases = ["gazenot"] 16 | blocking = ["tokio"] 17 | github_releases = ["axoasset/remote"] 18 | tls_native_roots = ["axoasset/tls-native-roots"] 19 | 20 | [dependencies] 21 | axoasset = { version = "1.4.0", default-features = false, features = ["json-serde"] } 22 | axoprocess = "0.2.0" 23 | axotag = { version = "0.3.0" } 24 | camino = { version = "1.2.1", features = ["serde1"] } 25 | homedir = "0.3.3" 26 | serde = "1.0.197" 27 | tempfile = "3.10.1" 28 | url = "2.5.7" 29 | 30 | # axo releases 31 | gazenot = { version = "0.3.3", features = ["client_lib"], optional = true } 32 | 33 | # blocking API 34 | tokio = { version = "1.36.0", features = ["full"], optional = true } 35 | 36 | # errors 37 | miette = "7.2.0" 38 | thiserror = "2.0.0" 39 | 40 | [target.'cfg(windows)'.dependencies] 41 | self-replace = "1.5.0" 42 | 43 | [dev-dependencies] 44 | tokio = { version = "1.36.0", features = ["test-util"] } 45 | httpmock = "0.8.1" 46 | serial_test = "3.2.0" 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks so much for your interest in contributing to `axohtml`. We are excited 4 | about the building a community of contributors to the project. Here's some 5 | guiding principles for working with us: 6 | 7 | **1. File an issue first!** 8 | 9 | Except for the absolute tiniest of PRs (e.g. a single typo fix), please file an 10 | issue before opening a PR. This can help ensure that the problem you are trying 11 | to solve and the solution you have in mind will be accepted. Where possible, we 12 | don't want folks wasting time on directions we don't want to take the project. 13 | 14 | **2. Write tests, or at least detailed reproduction steps** 15 | 16 | If you find a bug, the best way to prioritize getting it fixed is to open a PR 17 | with a failing test! If you a opening a bug fix PR, please add a test to show 18 | that your fix works. 19 | 20 | **3. Overcommunicate** 21 | 22 | In all scenarios, please provide as much context as possible- you may not think 23 | it's important but it may be! 24 | 25 | **4. Patience** 26 | 27 | Axo is a very small company, it's possible that we may not be able to 28 | immediately prioritize your issue. We are excite to develop a community of 29 | contributors around this project, but it won't always be on the top of our to-do 30 | list, even if we wish it could be. 31 | 32 | If you haven't heard from us in a while and want to check in, feel free to 33 | at-mention @ashleygwilliams- but please be kind while doing so! 34 | -------------------------------------------------------------------------------- /axoupdater-cli/src/bin/axoupdater/main.rs: -------------------------------------------------------------------------------- 1 | use axocli::{CliApp, CliAppBuilder}; 2 | use axoupdater::AxoUpdater; 3 | use clap::Parser; 4 | use miette::miette; 5 | 6 | #[derive(Parser)] 7 | struct CliArgs { 8 | /// Installs the specified tag instead of the latest version 9 | #[clap(long)] 10 | tag: Option, 11 | 12 | /// Installs the specified version instead of the latest version 13 | #[clap(long)] 14 | version: Option, 15 | 16 | /// Allows prereleases when just updating to "latest" 17 | #[clap(long)] 18 | prerelease: bool, 19 | } 20 | 21 | fn real_main(cli: &CliApp) -> Result<(), miette::Report> { 22 | if cli.config.tag.is_some() && cli.config.version.is_some() { 23 | return Err(miette!( 24 | "Both `tag` and `version` are specified; these options are mutually exclusive!" 25 | )); 26 | } 27 | 28 | eprintln!("Checking for updates..."); 29 | 30 | let mut updater = AxoUpdater::new_for_updater_executable()?; 31 | updater.load_receipt()?; 32 | 33 | if let Ok(token) = std::env::var("AXOUPDATER_GITHUB_TOKEN") { 34 | updater.set_github_token(&token); 35 | } 36 | 37 | if let Ok(path) = std::env::var("AXOUPDATER_INSTALLER_PATH") { 38 | updater.configure_installer_path(path); 39 | } 40 | 41 | let specifier = if let Some(tag) = &cli.config.tag { 42 | axoupdater::UpdateRequest::SpecificTag(tag.clone()) 43 | } else if let Some(version) = &cli.config.version { 44 | axoupdater::UpdateRequest::SpecificVersion(version.clone()) 45 | } else if cli.config.prerelease { 46 | axoupdater::UpdateRequest::LatestMaybePrerelease 47 | } else { 48 | axoupdater::UpdateRequest::Latest 49 | }; 50 | updater.configure_version_specifier(specifier); 51 | 52 | if let Some(result) = updater.run_sync()? { 53 | eprintln!("New release {} installed!", result.new_version) 54 | } else { 55 | eprintln!("Already up to date; not upgrading"); 56 | } 57 | 58 | Ok(()) 59 | } 60 | 61 | fn main() { 62 | CliAppBuilder::new("axoupdater").start(CliArgs::parse(), real_main); 63 | } 64 | -------------------------------------------------------------------------------- /axoupdater/src/release/axodotdev.rs: -------------------------------------------------------------------------------- 1 | //! Fetching and processing from axo Releases 2 | 3 | use super::{Asset, Release}; 4 | use crate::errors::*; 5 | use axotag::Version; 6 | use gazenot::Gazenot; 7 | 8 | pub(crate) async fn get_specific_axo_version( 9 | name: &str, 10 | owner: &str, 11 | app_name: &str, 12 | version: &Version, 13 | ) -> AxoupdateResult { 14 | let releases = get_axo_releases(name, owner, app_name).await?; 15 | let release = releases.into_iter().find(|r| &r.version == version); 16 | 17 | if let Some(release) = release { 18 | Ok(release) 19 | } else { 20 | Err(AxoupdateError::ReleaseNotFound { 21 | name: name.to_owned(), 22 | app_name: app_name.to_owned(), 23 | }) 24 | } 25 | } 26 | 27 | pub(crate) async fn get_specific_axo_tag( 28 | name: &str, 29 | owner: &str, 30 | app_name: &str, 31 | tag: &str, 32 | ) -> AxoupdateResult { 33 | let releases = get_axo_releases(name, owner, app_name).await?; 34 | let release = releases.into_iter().find(|r| r.tag_name == tag); 35 | 36 | if let Some(release) = release { 37 | Ok(release) 38 | } else { 39 | Err(AxoupdateError::ReleaseNotFound { 40 | name: name.to_owned(), 41 | app_name: app_name.to_owned(), 42 | }) 43 | } 44 | } 45 | 46 | pub(crate) async fn get_axo_releases( 47 | name: &str, 48 | owner: &str, 49 | app_name: &str, 50 | ) -> AxoupdateResult> { 51 | let abyss = Gazenot::new_unauthed("github".to_string(), owner)?; 52 | let release_lists = abyss.list_releases_many(vec![app_name.to_owned()]).await?; 53 | let Some(our_release) = release_lists 54 | .into_iter() 55 | .find(|rl| rl.package_name == app_name) 56 | else { 57 | return Err(AxoupdateError::ReleaseNotFound { 58 | name: name.to_owned(), 59 | app_name: app_name.to_owned(), 60 | }); 61 | }; 62 | 63 | let releases: Vec = our_release 64 | .releases 65 | .into_iter() 66 | .filter_map(|r| Release::try_from_gazenot(r).ok()) 67 | .collect(); 68 | 69 | Ok(releases) 70 | } 71 | 72 | impl Release { 73 | /// Constructs a release from Axo Releases data fetched via gazenot. 74 | pub(crate) fn try_from_gazenot(release: gazenot::PublicRelease) -> AxoupdateResult { 75 | Ok(Release { 76 | tag_name: release.tag_name, 77 | version: release.version.parse()?, 78 | name: release.name, 79 | url: String::new(), 80 | assets: release 81 | .assets 82 | .into_iter() 83 | .map(|asset| Asset { 84 | url: asset.browser_download_url.clone(), 85 | browser_download_url: asset.browser_download_url, 86 | name: asset.name, 87 | }) 88 | .collect(), 89 | prerelease: release.prerelease, 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # The "Normal" CI for tests and linters and whatnot 2 | name: Rust CI 3 | 4 | # Ci should be run on... 5 | on: 6 | # Every pull request (will need approval for new contributors) 7 | pull_request: 8 | # Every push to... 9 | push: 10 | branches: 11 | # The main branch 12 | - main 13 | # And once a week? 14 | # This can catch things like "rust updated and actually regressed something" 15 | schedule: 16 | - cron: "11 7 * * 1,4" 17 | 18 | # We want all these checks to fail if they spit out warnings 19 | env: 20 | RUSTFLAGS: -Dwarnings 21 | 22 | jobs: 23 | # Check that rustfmt is a no-op 24 | fmt: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: dtolnay/rust-toolchain@master 29 | with: 30 | toolchain: stable 31 | components: rustfmt 32 | - run: cargo fmt --all -- --check 33 | 34 | # Check that clippy is appeased 35 | clippy: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v3 39 | - uses: dtolnay/rust-toolchain@master 40 | with: 41 | toolchain: stable 42 | components: clippy 43 | - uses: swatinem/rust-cache@v2 44 | - uses: actions-rs/clippy-check@v1 45 | env: 46 | PWD: ${{ env.GITHUB_WORKSPACE }} 47 | with: 48 | token: ${{ secrets.GITHUB_TOKEN }} 49 | args: --workspace --tests --examples 50 | 51 | # Make sure the docs build without warnings 52 | docs: 53 | runs-on: ubuntu-latest 54 | env: 55 | RUSTDOCFLAGS: -Dwarnings 56 | steps: 57 | - uses: actions/checkout@master 58 | - uses: dtolnay/rust-toolchain@master 59 | with: 60 | toolchain: stable 61 | components: rust-docs 62 | - uses: swatinem/rust-cache@v2 63 | - run: cargo doc --workspace --no-deps 64 | 65 | # Build and run tests/doctests/examples on all platforms 66 | # FIXME: look into `cargo-hack` which lets you more aggressively 67 | # probe all your features and rust versions (see tracing's ci) 68 | test: 69 | runs-on: ${{ matrix.os }} 70 | env: 71 | # runtest the installer scripts 72 | RUIN_MY_COMPUTER_WITH_INSTALLERS: true 73 | AXOUPDATER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | strategy: 75 | # Test the cross-product of these platforms+toolchains 76 | matrix: 77 | os: [ubuntu-latest, windows-latest, macOS-14] 78 | rust: [stable] 79 | steps: 80 | # Setup tools 81 | - uses: actions/checkout@master 82 | - uses: dtolnay/rust-toolchain@master 83 | with: 84 | toolchain: ${{ matrix.rust }} 85 | - uses: swatinem/rust-cache@v2 86 | with: 87 | key: ${{ matrix.os }} 88 | # Run the tests/doctests (default features) 89 | - run: cargo test --workspace 90 | env: 91 | PWD: ${{ env.GITHUB_WORKSPACE }} 92 | # Run the tests/doctests (all features) 93 | - run: cargo test --workspace --all-features 94 | env: 95 | PWD: ${{ env.GITHUB_WORKSPACE }} 96 | # Test the examples (default features) 97 | - run: cargo test --workspace --examples --bins 98 | env: 99 | PWD: ${{ env.GITHUB_WORKSPACE }} 100 | # Test the examples (all features) 101 | - run: cargo test --workspace --all-features --examples --bins 102 | env: 103 | PWD: ${{ env.GITHUB_WORKSPACE }} 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # axoupdater 2 | 3 | axoupdater provides an autoupdater program designed for use with [cargo-dist](https://opensource.axo.dev/cargo-dist/). It can be used either as a standalone program, or as a library within your own program. It supports releases hosted on either [GitHub Releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases) or [Axo Releases (in beta)](https://axo.dev). 4 | 5 | In order to be able to check information about an installed program, it uses the install receipts produced by cargo-dist since version [0.10.0 or later](https://github.com/axodotdev/cargo-dist/releases/tag/v0.10.0). These install receipts are JSON files containing metadata about the currently-installed version of an app and the version of cargo-dist that produced it; they can be found in `~/.config/APP_NAME` (Linux, Mac) or `%LOCALAPPDATA%\APP_NAME` (Windows). 6 | 7 | ## Standalone use 8 | 9 | When built as a standalone commandline app, axoupdater does exactly one thing: check if the user is using the latest version of the software it's built for, and perform an update if not. Rather than being hardcoded for a specific application, the updater's filename is used to determine what app to update. For example, if axoupdater is installed under the filename `axolotlsay-update`, then it will try to fetch updates for the app named `axolotlsay`. This means you only need to build axoupdater once, and can deploy it for many apps without rebuilding. 10 | 11 | In an upcoming release, cargo-dist will support generating and installing the updater for your users as an optional feature. 12 | 13 | ## Library use 14 | 15 | axoupdater can also be used as a library within your own applications in order to let you check for updates or perform an automatic update within your own apps. Here's a few examples of how that can be used. 16 | 17 | To check for updates and notify the user: 18 | 19 | ```rust 20 | if AxoUpdater::new_for("axolotlsay").load_receipt()?.is_update_needed_sync()? { 21 | eprintln!("axolotlsay is outdated; please upgrade!"); 22 | } 23 | ``` 24 | 25 | To automatically perform an update if the program isn't up to date: 26 | 27 | ```rust 28 | if AxoUpdater::new_for("axolotlsay").load_receipt()?.run_sync()? { 29 | eprintln!("Update installed!"); 30 | } else { 31 | eprintln!("axolotlsay already up to date"); 32 | } 33 | ``` 34 | 35 | To use the blocking versions of the methods, make sure to enable the `"blocking"` feature on this dependency in your `Cargo.toml`. Asynchronous versions of `is_update_needed()` and `run()` are also provided: 36 | 37 | ```rust 38 | if AxoUpdater::new_for("axolotlsay").load_receipt()?.run().await? { 39 | eprintln!("Update installed!"); 40 | } else { 41 | eprintln!("axolotlsay already up to date"); 42 | } 43 | ``` 44 | 45 | ## GitHub Actions and Rate Limits in CI 46 | 47 | By default, axoupdater uses unauthenticated GitHub API calls when fetching release information. This is reliable in normal use, but it's much more likely to run into rate limits in the highly artificial environment of a CI test. Axoupdater provides a way to supply a GitHub API token in order to opt into a higher rate limit; if you find your app being rate limited in CI, you may want to opt into it. Cargo-dist uses this in its own tests. Here's a simple example of how you can integrate it into your own app. 48 | 49 | We recommend using an environment variable for token configuration so that you don't have to adjust how you call your app at the commandline in tests. We also recommend picking an environment variable name that's specific to your application; it's not uncommon for users to have stale or expired `GITHUB_TOKEN` tokens in their environment, and using that name may cause your app to behave unexpectedly. 50 | 51 | First, wherever you construct an updater client, add a check for the environment variable and, if set, pass its value to the `set_github_token()` method: 52 | 53 | ```rust 54 | if let Ok(token) = std::env::var("YOUR_APP_GITHUB_TOKEN") { 55 | updater.set_github_token(&token); 56 | } 57 | ``` 58 | 59 | A sample of how cargo-dist uses this can be found [here](https://github.com/axodotdev/cargo-dist/blob/80f2e19e5aa79b7b1f64beb62ceb07aa71566707/cargo-dist/src/main.rs#L599-L601). 60 | 61 | Then, in your CI configuration, assign that variable to the value of the `GITHUB_TOKEN` secret that's automatically assigned by GitHub Actions: 62 | 63 | ```yaml 64 | env: 65 | YOUR_APP_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | ``` 67 | 68 | A sample in cargo-dist's CI configuration can be found [here](https://github.com/axodotdev/cargo-dist/blob/80f2e19e5aa79b7b1f64beb62ceb07aa71566707/.github/workflows/ci.yml#L82-L85). 69 | 70 | ## Crate features 71 | 72 | By default, axoupdater is built with support for both GitHub and Axo releases. If you're using it as a library in your program, and you know ahead of time which backend you're using to host your release assets, you can disable the other library in order to reduce the size of the dependency tree. 73 | 74 | ## Building 75 | 76 | To build as a standalone binary, follow these steps: 77 | 78 | - Run `cargo build --release` 79 | - Rename `target/release/axoupdater` to `APPNAME-update`, where `APPNAME` is the name of the app you want it to upgrade. 80 | 81 | ## License 82 | 83 | Licensed under either of 84 | 85 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or [apache.org/licenses/LICENSE-2.0](https://www.apache.org/licenses/LICENSE-2.0)) 86 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or [opensource.org/licenses/MIT](https://opensource.org/licenses/MIT)) 87 | 88 | at your option. 89 | -------------------------------------------------------------------------------- /axoupdater/src/receipt.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env::{self, current_dir, current_exe}, 3 | path::PathBuf, 4 | }; 5 | 6 | use crate::{errors::*, AxoUpdater, ReleaseSource}; 7 | use axoasset::SourceFile; 8 | use axotag::Version; 9 | use camino::Utf8PathBuf; 10 | use serde::Deserialize; 11 | 12 | fn default_as_true() -> bool { 13 | true 14 | } 15 | 16 | /// Information parsed from a cargo-dist install receipt 17 | #[derive(Clone, Debug, Deserialize)] 18 | pub struct InstallReceipt { 19 | /// The path this app has been installed to 20 | pub install_prefix: Utf8PathBuf, 21 | /// A list of binaries installed by this app 22 | #[allow(dead_code)] 23 | pub binaries: Vec, 24 | /// A list of libraries installed by this app 25 | // Added in cargo-dist 0.20.0, missing in older receipts 26 | #[serde(default = "Vec::new")] 27 | #[allow(dead_code)] 28 | pub cdylibs: Vec, 29 | /// Information about where this release was fetched from 30 | pub source: ReleaseSource, 31 | /// Installed version 32 | pub version: String, 33 | /// Information about the tool used to produce this receipt 34 | pub provider: ReceiptProvider, 35 | /// Information about whether new installations should modify system paths 36 | // Added in cargo-dist 0.23.0, missing in older receipts 37 | #[serde(default = "default_as_true")] 38 | pub modify_path: bool, 39 | } 40 | 41 | /// Tool used to produce this install receipt 42 | #[derive(Clone, Debug, Deserialize)] 43 | pub struct ReceiptProvider { 44 | /// The name of the tool used to create this receipt 45 | pub source: String, 46 | /// The version of the above tool 47 | pub version: String, 48 | } 49 | 50 | impl AxoUpdater { 51 | /// Attempts to load an install receipt in order to prepare for an update. 52 | /// If present and valid, the install receipt is used to populate the 53 | /// `source` and `current_version` fields. 54 | /// Shell and Powershell installers produced by cargo-dist since 0.9.0 55 | /// will have created an install receipt. 56 | pub fn load_receipt(&mut self) -> AxoupdateResult<&mut AxoUpdater> { 57 | let Some(app_name) = self.name.clone() else { 58 | return Err(AxoupdateError::NoAppNamePassed {}); 59 | }; 60 | 61 | self.load_receipt_as(&app_name) 62 | } 63 | 64 | /// Similar to `AxoUpdater::load_receipt`, but loads a receipt for the app 65 | /// with the name `app_name` instead of the auto-detected name. This can be 66 | /// useful if the receipt may exist under several different names, for 67 | /// example if an app has been renamed. 68 | pub fn load_receipt_as(&mut self, app_name: &str) -> AxoupdateResult<&mut AxoUpdater> { 69 | let receipt = load_receipt_for(app_name)?; 70 | 71 | self.source = Some(receipt.source); 72 | self.current_version = Some(receipt.version.parse::()?); 73 | 74 | let provider = crate::Provider { 75 | source: receipt.provider.source, 76 | version: receipt.provider.version.parse::()?, 77 | }; 78 | 79 | self.current_version_installed_by = Some(provider); 80 | self.install_prefix = Some(receipt.install_prefix); 81 | self.modify_path = receipt.modify_path; 82 | 83 | Ok(self) 84 | } 85 | 86 | /// Checks to see if the loaded install receipt is for this executable. 87 | /// Used to guard against cases where the running EXE is from a package 88 | /// manager, but a receipt from a shell installed-copy is present on the 89 | /// system. 90 | /// Returns an error if the receipt hasn't been loaded yet. 91 | pub fn check_receipt_is_for_this_executable(&self) -> AxoupdateResult { 92 | let current_exe_path = Utf8PathBuf::from_path_buf(current_exe()?.canonicalize()?) 93 | .map_err(|path| AxoupdateError::CaminoConversionFailed { path })?; 94 | // First determine the parent dir 95 | let mut current_exe_root = if let Some(parent) = current_exe_path.parent() { 96 | parent.to_path_buf() 97 | } else { 98 | current_exe_path 99 | }; 100 | 101 | let receipt_root = self.install_prefix_root_normalized()?; 102 | 103 | // If the parent dir is a "bin" dir, strip it to get the true root, 104 | // but only if the true install root isn't itself a `bin` dir. 105 | if current_exe_root.file_name() == Some("bin") && receipt_root.file_name() != Some("bin") { 106 | if let Some(parent) = current_exe_root.parent() { 107 | current_exe_root = parent.to_path_buf(); 108 | } 109 | } 110 | 111 | // Looks like this EXE comes from a different source than the install 112 | // receipt 113 | if current_exe_root != receipt_root { 114 | return Ok(false); 115 | } 116 | 117 | Ok(true) 118 | } 119 | } 120 | 121 | /// Returns a Vec of possible receipt locations, beginning with 122 | /// `XDG_CONFIG_HOME` (if set). 123 | pub(crate) fn get_config_paths(app_name: &str) -> AxoupdateResult> { 124 | let mut potential_homes = vec![]; 125 | 126 | if env::var("AXOUPDATER_CONFIG_WORKING_DIR").is_ok() { 127 | Ok(vec![Utf8PathBuf::try_from(current_dir()?)?]) 128 | } else if let Ok(path) = env::var("AXOUPDATER_CONFIG_PATH") { 129 | Ok(vec![Utf8PathBuf::from(path)]) 130 | } else { 131 | let xdg_home = env::var("XDG_CONFIG_HOME") 132 | .ok() 133 | .map(Utf8PathBuf::from) 134 | .map(|h| h.join(app_name)); 135 | if let Some(home) = &xdg_home { 136 | if home.exists() { 137 | potential_homes.push(home.to_owned()); 138 | } 139 | } 140 | let home = if cfg!(windows) { 141 | env::var("LOCALAPPDATA") 142 | .map(PathBuf::from) 143 | .map(|h| h.join(app_name)) 144 | .ok() 145 | } else { 146 | homedir::my_home()?.map(|path| path.join(".config").join(app_name)) 147 | }; 148 | if let Some(home) = home { 149 | potential_homes.push(Utf8PathBuf::try_from(home)?); 150 | } 151 | 152 | if potential_homes.is_empty() { 153 | return Err(AxoupdateError::NoHome {}); 154 | } 155 | 156 | Ok(potential_homes) 157 | } 158 | } 159 | 160 | /// Iterates through the list of possible receipt locations from 161 | /// `get_config_paths` and returns the first that contains a valid receipt. 162 | pub(crate) fn get_receipt_path(app_name: &str) -> AxoupdateResult> { 163 | for receipt_prefix in get_config_paths(app_name)? { 164 | let install_receipt_path = receipt_prefix.join(format!("{app_name}-receipt.json")); 165 | if install_receipt_path.exists() { 166 | return Ok(Some(install_receipt_path)); 167 | } 168 | } 169 | 170 | Ok(None) 171 | } 172 | 173 | fn load_receipt_from_path(install_receipt_path: &Utf8PathBuf) -> AxoupdateResult { 174 | Ok(SourceFile::load_local(install_receipt_path)?.deserialize_json()?) 175 | } 176 | 177 | fn load_receipt_for(app_name: &str) -> AxoupdateResult { 178 | let Some(install_receipt_path) = get_receipt_path(app_name)? else { 179 | return Err(AxoupdateError::NoReceipt { 180 | app_name: app_name.to_owned(), 181 | }); 182 | }; 183 | 184 | load_receipt_from_path(&install_receipt_path).map_err(|_| AxoupdateError::ReceiptLoadFailed { 185 | app_name: app_name.to_owned(), 186 | }) 187 | } 188 | -------------------------------------------------------------------------------- /axoupdater/src/errors.rs: -------------------------------------------------------------------------------- 1 | //! Errors 2 | 3 | use miette::Diagnostic; 4 | use thiserror::Error; 5 | 6 | /// An alias for Result 7 | pub type AxoupdateResult = std::result::Result; 8 | 9 | /// An enum representing all of this crate's errors 10 | #[derive(Debug, Error, Diagnostic)] 11 | pub enum AxoupdateError { 12 | /// Passed through from Reqwest 13 | #[error(transparent)] 14 | Reqwest(#[from] axoasset::reqwest::Error), 15 | 16 | /// Passed through from std::io::Error 17 | #[error(transparent)] 18 | Io(#[from] std::io::Error), 19 | 20 | /// Passed through from Camino 21 | #[error(transparent)] 22 | CaminoPathBuf(#[from] camino::FromPathBufError), 23 | 24 | /// Passed through from homedir 25 | #[error(transparent)] 26 | Homedir(#[from] homedir::GetHomeError), 27 | 28 | /// Passed through from axoasset 29 | #[error(transparent)] 30 | Axoasset(#[from] axoasset::AxoassetError), 31 | 32 | /// Passed through from axoprocess 33 | #[error(transparent)] 34 | Axoprocess(#[from] axoprocess::AxoprocessError), 35 | 36 | /// Passed through from axotag 37 | #[error(transparent)] 38 | Axotag(#[from] axotag::errors::TagError), 39 | 40 | /// Passed through from gazenot 41 | #[cfg(feature = "axo_releases")] 42 | #[error(transparent)] 43 | Gazenot(#[from] gazenot::error::GazenotError), 44 | 45 | /// Failed to parse a version 46 | #[error(transparent)] 47 | Version(#[from] axotag::semver::Error), 48 | 49 | /// Failed to parse a URL 50 | #[error(transparent)] 51 | UrlParseError(#[from] url::ParseError), 52 | 53 | /// Failure when converting a PathBuf to a Utf8PathBuf 54 | #[error("An internal error occurred when decoding path `{:?}' to utf8", path)] 55 | #[diagnostic(help("This probably isn't your fault; please open an issue!"))] 56 | CaminoConversionFailed { 57 | /// The path which Camino failed to convert 58 | path: std::path::PathBuf, 59 | }, 60 | 61 | /// Indicates that the only updates available are located at a source 62 | /// this crate isn't configured to support. This is returned if the 63 | /// appropriate source is disabled via features. 64 | #[error("Release is located on backend {backend}, but it's not enabled")] 65 | #[diagnostic(help("This probably isn't your fault; please open an issue!"))] 66 | BackendDisabled { 67 | /// The name of the backend 68 | backend: String, 69 | }, 70 | 71 | /// Indicates that axoupdater wasn't able to determine the config file path 72 | /// for this app. This path is where install receipts are located. 73 | #[error("Unable to determine config file path for app {app_name}!")] 74 | #[diagnostic(help("This probably isn't your fault; please open an issue!"))] 75 | ConfigFetchFailed { 76 | /// This app's name 77 | app_name: String, 78 | }, 79 | 80 | /// Indicates that the install receipt for this app couldn't be read. 81 | #[error("Unable to read installation information for app {app_name}.")] 82 | #[diagnostic(help("This probably isn't your fault; please open an issue!"))] 83 | ReceiptLoadFailed { 84 | /// This app's name 85 | app_name: String, 86 | }, 87 | 88 | /// Not a generic receipt load failure, but the receipt itself doesn't exist. 89 | #[error("Unable to load receipt for app {app_name}")] 90 | #[diagnostic(help( 91 | "This may indicate that this installation of {app_name} was installed via a method that's not eligible for upgrades." 92 | ))] 93 | NoReceipt { 94 | /// This app's name 95 | app_name: String, 96 | }, 97 | 98 | /// Indicates that this app's name couldn't be determined when trying 99 | /// to autodetect it. 100 | #[error("Unable to determine the name of the app to update")] 101 | #[diagnostic(help("This probably isn't your fault; please open an issue!"))] 102 | NoAppName {}, 103 | 104 | /// Indicates that no app name was specified before the updater process began. 105 | #[error("No app name was configured for this updater")] 106 | #[diagnostic(help("This isn't your fault; please open an issue!"))] 107 | NoAppNamePassed {}, 108 | 109 | /// Indicates that the home directory couldn't be determined. 110 | #[error("Unable to fetch your home directory")] 111 | #[diagnostic(help("This may not be your fault; please open an issue!"))] 112 | NoHome {}, 113 | 114 | /// Indicates that no installer is available for this OS when looking up 115 | /// the latest release. 116 | #[error("Unable to find an installer for your OS")] 117 | NoInstallerForPackage {}, 118 | 119 | /// Indicates that no stable releases exist for the app being updated. 120 | #[error("There are no stable releases available for {app_name}")] 121 | NoStableReleases { 122 | /// This app's name 123 | app_name: String, 124 | }, 125 | 126 | /// Indicates that no releases exist for this app at all. 127 | #[error("No releases were found for the app {app_name} in workspace {name}")] 128 | ReleaseNotFound { 129 | /// The workspace's name 130 | name: String, 131 | /// This app's name 132 | app_name: String, 133 | }, 134 | 135 | /// Indicates that no releases exist for this app at all. 136 | #[error("The version {version} was not found for the app {app_name} in workspace {name}")] 137 | VersionNotFound { 138 | /// The workspace's name 139 | name: String, 140 | /// This app's name 141 | app_name: String, 142 | /// The version we failed to find 143 | version: String, 144 | }, 145 | 146 | /// This error catches an edge case where the axoupdater executable was run 147 | /// under its default filename, "axoupdater", instead of being installed 148 | /// under an app-specific name. 149 | #[error("App name calculated as `axoupdater'")] 150 | #[diagnostic(help( 151 | "This probably isn't what you meant to update; was the updater installed correctly?" 152 | ))] 153 | UpdateSelf {}, 154 | 155 | /// Indicates that a mandatory config field wasn't specified before the 156 | /// update process ran. 157 | #[error("The updater isn't properly configured")] 158 | #[diagnostic(help("Missing configuration value for {}", missing_field))] 159 | NotConfigured { 160 | /// The name of the missing field 161 | missing_field: String, 162 | }, 163 | 164 | /// Indicates the installation failed for some reason we're not sure of 165 | #[error("The installation failed. Output from the installer: {}\n{}", stdout.clone().unwrap_or_default(), stderr.clone().unwrap_or_default())] 166 | InstallFailed { 167 | /// The status code from the underlying process, if any 168 | status: Option, 169 | /// The stdout, decoded to UTF-8. This will be None if it was piped 170 | /// to the terminal when running the installer. 171 | stdout: Option, 172 | /// The stderr, decoded to UTF-8. This will be None if it was piped 173 | /// to the terminal when running the installer. 174 | stderr: Option, 175 | }, 176 | 177 | /// self_replace/self_delete failed 178 | #[error( 179 | "Cleaning up the previous version failed; a copy of the old version has been left behind." 180 | )] 181 | #[diagnostic(help("This probably isn't your fault; please open an issue at https://github.com/axodotdev/axoupdater!"))] 182 | CleanupFailed {}, 183 | 184 | /// User passed conflicting GitHub API environment variables 185 | #[error("Both {ghe_env_var} and {github_env_var} have been set in the environment")] 186 | #[diagnostic(help("These variables are mutually exclusive; please pick one."))] 187 | MultipleGitHubAPIs { 188 | /// The GitHub Enterprise env var 189 | ghe_env_var: String, 190 | /// The GitHub env var 191 | github_env_var: String, 192 | }, 193 | 194 | /// Couldn't parse the text domain (could be an IP, etc.) 195 | #[error("Unable to parse the domain from the passed url: {url}")] 196 | #[diagnostic(help("The {env_var} variable only takes domains. If you're using an IP, we recommend the GitHub Enterprise-style variable: {ghe_env_var}"))] 197 | GitHubDomainParseError { 198 | /// The GitHub env var 199 | env_var: String, 200 | /// The GitHub Enterprise env var 201 | ghe_env_var: String, 202 | /// The supplied URL 203 | url: String, 204 | }, 205 | } 206 | -------------------------------------------------------------------------------- /axoupdater/src/test/helpers.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, PathBuf}, 3 | process::{Command, Stdio}, 4 | }; 5 | 6 | use crate::{receipt::get_config_paths, ReleaseSourceType}; 7 | 8 | static RECEIPT_TEMPLATE: &str = r#"{"binaries":[BINARIES],"install_prefix":"INSTALL_PREFIX","provider":{"source":"cargo-dist","version":"0.10.0-prerelease.1"},"source":{"app_name":"APP_NAME","name":"PACKAGE","owner":"OWNER","release_type":"RELEASE_TYPE"},"version":"VERSION"}"#; 9 | 10 | /// Generates an install receipt given the specified fields and returns it as a string. 11 | fn install_receipt( 12 | app_name: &str, 13 | package: &str, 14 | owner: &str, 15 | binaries: &[String], 16 | version: &str, 17 | prefix: &str, 18 | release_type: &ReleaseSourceType, 19 | ) -> String { 20 | let binaries = binaries 21 | .iter() 22 | .map(|name| format!(r#""{name}""#)) 23 | .collect::>() 24 | .join(", "); 25 | RECEIPT_TEMPLATE 26 | .replace("BINARIES", &binaries) 27 | .replace("PACKAGE", package) 28 | .replace("OWNER", owner) 29 | .replace("APP_NAME", app_name) 30 | .replace("INSTALL_PREFIX", &prefix.replace('\\', "\\\\")) 31 | .replace("VERSION", version) 32 | .replace("RELEASE_TYPE", &release_type.to_string()) 33 | } 34 | 35 | /// Generates an install receipt given the specified fields and writes it to disk at the appropriate location in `config_path`. The path to the new file is returned. 36 | #[allow(clippy::too_many_arguments)] 37 | fn write_receipt( 38 | app_name: &str, 39 | package: &str, 40 | owner: &str, 41 | binaries: &[String], 42 | version: &str, 43 | prefix: &Path, 44 | config_path: &PathBuf, 45 | release_type: &ReleaseSourceType, 46 | ) -> std::io::Result { 47 | let contents = install_receipt( 48 | app_name, 49 | package, 50 | owner, 51 | binaries, 52 | version, 53 | &prefix.to_string_lossy(), 54 | release_type, 55 | ); 56 | let receipt_name = config_path.join(format!("{package}-receipt.json")); 57 | std::fs::create_dir_all(config_path)?; 58 | std::fs::write(&receipt_name, contents)?; 59 | 60 | Ok(receipt_name) 61 | } 62 | 63 | /// The arguments used for `perform_runtest`. 64 | pub struct RuntestArgs { 65 | /// The name of the app being tested. 66 | pub app_name: String, 67 | /// The name of the package/workspace being tested. In GitHub terms, this is the "name" of the owner/name repo format. 68 | pub package: String, 69 | /// The owner of the package being tested. In GitHub terms, this is the "owner" of the owner/name repo format. 70 | pub owner: String, 71 | /// The path to the executable being tested, usually the one from the `CARGO_BIN_EXE_` environment variable. 72 | pub bin: PathBuf, 73 | /// A list of all binaries installed by the app being tested. 74 | pub binaries: Vec, 75 | /// The arguments taken by the binary being tested. 76 | /// 77 | /// For example, for cargo dist, it's called as `cargo dist selfupdate --skip-init`, so this value is `vec!["dist", "selfupdate", "--skip-init"]`. 78 | pub args: Vec, 79 | /// The type of release to test, either GitHub or Axo Releases. 80 | pub release_type: ReleaseSourceType, 81 | } 82 | 83 | /// Actually installs your app and runs its updater. 84 | /// For detailed information on the arguments, see [`RuntestArgs`][]. 85 | /// 86 | /// This function performs several assertions of its own, then returns the path to which the binary was expected to have been installed in order to allow the caller to perform additional tests or assertions. 87 | /// Because it writes to real files outside a temporary directory, it's highly recommended that this only becalled within CI builds. 88 | /// 89 | /// Note that, at the moment, this always attempts to install to CARGO_HOME (~/.cargo/bin). 90 | pub fn perform_runtest(runtest_args: &RuntestArgs) -> PathBuf { 91 | let RuntestArgs { 92 | app_name, 93 | package, 94 | owner, 95 | bin, 96 | binaries, 97 | args, 98 | release_type, 99 | } = runtest_args; 100 | 101 | let basename = bin.file_name().unwrap(); 102 | let home = homedir::my_home().unwrap().unwrap(); 103 | 104 | let app_home = &home.join(".cargo").join("bin"); 105 | let app_path = &app_home.join(basename); 106 | 107 | let config_path = get_config_paths(app_name) 108 | .unwrap() 109 | // Accept whichever path comes first; it doesn't matter to us. 110 | .first() 111 | .expect("no possible legal config paths found!?") 112 | .to_owned() 113 | .into_std_path_buf(); 114 | 115 | // Ensure we delete any previous copy that may exist 116 | // at this path before we copy in our version. 117 | if app_path.exists() { 118 | std::fs::remove_file(app_path).unwrap(); 119 | } 120 | assert!(!app_path.exists()); 121 | 122 | // Install to the home directory 123 | std::fs::copy(bin, app_path).unwrap(); 124 | 125 | // Create a fake install receipt 126 | // We lie about being a very old version so we always 127 | // consider upgrading to something. 128 | write_receipt( 129 | app_name, 130 | package, 131 | owner, 132 | binaries.as_slice(), 133 | "0.0.1", 134 | app_home, 135 | &config_path, 136 | release_type, 137 | ) 138 | .unwrap(); 139 | 140 | let output = Command::new(app_path) 141 | .args(args) 142 | .stdout(Stdio::piped()) 143 | .stderr(Stdio::piped()) 144 | .output() 145 | .unwrap(); 146 | 147 | let out_str = String::from_utf8_lossy(&output.stdout); 148 | let err_str = String::from_utf8_lossy(&output.stderr); 149 | 150 | assert!( 151 | output.status.success(), 152 | "status code: {}, stdout: {out_str}; stderr: {err_str}", 153 | output.status 154 | ); 155 | 156 | app_path.to_owned() 157 | } 158 | 159 | // Who tests the testers........ 160 | #[test] 161 | fn test_receipt_generation() { 162 | let expected = r#"{"binaries":["cargo-dist"],"install_prefix":"/tmp/prefix","provider":{"source":"cargo-dist","version":"0.10.0-prerelease.1"},"source":{"app_name":"cargo-dist","name":"cargo-dist","owner":"axodotdev","release_type":"github"},"version":"0.5.0"}"#; 163 | 164 | let actual = install_receipt( 165 | "cargo-dist", 166 | "cargo-dist", 167 | "axodotdev", 168 | &["cargo-dist".to_owned()], 169 | "0.5.0", 170 | "/tmp/prefix", 171 | &ReleaseSourceType::GitHub, 172 | ); 173 | assert_eq!(expected, actual); 174 | } 175 | 176 | #[test] 177 | fn test_receipt_different_app_package() { 178 | let expected = r#"{"binaries":["axolotlsay"],"install_prefix":"/tmp/prefix","provider":{"source":"cargo-dist","version":"0.10.0-prerelease.1"},"source":{"app_name":"axolotlsay","name":"cargodisttest","owner":"mistydemeo","release_type":"github"},"version":"0.5.0"}"#; 179 | 180 | let actual = install_receipt( 181 | "axolotlsay", 182 | "cargodisttest", 183 | "mistydemeo", 184 | &["axolotlsay".to_owned()], 185 | "0.5.0", 186 | "/tmp/prefix", 187 | &ReleaseSourceType::GitHub, 188 | ); 189 | assert_eq!(expected, actual); 190 | } 191 | 192 | #[test] 193 | fn test_receipt_multiple_binaries() { 194 | let expected = r#"{"binaries":["bin1", "bin2"],"install_prefix":"/tmp/prefix","provider":{"source":"cargo-dist","version":"0.10.0-prerelease.1"},"source":{"app_name":"axolotlsay","name":"cargodisttest","owner":"mistydemeo","release_type":"github"},"version":"0.5.0"}"#; 195 | 196 | let actual = install_receipt( 197 | "axolotlsay", 198 | "cargodisttest", 199 | "mistydemeo", 200 | &["bin1".to_owned(), "bin2".to_owned()], 201 | "0.5.0", 202 | "/tmp/prefix", 203 | &ReleaseSourceType::GitHub, 204 | ); 205 | assert_eq!(expected, actual); 206 | } 207 | 208 | #[test] 209 | fn test_receipt_different_alternate_release_type() { 210 | let expected = r#"{"binaries":["axolotlsay"],"install_prefix":"/tmp/prefix","provider":{"source":"cargo-dist","version":"0.10.0-prerelease.1"},"source":{"app_name":"axolotlsay","name":"cargodisttest","owner":"mistydemeo","release_type":"axodotdev"},"version":"0.5.0"}"#; 211 | 212 | let actual = install_receipt( 213 | "axolotlsay", 214 | "cargodisttest", 215 | "mistydemeo", 216 | &["axolotlsay".to_owned()], 217 | "0.5.0", 218 | "/tmp/prefix", 219 | &ReleaseSourceType::Axo, 220 | ); 221 | assert_eq!(expected, actual); 222 | } 223 | -------------------------------------------------------------------------------- /axoupdater/src/release/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use serde::Deserialize; 4 | 5 | use crate::{errors::*, AuthorizationTokens, AxoUpdater, UpdateRequest, Version}; 6 | 7 | #[cfg(feature = "axo_releases")] 8 | pub(crate) mod axodotdev; 9 | #[cfg(feature = "github_releases")] 10 | pub(crate) mod github; 11 | 12 | /// A struct representing a specific release, either from GitHub or Axo Releases. 13 | #[derive(Clone, Debug)] 14 | pub struct Release { 15 | /// The tag this release represents 16 | pub tag_name: String, 17 | /// The version this release represents 18 | pub version: Version, 19 | /// The name of the release 20 | pub name: String, 21 | /// The URL at which this release lists 22 | pub url: String, 23 | /// All assets associated with this release 24 | pub assets: Vec, 25 | /// Whether or not this release is a prerelease 26 | pub prerelease: bool, 27 | } 28 | 29 | /// Represents a specific asset inside a release. 30 | #[derive(Clone, Debug)] 31 | pub struct Asset { 32 | /// The URL at which this asset can be found 33 | pub url: String, 34 | /// The URL at which this asset can be downloaded 35 | pub browser_download_url: String, 36 | /// This asset's name 37 | pub name: String, 38 | } 39 | 40 | /// Where service this app's releases are hosted on 41 | #[derive(Clone, Debug, Deserialize, PartialEq)] 42 | #[serde(rename_all = "lowercase")] 43 | pub enum ReleaseSourceType { 44 | /// GitHub Releases 45 | GitHub, 46 | /// Axo Releases 47 | Axo, 48 | } 49 | 50 | impl fmt::Display for ReleaseSourceType { 51 | /// Returns a string representation of this ReleaseSourceType. 52 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 53 | match self { 54 | Self::GitHub => write!(f, "github"), 55 | Self::Axo => write!(f, "axodotdev"), 56 | } 57 | } 58 | } 59 | 60 | /// Information about the source of this app's releases 61 | #[derive(Clone, Debug, Deserialize)] 62 | pub struct ReleaseSource { 63 | /// Which hosting service to query for new releases 64 | pub release_type: ReleaseSourceType, 65 | /// Owner, in GitHub name-with-owner format 66 | pub owner: String, 67 | /// Name, in GitHub name-with-owner format 68 | pub name: String, 69 | /// The app's name; this can be distinct from the repository name above 70 | pub app_name: String, 71 | } 72 | 73 | impl AxoUpdater { 74 | /// Configures AxoUpdater to use a specific GitHub token when performing requests. 75 | /// This is useful in circumstances where the user may encounter rate 76 | /// limits, and is necessary to access private repositories. 77 | /// This must have the `repo` scope enabled. 78 | pub fn set_github_token(&mut self, token: &str) -> &mut AxoUpdater { 79 | self.tokens.github = Some(token.to_owned()); 80 | 81 | self 82 | } 83 | 84 | /// Configures AxoUpdater to use a specific Axo Releases token when performing requests. 85 | pub fn set_axo_token(&mut self, token: &str) -> &mut AxoUpdater { 86 | self.tokens.axodotdev = Some(token.to_owned()); 87 | 88 | self 89 | } 90 | 91 | pub(crate) async fn fetch_release(&mut self) -> AxoupdateResult<()> { 92 | let Some(app_name) = &self.name else { 93 | return Err(AxoupdateError::NotConfigured { 94 | missing_field: "app_name".to_owned(), 95 | }); 96 | }; 97 | let Some(source) = &self.source else { 98 | return Err(AxoupdateError::NotConfigured { 99 | missing_field: "source".to_owned(), 100 | }); 101 | }; 102 | 103 | let release = match self.version_specifier.to_owned() { 104 | UpdateRequest::Latest => { 105 | get_latest_stable_release( 106 | &source.name, 107 | &source.owner, 108 | &source.app_name, 109 | &source.release_type, 110 | &self.tokens, 111 | ) 112 | .await? 113 | } 114 | UpdateRequest::LatestMaybePrerelease => { 115 | get_latest_maybe_prerelease( 116 | &source.name, 117 | &source.owner, 118 | &source.app_name, 119 | &source.release_type, 120 | &self.tokens, 121 | ) 122 | .await? 123 | } 124 | UpdateRequest::SpecificTag(version) => { 125 | get_specific_tag( 126 | &source.name, 127 | &source.owner, 128 | &source.app_name, 129 | &source.release_type, 130 | &version, 131 | &self.tokens, 132 | ) 133 | .await? 134 | } 135 | UpdateRequest::SpecificVersion(version) => { 136 | get_specific_version( 137 | &source.name, 138 | &source.owner, 139 | &source.app_name, 140 | &source.release_type, 141 | &version.parse::()?, 142 | &self.tokens, 143 | ) 144 | .await? 145 | } 146 | }; 147 | 148 | let Some(release) = release else { 149 | return Err(AxoupdateError::NoStableReleases { 150 | app_name: app_name.to_owned(), 151 | }); 152 | }; 153 | 154 | self.requested_release = Some(release); 155 | 156 | Ok(()) 157 | } 158 | } 159 | 160 | pub(crate) async fn get_specific_version( 161 | name: &str, 162 | owner: &str, 163 | app_name: &str, 164 | release_type: &ReleaseSourceType, 165 | version: &Version, 166 | tokens: &AuthorizationTokens, 167 | ) -> AxoupdateResult> { 168 | let release = match release_type { 169 | #[cfg(feature = "github_releases")] 170 | ReleaseSourceType::GitHub => { 171 | github::get_specific_github_version(name, owner, app_name, version, &tokens.github) 172 | .await? 173 | } 174 | #[cfg(not(feature = "github_releases"))] 175 | ReleaseSourceType::GitHub => { 176 | return Err(AxoupdateError::BackendDisabled { 177 | backend: "github".to_owned(), 178 | }) 179 | } 180 | #[cfg(feature = "axo_releases")] 181 | ReleaseSourceType::Axo => { 182 | axodotdev::get_specific_axo_version(name, owner, app_name, version).await? 183 | } 184 | #[cfg(not(feature = "axo_releases"))] 185 | ReleaseSourceType::Axo => { 186 | return Err(AxoupdateError::BackendDisabled { 187 | backend: "axodotdev".to_owned(), 188 | }) 189 | } 190 | }; 191 | 192 | Ok(Some(release)) 193 | } 194 | 195 | pub(crate) async fn get_specific_tag( 196 | name: &str, 197 | owner: &str, 198 | app_name: &str, 199 | release_type: &ReleaseSourceType, 200 | tag: &str, 201 | tokens: &AuthorizationTokens, 202 | ) -> AxoupdateResult> { 203 | let release = match release_type { 204 | #[cfg(feature = "github_releases")] 205 | ReleaseSourceType::GitHub => { 206 | github::get_specific_github_tag(name, owner, app_name, tag, &tokens.github).await? 207 | } 208 | #[cfg(not(feature = "github_releases"))] 209 | ReleaseSourceType::GitHub => { 210 | return Err(AxoupdateError::BackendDisabled { 211 | backend: "github".to_owned(), 212 | }) 213 | } 214 | #[cfg(feature = "axo_releases")] 215 | ReleaseSourceType::Axo => { 216 | axodotdev::get_specific_axo_tag(name, owner, app_name, tag).await? 217 | } 218 | #[cfg(not(feature = "axo_releases"))] 219 | ReleaseSourceType::Axo => { 220 | return Err(AxoupdateError::BackendDisabled { 221 | backend: "axodotdev".to_owned(), 222 | }) 223 | } 224 | }; 225 | 226 | Ok(Some(release)) 227 | } 228 | 229 | pub(crate) async fn get_release_list( 230 | name: &str, 231 | owner: &str, 232 | app_name: &str, 233 | release_type: &ReleaseSourceType, 234 | tokens: &AuthorizationTokens, 235 | ) -> AxoupdateResult> { 236 | let releases = match release_type { 237 | #[cfg(feature = "github_releases")] 238 | ReleaseSourceType::GitHub => { 239 | github::get_github_releases(name, owner, app_name, &tokens.github).await? 240 | } 241 | #[cfg(not(feature = "github_releases"))] 242 | ReleaseSourceType::GitHub => { 243 | return Err(AxoupdateError::BackendDisabled { 244 | backend: "github".to_owned(), 245 | }) 246 | } 247 | #[cfg(feature = "axo_releases")] 248 | ReleaseSourceType::Axo => axodotdev::get_axo_releases(name, owner, app_name).await?, 249 | #[cfg(not(feature = "axo_releases"))] 250 | ReleaseSourceType::Axo => { 251 | return Err(AxoupdateError::BackendDisabled { 252 | backend: "axodotdev".to_owned(), 253 | }) 254 | } 255 | }; 256 | Ok(releases) 257 | } 258 | 259 | /// Get the latest stable release 260 | pub(crate) async fn get_latest_stable_release( 261 | name: &str, 262 | owner: &str, 263 | app_name: &str, 264 | release_type: &ReleaseSourceType, 265 | tokens: &AuthorizationTokens, 266 | ) -> AxoupdateResult> { 267 | // GitHub has an API to request the latest stable release. 268 | // If we're looking up a GitHub release, we can use that. 269 | // This cuts down on our API requests compared to the paginated release list 270 | // we do below. 271 | // Note that abyss has an API for this, but gazenot doesn't expose it yet; 272 | // we can expand this pattern to Axo Releases in a later release. 273 | // It's less critical for that path because the rate limits are less of a 274 | // blocker. 275 | #[cfg(feature = "github_releases")] 276 | if release_type == &ReleaseSourceType::GitHub { 277 | if let Ok(Some(release)) = 278 | github::get_latest_github_release(name, owner, app_name, &tokens.github).await 279 | { 280 | return Ok(Some(release)); 281 | } 282 | } 283 | 284 | let releases = get_release_list(name, owner, app_name, release_type, tokens).await?; 285 | Ok(releases 286 | .into_iter() 287 | .filter(|r| !r.prerelease) 288 | .max_by_key(|r| r.version.clone())) 289 | } 290 | 291 | /// Get the latest release, allowing for prereleases 292 | pub(crate) async fn get_latest_maybe_prerelease( 293 | name: &str, 294 | owner: &str, 295 | app_name: &str, 296 | release_type: &ReleaseSourceType, 297 | tokens: &AuthorizationTokens, 298 | ) -> AxoupdateResult> { 299 | let releases = get_release_list(name, owner, app_name, release_type, tokens).await?; 300 | Ok(releases.into_iter().max_by_key(|r| r.version.clone())) 301 | } 302 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Axo Developer Co. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version 0.9.1 (2025-07-20) 2 | 3 | This release updates several dependencies. 4 | 5 | # Version 0.9.0 (2024-12-19) 6 | 7 | This release adds support for `XDG_CONFIG_HOME` as the location for install 8 | receipts. If this variable is set and the receipt is located within this path, 9 | it overrides the default location of `$HOME/.config` (Mac and Linux) or 10 | `%LOCALAPPDATA%` (Windows). Install receipts will be created in this path when 11 | running installers created by dist 0.27.0 or later if `XDG_CONFIG_HOME` is set. 12 | 13 | This release also adds infrastructure to support app renaming when running as 14 | a library. There are two new features: 15 | 16 | * It's now possible to load receipts for alternate app names, not just the one 17 | that a given instance of `AxoUpdater` was instantiated for. This can be done 18 | by running `AxoUpdater::load_receipt_for(app_name)`. 19 | * It's now possible to change the name a given `AxoUpdater` instance is for. 20 | This can be done by running `AxoUpdater::set_name(app_name)`. This can 21 | override the name that was loaded from an app receipt. 22 | 23 | For example, if your app is changing from `oldname` to `newname`, you might set 24 | up `AxoUpdater` like this: 25 | 26 | ```rust 27 | // Instantiate the updater class with the new app name 28 | let mut updater = AxoUpdater::new_for("newname"); 29 | 30 | // First, try to check for a "newname" receipt 31 | // (this might be a post-rename release) 32 | if updater.load_receipt_as("newname").is_err() { 33 | // If that didn't work, try again as "oldname" 34 | if updater 35 | .load_receipt_as("oldname") 36 | .map(|updater| updater.set_name("newname")) 37 | .is_err() 38 | { 39 | eprintln!("Unable to load install receipt!"); 40 | } 41 | } 42 | ``` 43 | 44 | # Version 0.8.2 (2024-12-03) 45 | 46 | This release adds `x86_64-pc-windows-gnu` to the list of targets for which we 47 | publish binaries. It also contains a few small changes to the library: 48 | 49 | * The new `AxoUpdater::VERSION` constant exposes axoupdater's version. 50 | * The `AxoUpdater::install_prefix_root` method is now public. 51 | 52 | # Version 0.8.1 (2024-10-31) 53 | 54 | This release fixes an issue with the previous release in which 55 | `{app_name}_INSTALLER_GITHUB_BASE_URL` wouldn't respect the port specified by 56 | the user. 57 | 58 | # Version 0.8.0 (2024-10-31) 59 | 60 | This release adds support for overriding the GitHub API URL using new environment variables: 61 | 62 | * `{app_name}_INSTALLER_GITHUB_BASE_URL` 63 | * `{app_name}_INSTALLER_GHE_BASE_URL` 64 | 65 | For more information, see the [dist installer docs](https://opensource.axo.dev/cargo-dist/book/installers/usage.html#artifact-location). 66 | 67 | - impl 68 | - @gaborbernat [Allow changing the GitHub API base URL via the INSTALLER_DOWNLOAD_URL env var](https://github.com/axodotdev/axoupdater/pull/199) 69 | - @mistydemeo [feat: use new custom env vars](https://github.com/axodotdev/axoupdater/pull/201) 70 | 71 | # Version 0.7.3 (2024-10-22) 72 | 73 | This release contains improvements on Windows, ensuring that temporary files and 74 | files from older versions are correctly cleaned up. 75 | 76 | # Version 0.7.2 (2024-09-11) 77 | 78 | This release fixes a bug that caused axoupdater to return a confusing error 79 | message if it attempted to load an install receipt containing a reference to an 80 | install path which no longer exists. 81 | 82 | # Version 0.7.1 (2024-08-28) 83 | 84 | This release improves compatibility with certain Windows configurations by setting the execution policy before running the new installer. A similar change is shipped in cargo-dist 0.21.2. 85 | 86 | This release also contains a forward-looking change to ensure compatibility with installers produced by future versions of cargo-dist ([#169](https://github.com/axodotdev/axoupdater/pull/169)). 87 | 88 | # Version 0.7.0 (2024-07-25) 89 | 90 | This release improves debugging for users who use axoupdater as a crate and who 91 | disable printing stdout/stderr from the installer. If the installer runs but 92 | fails, we now return a new error type which contains the stderr/stdout and exit 93 | status from the underlying installer; this can be used by callers to help 94 | identify what failed. 95 | 96 | This release also introduces a debugging feature for the standalone installer. 97 | It's now possible to override which installer to use by setting the 98 | `AXOUPDATER_INSTALLER_PATH` environment variable to the path on disk of the 99 | installer to use. A similar feature was already available to library users 100 | using the `AxoUpdater::configure_installer_path` method. 101 | 102 | 103 | # Version 0.6.9 (2024-07-18) 104 | 105 | This release fixes a bug in which axoupdater could pick the wrong installer when handling releases containing more than one app. 106 | 107 | 108 | # Version 0.6.8 (2024-07-05) 109 | 110 | This release updates cargo-dist. 111 | 112 | 113 | # Version 0.6.7 (2024-07-05) 114 | 115 | This release adds an experimental opt-in tls_native_roots feature. 116 | 117 | 118 | # Version 0.6.6 (2024-06-12) 119 | 120 | This release updates several dependencies. 121 | 122 | # Version 0.6.5 (2024-05-30) 123 | 124 | This release makes us prefer creating temporary files nested under the install directory, avoiding issues with renaming files across filesystems, in cases where the system tempdir is on a separate logic drive. 125 | 126 | 127 | # Version 0.6.4 (2024-05-14) 128 | 129 | This release contains two bugfixes for the previous release: 130 | 131 | * Improved path handling in `check_receipt_is_for_this_executable`. 132 | * Fixed an issue where checking cargo-dist versions from the receipt would fail if the cargo-dist version was a prerelease. 133 | 134 | # Version 0.6.3 (2024-05-14) 135 | 136 | This release removes a temporary workaround for an upstream cargo-dist bug, removing an ambiguity in install-receipts that pointed at a dir named "bin" for cargo-dist 0.15.0 and later. 137 | 138 | # Version 0.6.2 (2024-05-09) 139 | 140 | This release fixes a bug which could prevent fetching release information from 141 | GitHub for repositories with under 30 releases ([#106](https://github.com/axodotdev/axoupdater/pull/106)). 142 | 143 | # Version 0.6.1 (2024-05-02) 144 | 145 | This release reexports the `Version` type to simplify calling `set_current_version`. 146 | 147 | # Version 0.6.0 (2024-05-01) 148 | 149 | This release contains several new features: 150 | 151 | - It's now possible to specify the path to install to via the new `set_install_dir` method. This is especially useful in cases where no install receipt will be loaded, since this value is required for performing full updates. 152 | - It's now possible to skip querying for new versions and force updates to always be performed; this is done by calling `always_update(bool)` on the updater. This is useful in cases where the version of the installed copy of the software to be updated isn't known, or when using axoupdater to perform a first-time install. 153 | - It's now possible to specify a GitHub token via the `set_github_token` method. This is useful when the repo to query is private, or in order to opt into the higher rate limit granted to authenticated requests. AxoUpdater uses this in its own tests. The standalone `axoupdater` executable uses this feature by reading optional tokens specified in the `AXOUPDATER_GITHUB_TOKEN` environment variable. 154 | 155 | # Version 0.5.1 (2024-04-16) 156 | 157 | This release relaxes the range of the axoasset dependency. 158 | 159 | # Version 0.5.0 (2024-04-11) 160 | 161 | This release contains a few new features targeted primarily at testing environments. 162 | 163 | - A new feature enables forcing axoupdater to call a custom installer at a specified path on disk instead of downloading a new release. This is only expected to be useful for testing. ([#77](https://github.com/axodotdev/axoupdater/pull/77)) 164 | - It's now possible to query for a new release without performing an update. ([#78](https://github.com/axodotdev/axoupdater/pull/78)) 165 | - It's now possible to manually specify the release source used for querying new releases without needing to read it from an install receipt. ([#81](https://github.com/axodotdev/axoupdater/pull/81)) 166 | 167 | # Version 0.4.1 (2024-04-10) 168 | 169 | This is a minor patch release to preserve more http error info in cases where GitHub is flaking out ([#80](https://github.com/axodotdev/axoupdater/pull/80)). 170 | 171 | # Version 0.4.0 (2024-04-08) 172 | 173 | This release contains a few new features and fixes: 174 | 175 | - Pagination has been implemented for the GitHub API, making it possible to query for specific releases older than the 30 most recent versions. ([#70](https://github.com/axodotdev/axoupdater/pull/70) 176 | - Improved version parsing and handling has been adding, ensuring that axoupdater will no longer try to pick an older stable version if the user is already running on a newer release. ([#72](https://github.com/axodotdev/axoupdater/pull/72)) 177 | - Added a test helper to simplify end-to-end self-updater tests for users of the axoupdater library. ([#76](https://github.com/axodotdev/axoupdater/pull/76)) 178 | 179 | # Version 0.3.6 (2024-04-05) 180 | 181 | This is a minor bugfix release. It updates the ordering of axo releases queries to reflect changes to the deployed API. 182 | 183 | # Version 0.3.5 (2024-04-05) 184 | 185 | This is a minor bugfix release. It makes us try to temporarily rename the current executable on windows, in case we're about to overwrite it. 186 | 187 | # Version 0.3.4 (2024-04-04) 188 | 189 | This is a minor bugfix release. It fixes an issue which would cause Windows updates to fail if the parent process is PowerShell Core. 190 | 191 | # Version 0.3.3 (2024-03-21) 192 | 193 | This is a minor bugfix release. It relaxes the reqwest dependency, which had been bumped to 0.12.0 in the previous release. It will now accept either 0.11.0 or any later version. 194 | 195 | # Version 0.3.2 (2024-03-21) 196 | 197 | This is a minor bugfix release: 198 | 199 | * more robust behaviour when paired with installers built with cargo-dist 0.12.0 (not yet released) 200 | * fix for an issue on windows where the installer would never think the receipt matched the binary 201 | 202 | # Version 0.3.1 (2024-03-18) 203 | 204 | This is a minor bugfix release which fixes loading install receipts which contain UTF-8 byte order marks. 205 | 206 | # Version 0.3.0 (2024-03-08) 207 | 208 | This release contains several bugfixes and improvements: 209 | 210 | - `axoupdater` now compares the path to which the running program was installed to the path it locates in the install receipt, and declines to upgrade if they're not equivalent. This fixes an issue where a user who had installed a copy with an installer which produced an install receipt and a second copy from a package manager would be prompted to upgrade even on the package manager-provided version. 211 | - The `run()` and `run_sync()` methods now provide information on the upgrade that they performed. If the upgrade was performed, it returns the old and new versions and the tag that the new version was built from. 212 | - It's now possible to silence stdout and stderr from the underlying installer when using `axoupdater` as a library. 213 | 214 | # Version 0.2.0 (2024-03-06) 215 | 216 | This release makes a breaking change to the library API. `run()` and `is_update_needed()` are now both async methods; new `run_sync()` and `is_update_needed_sync()` methods have been added which replicate the old behaviour. This should make it easier to incorporate the axoupdater library into asynchronous applications, especially applications which already use tokio. 217 | 218 | To use the blocking methods, enable the `blocking` feature when importing this crate as a library. 219 | 220 | # Version 0.1.0 (2024-03-01) 221 | 222 | This is the initial release of axoupdater, including both the standalone binary and the library for embedding in other binaries. 223 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist 2 | # 3 | # Copyright 2022-2024, axodotdev 4 | # SPDX-License-Identifier: MIT or Apache-2.0 5 | # 6 | # CI that: 7 | # 8 | # * checks for a Git Tag that looks like a release 9 | # * builds artifacts with dist (archives, installers, hashes) 10 | # * uploads those artifacts to temporary workflow zip 11 | # * on success, uploads the artifacts to a GitHub Release 12 | # 13 | # Note that the GitHub Release will be created with a generated 14 | # title/body based on your changelogs. 15 | 16 | name: Release 17 | permissions: 18 | "contents": "write" 19 | 20 | # This task will run whenever you push a git tag that looks like a version 21 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 22 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 23 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 24 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 25 | # 26 | # If PACKAGE_NAME is specified, then the announcement will be for that 27 | # package (erroring out if it doesn't have the given version or isn't dist-able). 28 | # 29 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 30 | # (dist-able) packages in the workspace with that version (this mode is 31 | # intended for workspaces with only one dist-able package, or with all dist-able 32 | # packages versioned/released in lockstep). 33 | # 34 | # If you push multiple tags at once, separate instances of this workflow will 35 | # spin up, creating an independent announcement for each one. However, GitHub 36 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 37 | # mistake. 38 | # 39 | # If there's a prerelease-style suffix to the version, then the release(s) 40 | # will be marked as a prerelease. 41 | on: 42 | pull_request: 43 | push: 44 | tags: 45 | - '**[0-9]+.[0-9]+.[0-9]+*' 46 | 47 | jobs: 48 | # Run 'dist plan' (or host) to determine what tasks we need to do 49 | plan: 50 | runs-on: "ubuntu-22.04" 51 | outputs: 52 | val: ${{ steps.plan.outputs.manifest }} 53 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 54 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 55 | publishing: ${{ !github.event.pull_request }} 56 | env: 57 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | steps: 59 | - uses: actions/checkout@v4 60 | with: 61 | submodules: recursive 62 | - name: Install dist 63 | # we specify bash to get pipefail; it guards against the `curl` command 64 | # failing. otherwise `sh` won't catch that `curl` returned non-0 65 | shell: bash 66 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.28.1/cargo-dist-installer.sh | sh" 67 | - name: Cache dist 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: cargo-dist-cache 71 | path: ~/.cargo/bin/dist 72 | # sure would be cool if github gave us proper conditionals... 73 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 74 | # functionality based on whether this is a pull_request, and whether it's from a fork. 75 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 76 | # but also really annoying to build CI around when it needs secrets to work right.) 77 | - id: plan 78 | run: | 79 | dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 80 | echo "dist ran successfully" 81 | cat plan-dist-manifest.json 82 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 83 | - name: "Upload dist-manifest.json" 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: artifacts-plan-dist-manifest 87 | path: plan-dist-manifest.json 88 | 89 | # Build and packages all the platform-specific things 90 | build-local-artifacts: 91 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 92 | # Let the initial task tell us to not run (currently very blunt) 93 | needs: 94 | - plan 95 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 96 | strategy: 97 | fail-fast: false 98 | # Target platforms/runners are computed by dist in create-release. 99 | # Each member of the matrix has the following arguments: 100 | # 101 | # - runner: the github runner 102 | # - dist-args: cli flags to pass to dist 103 | # - install-dist: expression to run to install dist on the runner 104 | # 105 | # Typically there will be: 106 | # - 1 "global" task that builds universal installers 107 | # - N "local" tasks that build each platform's binaries and platform-specific installers 108 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 109 | runs-on: ${{ matrix.runner }} 110 | container: ${{ matrix.container && matrix.container.image || null }} 111 | env: 112 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 113 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 114 | steps: 115 | - name: enable windows longpaths 116 | run: | 117 | git config --global core.longpaths true 118 | - uses: actions/checkout@v4 119 | with: 120 | submodules: recursive 121 | - name: Install Rust non-interactively if not already installed 122 | if: ${{ matrix.container }} 123 | run: | 124 | if ! command -v cargo > /dev/null 2>&1; then 125 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 126 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 127 | fi 128 | - name: Install dist 129 | run: ${{ matrix.install_dist.run }} 130 | # Get the dist-manifest 131 | - name: Fetch local artifacts 132 | uses: actions/download-artifact@v4 133 | with: 134 | pattern: artifacts-* 135 | path: target/distrib/ 136 | merge-multiple: true 137 | - name: Install dependencies 138 | run: | 139 | ${{ matrix.packages_install }} 140 | - name: Build artifacts 141 | run: | 142 | # Actually do builds and make zips and whatnot 143 | dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 144 | echo "dist ran successfully" 145 | - id: cargo-dist 146 | name: Post-build 147 | # We force bash here just because github makes it really hard to get values up 148 | # to "real" actions without writing to env-vars, and writing to env-vars has 149 | # inconsistent syntax between shell and powershell. 150 | shell: bash 151 | run: | 152 | # Parse out what we just built and upload it to scratch storage 153 | echo "paths<> "$GITHUB_OUTPUT" 154 | dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" 155 | echo "EOF" >> "$GITHUB_OUTPUT" 156 | 157 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 158 | - name: "Upload artifacts" 159 | uses: actions/upload-artifact@v4 160 | with: 161 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 162 | path: | 163 | ${{ steps.cargo-dist.outputs.paths }} 164 | ${{ env.BUILD_MANIFEST_NAME }} 165 | 166 | # Build and package all the platform-agnostic(ish) things 167 | build-global-artifacts: 168 | needs: 169 | - plan 170 | - build-local-artifacts 171 | runs-on: "ubuntu-22.04" 172 | env: 173 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 174 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 175 | steps: 176 | - uses: actions/checkout@v4 177 | with: 178 | submodules: recursive 179 | - name: Install cached dist 180 | uses: actions/download-artifact@v4 181 | with: 182 | name: cargo-dist-cache 183 | path: ~/.cargo/bin/ 184 | - run: chmod +x ~/.cargo/bin/dist 185 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 186 | - name: Fetch local artifacts 187 | uses: actions/download-artifact@v4 188 | with: 189 | pattern: artifacts-* 190 | path: target/distrib/ 191 | merge-multiple: true 192 | - id: cargo-dist 193 | shell: bash 194 | run: | 195 | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 196 | echo "dist ran successfully" 197 | 198 | # Parse out what we just built and upload it to scratch storage 199 | echo "paths<> "$GITHUB_OUTPUT" 200 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 201 | echo "EOF" >> "$GITHUB_OUTPUT" 202 | 203 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 204 | - name: "Upload artifacts" 205 | uses: actions/upload-artifact@v4 206 | with: 207 | name: artifacts-build-global 208 | path: | 209 | ${{ steps.cargo-dist.outputs.paths }} 210 | ${{ env.BUILD_MANIFEST_NAME }} 211 | # Determines if we should publish/announce 212 | host: 213 | needs: 214 | - plan 215 | - build-local-artifacts 216 | - build-global-artifacts 217 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 218 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 219 | env: 220 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 221 | runs-on: "ubuntu-22.04" 222 | outputs: 223 | val: ${{ steps.host.outputs.manifest }} 224 | steps: 225 | - uses: actions/checkout@v4 226 | with: 227 | submodules: recursive 228 | - name: Install cached dist 229 | uses: actions/download-artifact@v4 230 | with: 231 | name: cargo-dist-cache 232 | path: ~/.cargo/bin/ 233 | - run: chmod +x ~/.cargo/bin/dist 234 | # Fetch artifacts from scratch-storage 235 | - name: Fetch artifacts 236 | uses: actions/download-artifact@v4 237 | with: 238 | pattern: artifacts-* 239 | path: target/distrib/ 240 | merge-multiple: true 241 | - id: host 242 | shell: bash 243 | run: | 244 | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 245 | echo "artifacts uploaded and released successfully" 246 | cat dist-manifest.json 247 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 248 | - name: "Upload dist-manifest.json" 249 | uses: actions/upload-artifact@v4 250 | with: 251 | # Overwrite the previous copy 252 | name: artifacts-dist-manifest 253 | path: dist-manifest.json 254 | # Create a GitHub Release while uploading all files to it 255 | - name: "Download GitHub Artifacts" 256 | uses: actions/download-artifact@v4 257 | with: 258 | pattern: artifacts-* 259 | path: artifacts 260 | merge-multiple: true 261 | - name: Cleanup 262 | run: | 263 | # Remove the granular manifests 264 | rm -f artifacts/*-dist-manifest.json 265 | - name: Create GitHub Release 266 | env: 267 | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" 268 | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" 269 | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" 270 | RELEASE_COMMIT: "${{ github.sha }}" 271 | run: | 272 | # Write and read notes from a file to avoid quoting breaking things 273 | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 274 | 275 | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* 276 | 277 | custom-publish-crates: 278 | needs: 279 | - plan 280 | - host 281 | if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} 282 | uses: ./.github/workflows/publish-crates.yml 283 | with: 284 | plan: ${{ needs.plan.outputs.val }} 285 | secrets: inherit 286 | # publish jobs get escalated permissions 287 | permissions: 288 | "id-token": "write" 289 | "packages": "write" 290 | 291 | announce: 292 | needs: 293 | - plan 294 | - host 295 | - custom-publish-crates 296 | # use "always() && ..." to allow us to wait for all publish jobs while 297 | # still allowing individual publish jobs to skip themselves (for prereleases). 298 | # "host" however must run to completion, no skipping allowed! 299 | if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') }} 300 | runs-on: "ubuntu-22.04" 301 | env: 302 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 303 | steps: 304 | - uses: actions/checkout@v4 305 | with: 306 | submodules: recursive 307 | -------------------------------------------------------------------------------- /axoupdater/src/release/github.rs: -------------------------------------------------------------------------------- 1 | //! Fetching and processing from GitHub Releases 2 | 3 | use super::{Asset, Release}; 4 | use crate::{app_name_to_env_var, errors::*}; 5 | use axoasset::reqwest::{ 6 | self, 7 | header::{ACCEPT, USER_AGENT}, 8 | }; 9 | use axotag::{parse_tag, Version}; 10 | use serde::{Deserialize, Serialize}; 11 | use std::env; 12 | use url::Url; 13 | 14 | fn github_api(app_name: &str) -> AxoupdateResult { 15 | let formatted_app_name = app_name_to_env_var(app_name); 16 | let ghe_env_var = format!("{}_INSTALLER_GHE_BASE_URL", formatted_app_name); 17 | let github_env_var = format!("{}_INSTALLER_GITHUB_BASE_URL", formatted_app_name); 18 | 19 | if env::var(&ghe_env_var).is_ok() && env::var(&github_env_var).is_ok() { 20 | return Err(AxoupdateError::MultipleGitHubAPIs { 21 | ghe_env_var, 22 | github_env_var, 23 | }); 24 | } 25 | 26 | if let Ok(value) = env::var(&ghe_env_var) { 27 | let parsed = Url::parse(&value)?; 28 | Ok(parsed.join("api/v3")?.to_string()) 29 | } else if let Ok(value) = env::var(&github_env_var) { 30 | let parsed = Url::parse(&value)?; 31 | let Some(domain) = parsed.domain() else { 32 | return Err(AxoupdateError::GitHubDomainParseError { 33 | env_var: github_env_var, 34 | ghe_env_var, 35 | url: value, 36 | }); 37 | }; 38 | let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); 39 | Ok(format!("{}://api.{}{}", parsed.scheme(), domain, port)) 40 | } else { 41 | Ok("https://api.github.com".to_string()) 42 | } 43 | } 44 | 45 | /// A struct representing a specific GitHub Release 46 | #[derive(Clone, Debug, Deserialize, Serialize)] 47 | pub struct GithubRelease { 48 | /// The tag this release represents 49 | pub tag_name: String, 50 | /// The name of the release 51 | pub name: String, 52 | /// The URL at which this release lists 53 | pub url: String, 54 | /// All assets associated with this release 55 | pub assets: Vec, 56 | /// Whether or not this release is a prerelease 57 | pub prerelease: bool, 58 | } 59 | 60 | /// Represents a specific asset inside a GitHub Release. 61 | #[derive(Clone, Debug, Deserialize, Serialize)] 62 | pub struct GithubAsset { 63 | /// The URL at which this asset can be found 64 | pub url: String, 65 | /// The URL at which this asset can be downloaded 66 | pub browser_download_url: String, 67 | /// This asset's name 68 | pub name: String, 69 | } 70 | 71 | pub(crate) async fn get_latest_github_release( 72 | name: &str, 73 | owner: &str, 74 | app_name: &str, 75 | token: &Option, 76 | ) -> AxoupdateResult> { 77 | let client = reqwest::Client::new(); 78 | let api: String = github_api(app_name)?; 79 | let mut request = client 80 | .get(format!("{api}/repos/{owner}/{name}/releases/latest")) 81 | .header(ACCEPT, "application/json") 82 | .header( 83 | USER_AGENT, 84 | format!("axoupdate/{}", env!("CARGO_PKG_VERSION")), 85 | ); 86 | if let Some(token) = token { 87 | request = request.bearer_auth(token); 88 | } 89 | let gh_release: GithubRelease = request 90 | .send() 91 | .await? 92 | .error_for_status() 93 | .map_err(|_| AxoupdateError::NoStableReleases { 94 | app_name: app_name.to_owned(), 95 | })? 96 | .json() 97 | .await?; 98 | 99 | // Ensure that this release contains an installer asset; if not, it may be 100 | // a mismarked "latest" release that's not installable by us. 101 | // Returning None here will let us fall back to iterating releases. 102 | if !gh_release 103 | .assets 104 | .iter() 105 | .any(|asset| asset.name.starts_with(&format!("{app_name}-installer"))) 106 | { 107 | return Ok(None); 108 | } 109 | 110 | match Release::try_from_github(app_name, gh_release) { 111 | Ok(release) => Ok(Some(release)), 112 | Err(e) => Err(e), 113 | } 114 | } 115 | 116 | pub(crate) async fn get_specific_github_tag( 117 | name: &str, 118 | owner: &str, 119 | app_name: &str, 120 | tag: &str, 121 | token: &Option, 122 | ) -> AxoupdateResult { 123 | let client = reqwest::Client::new(); 124 | let api: String = github_api(app_name)?; 125 | let mut request = client 126 | .get(format!("{api}/repos/{owner}/{name}/releases/tags/{tag}")) 127 | .header(ACCEPT, "application/json") 128 | .header( 129 | USER_AGENT, 130 | format!("axoupdate/{}", env!("CARGO_PKG_VERSION")), 131 | ); 132 | if let Some(token) = token { 133 | request = request.bearer_auth(token); 134 | } 135 | let gh_release: GithubRelease = request 136 | .send() 137 | .await? 138 | .error_for_status() 139 | .map_err(|_| AxoupdateError::VersionNotFound { 140 | name: name.to_owned(), 141 | app_name: app_name.to_owned(), 142 | version: tag.to_owned(), 143 | })? 144 | .json() 145 | .await?; 146 | 147 | Release::try_from_github(app_name, gh_release) 148 | } 149 | 150 | pub(crate) async fn get_specific_github_version( 151 | name: &str, 152 | owner: &str, 153 | app_name: &str, 154 | version: &Version, 155 | token: &Option, 156 | ) -> AxoupdateResult { 157 | let releases = get_github_releases(name, owner, app_name, token).await?; 158 | let release = releases.into_iter().find(|r| &r.version == version); 159 | 160 | if let Some(release) = release { 161 | Ok(release) 162 | } else { 163 | Err(AxoupdateError::VersionNotFound { 164 | name: name.to_owned(), 165 | app_name: app_name.to_owned(), 166 | version: version.to_string(), 167 | }) 168 | } 169 | } 170 | 171 | pub(crate) async fn get_github_releases( 172 | name: &str, 173 | owner: &str, 174 | app_name: &str, 175 | token: &Option, 176 | ) -> AxoupdateResult> { 177 | let client = reqwest::Client::new(); 178 | let api: String = github_api(app_name)?; 179 | let mut url = format!("{api}/repos/{owner}/{name}/releases"); 180 | let mut pages_remain = true; 181 | let mut data: Vec = vec![]; 182 | 183 | while pages_remain { 184 | // fetch the releases 185 | let resp = get_releases(&client, &url, token).await?; 186 | 187 | // collect the response headers 188 | let headers = resp.headers(); 189 | let link_header = &headers 190 | .get(reqwest::header::LINK) 191 | .as_ref() 192 | .map(|link_header_val| { 193 | link_header_val 194 | .to_str() 195 | .expect("header was not ascii") 196 | .to_string() 197 | }); 198 | 199 | // append the data 200 | let mut body: Vec = resp 201 | .json::>() 202 | .await? 203 | .into_iter() 204 | .filter_map(|gh| Release::try_from_github(app_name, gh).ok()) 205 | .collect(); 206 | data.append(&mut body); 207 | 208 | // check headers to see pages remain and if they do update the URL 209 | pages_remain = if let Some(link_header) = link_header { 210 | if link_header.contains("rel=\"next\"") { 211 | url = get_next_url(link_header).expect("detected a next but it was a lie"); 212 | true 213 | } else { 214 | false 215 | } 216 | } else { 217 | false 218 | }; 219 | } 220 | 221 | Ok(data 222 | .into_iter() 223 | .filter(|r| { 224 | r.assets 225 | .iter() 226 | .any(|asset| asset.name.starts_with(&format!("{app_name}-installer"))) 227 | }) 228 | .collect()) 229 | } 230 | 231 | // The format of the header looks like so: 232 | // ``` 233 | // ; rel="prev", ; rel="next", ; rel="last", ; rel="first" 234 | // ``` 235 | fn get_next_url(link_header: &str) -> Option { 236 | let links = link_header.split(',').collect::>(); 237 | for entry in links { 238 | if entry.contains("next") { 239 | let mut link = entry.split(';').collect::>()[0] 240 | .to_string() 241 | .trim() 242 | .to_string(); 243 | link.remove(0); 244 | link.pop(); 245 | return Some(link); 246 | } 247 | } 248 | None 249 | } 250 | 251 | pub(crate) async fn get_releases( 252 | client: &reqwest::Client, 253 | url: &str, 254 | token: &Option, 255 | ) -> AxoupdateResult { 256 | let mut request = client 257 | .get(url) 258 | .header(ACCEPT, "application/json") 259 | .header( 260 | USER_AGENT, 261 | format!("axoupdate/{}", env!("CARGO_PKG_VERSION")), 262 | ) 263 | .header("X-GitHub-Api-Version", "2022-11-28"); 264 | if let Some(token) = token { 265 | request = request.bearer_auth(token); 266 | } 267 | Ok(request.send().await?.error_for_status()?) 268 | } 269 | 270 | impl Release { 271 | /// Constructs a release from GitHub Releases data. 272 | pub(crate) fn try_from_github( 273 | package_name: &str, 274 | release: GithubRelease, 275 | ) -> AxoupdateResult { 276 | // try to parse the github release's tag using axotag 277 | let announce = parse_tag( 278 | &[axotag::Package { 279 | name: package_name.to_owned(), 280 | version: None, 281 | }], 282 | &release.tag_name, 283 | )?; 284 | let version = match announce.release { 285 | axotag::ReleaseType::None => unreachable!("parse_tag should never return None"), 286 | axotag::ReleaseType::Version(v) => v, 287 | axotag::ReleaseType::Package { version, .. } => version, 288 | }; 289 | Ok(Release { 290 | tag_name: release.tag_name, 291 | version, 292 | name: release.name, 293 | url: String::new(), 294 | assets: release 295 | .assets 296 | .into_iter() 297 | .map(|asset| Asset { 298 | url: asset.url, 299 | browser_download_url: asset.browser_download_url, 300 | name: asset.name, 301 | }) 302 | .collect(), 303 | prerelease: release.prerelease, 304 | }) 305 | } 306 | } 307 | 308 | #[cfg(test)] 309 | mod test { 310 | use super::{ 311 | get_github_releases, get_latest_github_release, get_next_url, get_specific_github_tag, 312 | github_api, GithubAsset, GithubRelease, 313 | }; 314 | use axoasset::reqwest::StatusCode; 315 | use axoasset::serde_json::json; 316 | use httpmock::prelude::*; 317 | use serial_test::serial; 318 | use std::env; 319 | 320 | #[test] 321 | fn test_link_header_parse() { 322 | let sample = r#" 323 | ; rel="prev", ; rel="next", ; rel="last", ; rel="first" 324 | "#; 325 | 326 | let result = get_next_url(sample); 327 | assert!(result.is_some()); 328 | assert_eq!( 329 | "https://api.github.com/repositories/1300192/issues?page=4", 330 | result.unwrap() 331 | ); 332 | } 333 | 334 | #[test] 335 | fn test_link_header_parse_next_missing() { 336 | let sample = r#" 337 | ; rel="prev", ; rel="last", ; rel="first" 338 | "#; 339 | 340 | let result = get_next_url(sample); 341 | assert!(result.is_none()); 342 | } 343 | 344 | #[test] 345 | fn test_link_header_parse_empty_header() { 346 | let sample = ""; 347 | 348 | let result = get_next_url(sample); 349 | assert!(result.is_none()); 350 | } 351 | 352 | #[test] 353 | #[serial] // modifying the global state environment variables 354 | fn test_github_api_no_env_var() { 355 | env::remove_var("DIST_INSTALLER_GITHUB_BASE_URL"); 356 | let result = github_api("dist").unwrap(); 357 | 358 | assert_eq!(result, "https://api.github.com"); 359 | } 360 | 361 | #[test] 362 | #[serial] // modifying the global state environment variables 363 | fn test_github_api_overwrite() { 364 | env::set_var("DIST_INSTALLER_GITHUB_BASE_URL", "https://magic.com"); 365 | let result = github_api("dist").unwrap(); 366 | env::remove_var("DIST_INSTALLER_GITHUB_BASE_URL"); 367 | 368 | assert_eq!(result, "https://api.magic.com"); 369 | } 370 | 371 | #[test] 372 | #[serial] // modifying the global state environment variables 373 | fn test_github_api_overwrite_ip() { 374 | env::set_var("DIST_INSTALLER_GITHUB_BASE_URL", "https://127.0.0.1"); 375 | let result = github_api("dist"); 376 | env::remove_var("DIST_INSTALLER_GITHUB_BASE_URL"); 377 | assert!(result.is_err()); 378 | } 379 | 380 | #[test] 381 | #[serial] // modifying the global state environment variables 382 | fn test_github_api_overwrite_port() { 383 | env::set_var("DIST_INSTALLER_GITHUB_BASE_URL", "https://magic.com:8000"); 384 | let result = github_api("dist").unwrap(); 385 | env::remove_var("DIST_INSTALLER_GITHUB_BASE_URL"); 386 | 387 | assert_eq!(result, "https://api.magic.com:8000"); 388 | } 389 | 390 | #[test] 391 | #[serial] // modifying the global state environment variables 392 | fn test_github_api_overwrite_bad_value() { 393 | env::set_var("DIST_INSTALLER_GITHUB_BASE_URL", "this is not a url"); 394 | let result = github_api("dist"); 395 | env::remove_var("DIST_INSTALLER_GITHUB_BASE_URL"); 396 | assert!(result.is_err()); 397 | } 398 | 399 | #[test] 400 | #[serial] // modifying the global state environment variables 401 | fn test_ghe_api_no_env_var() { 402 | env::remove_var("DIST_INSTALLER_GHE_BASE_URL"); 403 | let result = github_api("dist").unwrap(); 404 | 405 | assert_eq!(result, "https://api.github.com"); 406 | } 407 | 408 | #[test] 409 | #[serial] // modifying the global state environment variables 410 | fn test_ghe_api_overwrite() { 411 | env::set_var("DIST_INSTALLER_GHE_BASE_URL", "https://magic.com"); 412 | let result = github_api("dist").unwrap(); 413 | env::remove_var("DIST_INSTALLER_GHE_BASE_URL"); 414 | 415 | assert_eq!(result, "https://magic.com/api/v3"); 416 | } 417 | 418 | #[test] 419 | #[serial] // modifying the global state environment variables 420 | fn test_ghe_ip_api_overwrite() { 421 | env::set_var("DIST_INSTALLER_GHE_BASE_URL", "https://127.0.0.1"); 422 | let result = github_api("dist").unwrap(); 423 | env::remove_var("DIST_INSTALLER_GHE_BASE_URL"); 424 | 425 | assert_eq!(result, "https://127.0.0.1/api/v3"); 426 | } 427 | 428 | #[tokio::test] 429 | #[serial] // modifying the global state environment variables 430 | async fn test_get_latest_github_release_custom_endpoint() { 431 | let server = MockServer::start_async().await; 432 | env::set_var("APP_INSTALLER_GHE_BASE_URL", server.base_url()); 433 | 434 | let latest_release_http_call = server 435 | .mock_async(|when, then| { 436 | when.method("GET") 437 | .path("/api/v3/repos/owner/name/releases/latest"); 438 | then.status(StatusCode::OK.as_u16()) 439 | .header("content-type", "application/json") 440 | .json_body(json!(build_test_git_hub_release())); 441 | }) 442 | .await; 443 | 444 | let result = get_latest_github_release("name", "owner", "app", &None).await; 445 | env::remove_var("APP_INSTALLER_GHE_BASE_URL"); 446 | 447 | assert!(result.is_ok()); 448 | assert!(result.unwrap().is_some()); 449 | 450 | latest_release_http_call.assert(); 451 | } 452 | 453 | fn build_test_git_hub_release() -> GithubRelease { 454 | GithubRelease { 455 | tag_name: String::from("1.0.0"), 456 | name: String::from("n"), 457 | url: String::from("u"), 458 | assets: vec![GithubAsset { 459 | url: String::from("un"), 460 | browser_download_url: String::from("bdu"), 461 | name: String::from("app-installer"), 462 | }], 463 | prerelease: false, 464 | } 465 | } 466 | 467 | #[tokio::test] 468 | #[serial] // modifying the global state environment variables 469 | async fn test_get_specific_github_tag_custom_endpoint() { 470 | let server = MockServer::start_async().await; 471 | env::set_var("APP_INSTALLER_GHE_BASE_URL", server.base_url()); 472 | 473 | let release_tag_http_call = server 474 | .mock_async(|when, then| { 475 | when.method("GET") 476 | .path("/api/v3/repos/owner/name/releases/tags/1.0.0"); 477 | then.status(StatusCode::OK.as_u16()) 478 | .header("content-type", "application/json") 479 | .json_body(json!(build_test_git_hub_release())); 480 | }) 481 | .await; 482 | 483 | let result = get_specific_github_tag("name", "owner", "app", "1.0.0", &None).await; 484 | env::remove_var("APP_INSTALLER_GHE_BASE_URL"); 485 | 486 | assert!(result.is_ok()); 487 | 488 | release_tag_http_call.assert(); 489 | } 490 | 491 | #[tokio::test] 492 | #[serial] // modifying the global state environment variables 493 | async fn test_get_github_releases_custom_endpoint() { 494 | let server = MockServer::start_async().await; 495 | env::set_var("APP_INSTALLER_GHE_BASE_URL", server.base_url()); 496 | 497 | let releases_http_call = server 498 | .mock_async(|when, then| { 499 | when.method("GET").path("/api/v3/repos/owner/name/releases"); 500 | then.status(StatusCode::OK.as_u16()) 501 | .header("content-type", "application/json") 502 | .json_body(json!(vec![build_test_git_hub_release()])); 503 | }) 504 | .await; 505 | 506 | let result = get_github_releases("name", "owner", "app", &None).await; 507 | env::remove_var("APP_INSTALLER_GHE_BASE_URL"); 508 | 509 | assert!(result.is_ok()); 510 | 511 | releases_http_call.assert(); 512 | } 513 | } 514 | -------------------------------------------------------------------------------- /axoupdater-cli/tests/integration.rs: -------------------------------------------------------------------------------- 1 | use std::env::consts::EXE_SUFFIX; 2 | 3 | use axoasset::LocalAsset; 4 | use axoprocess::Cmd; 5 | use camino::{Utf8Path, Utf8PathBuf}; 6 | use tempfile::TempDir; 7 | 8 | static BIN: &str = env!("CARGO_BIN_EXE_axoupdater"); 9 | static RECEIPT_TEMPLATE: &str = r#"{"binaries":["axolotlsay"],"install_prefix":"INSTALL_PREFIX","provider":{"source":"cargo-dist","version":"CARGO_DIST_VERSION"},"source":{"app_name":"axolotlsay","name":"cargodisttest","owner":"mistydemeo","release_type":"github"},"version":"VERSION"}"#; 10 | 11 | // Handle aarch64 later 12 | fn triple() -> String { 13 | match std::env::consts::OS { 14 | "windows" => "x86_64-pc-windows-msvc".to_owned(), 15 | "macos" => { 16 | if std::env::consts::ARCH == "x86_64" { 17 | "x86_64-apple-darwin".to_owned() 18 | } else { 19 | "aarch64-apple-darwin".to_owned() 20 | } 21 | } 22 | "linux" => { 23 | if std::env::consts::ARCH == "x86_64" { 24 | "x86_64-unknown-linux-gnu".to_owned() 25 | } else { 26 | "aarch64-unknown-linux-gnu".to_owned() 27 | } 28 | } 29 | _ => unimplemented!(), 30 | } 31 | } 32 | 33 | fn axolotlsay_tarball_path(version: &str) -> String { 34 | let triple = triple(); 35 | format!("https://github.com/mistydemeo/cargodisttest/releases/download/v{version}/axolotlsay-{triple}.tar.gz") 36 | } 37 | 38 | fn install_receipt(version: &str, cargo_dist_version: &str, prefix: &Utf8PathBuf) -> String { 39 | RECEIPT_TEMPLATE 40 | .replace("INSTALL_PREFIX", &prefix.to_string().replace('\\', "\\\\")) 41 | .replace("CARGO_DIST_VERSION", cargo_dist_version) 42 | .replace("VERSION", version) 43 | } 44 | 45 | fn write_receipt( 46 | version: &str, 47 | cargo_dist_version: &str, 48 | receipt_prefix: &Utf8PathBuf, 49 | install_prefix: &Utf8PathBuf, 50 | ) -> std::io::Result<()> { 51 | // Create the prefix in case it doesn't exist 52 | LocalAsset::create_dir_all(receipt_prefix).unwrap(); 53 | 54 | let contents = install_receipt(version, cargo_dist_version, install_prefix); 55 | let receipt_name = receipt_prefix.join("axolotlsay-receipt.json"); 56 | LocalAsset::write_new(&contents, receipt_name).unwrap(); 57 | 58 | Ok(()) 59 | } 60 | 61 | #[test] 62 | fn bails_out_with_default_name() { 63 | let mut command = Cmd::new(BIN, "execute axoupdater"); 64 | command.check(false); 65 | let result = command.output().unwrap(); 66 | assert!(!result.status.success()); 67 | 68 | let stderr_string = String::from_utf8(result.stderr).unwrap(); 69 | assert!(stderr_string.contains("App name calculated as `axoupdater'")); 70 | } 71 | 72 | // Performs an in-place upgrade from an old version to a newer one. 73 | // The process runs like so: 74 | // * Simulate an install of axolotlsay into a temporary directory 75 | // * Write an install receipt to that path 76 | // * Copy this repo's copy of axoupdater into the temporary directory in place of the one that axolotlsay once came with 77 | // * Run axoupdater 78 | // * Confirm that the new binary exists and is a newer version than the one we had before 79 | // 80 | // NOTE: axolotlsay 0.2.115 is a good base version to use because it contains a 81 | // several noteworthy bugfixes in its installer. 82 | #[test] 83 | fn test_upgrade() -> std::io::Result<()> { 84 | let tempdir = TempDir::new()?; 85 | let bindir_path = &tempdir.path().join("bin"); 86 | let bindir = Utf8Path::from_path(bindir_path).unwrap(); 87 | std::fs::create_dir_all(bindir)?; 88 | 89 | let base_version = "0.2.115"; 90 | 91 | let url = axolotlsay_tarball_path(base_version); 92 | let compressed_path = 93 | Utf8PathBuf::from_path_buf(tempdir.path().join("axolotlsay.tar.gz")).unwrap(); 94 | 95 | let client = axoasset::AxoClient::with_reqwest(axoasset::reqwest::Client::new()); 96 | let rt = tokio::runtime::Runtime::new().unwrap(); 97 | rt.block_on(client.load_and_write_to_file(&url, &compressed_path)) 98 | .unwrap(); 99 | 100 | // Write the receipt for the updater to use 101 | write_receipt( 102 | base_version, 103 | "0.11.1", 104 | &bindir.to_path_buf(), 105 | &bindir.to_path_buf(), 106 | )?; 107 | 108 | LocalAsset::untar_gz_all(&compressed_path, bindir).unwrap(); 109 | 110 | // Now install our copy of the updater instead of the one axolotlsay came with 111 | let updater_path = bindir.join(format!("axolotlsay-update{EXE_SUFFIX}")); 112 | std::fs::copy(BIN, &updater_path)?; 113 | 114 | let mut updater = Cmd::new(&updater_path, "run updater"); 115 | updater.env("AXOUPDATER_CONFIG_PATH", bindir); 116 | // If we're not running in CI, try to avoid ruining the user's PATH. 117 | if std::env::var("CI").is_err() { 118 | updater.env("INSTALLER_NO_MODIFY_PATH", "1"); 119 | updater.env("AXOLOTLSAY_NO_MODIFY_PATH", "1"); 120 | } 121 | // We'll do that manually 122 | updater.check(false); 123 | let res = updater.output().unwrap(); 124 | let output_stdout = String::from_utf8(res.stdout).unwrap(); 125 | let output_stderr = String::from_utf8(res.stderr).unwrap(); 126 | 127 | // Now let's check the version we just updated to 128 | let new_axolotlsay_path = &bindir.join(format!("axolotlsay{EXE_SUFFIX}")); 129 | assert!( 130 | new_axolotlsay_path.exists(), 131 | "update result was\nstdout\n{}\nstderr\n{}", 132 | output_stdout, 133 | output_stderr 134 | ); 135 | let mut new_axolotlsay = Cmd::new(new_axolotlsay_path, "version test"); 136 | new_axolotlsay.arg("--version"); 137 | let output = new_axolotlsay.output().unwrap(); 138 | let stderr_string = String::from_utf8(output.stdout).unwrap(); 139 | assert!(stderr_string.starts_with("axolotlsay ")); 140 | assert_ne!(stderr_string, format!("axolotlsay {}\n", base_version)); 141 | 142 | Ok(()) 143 | } 144 | 145 | #[test] 146 | fn test_upgrade_xdg_config_home() -> std::io::Result<()> { 147 | let tempdir = TempDir::new()?; 148 | let bindir_path = &tempdir.path().join("bin"); 149 | let bindir = Utf8Path::from_path(bindir_path).unwrap(); 150 | std::fs::create_dir_all(bindir)?; 151 | let xdg_config_home = tempdir.path().join("config"); 152 | let xdg_config_home = Utf8Path::from_path(&xdg_config_home).unwrap(); 153 | 154 | let base_version = "0.2.115"; 155 | 156 | let url = axolotlsay_tarball_path(base_version); 157 | let compressed_path = 158 | Utf8PathBuf::from_path_buf(tempdir.path().join("axolotlsay.tar.gz")).unwrap(); 159 | 160 | let client = axoasset::AxoClient::with_reqwest(axoasset::reqwest::Client::new()); 161 | let rt = tokio::runtime::Runtime::new().unwrap(); 162 | rt.block_on(client.load_and_write_to_file(&url, &compressed_path)) 163 | .unwrap(); 164 | 165 | // Write the receipt for the updater to use 166 | write_receipt( 167 | base_version, 168 | "0.11.1", 169 | &xdg_config_home.join("axolotlsay"), 170 | &bindir.to_path_buf(), 171 | )?; 172 | 173 | LocalAsset::untar_gz_all(&compressed_path, bindir).unwrap(); 174 | 175 | // Now install our copy of the updater instead of the one axolotlsay came with 176 | let updater_path = bindir.join(format!("axolotlsay-update{EXE_SUFFIX}")); 177 | std::fs::copy(BIN, &updater_path)?; 178 | 179 | let mut updater = Cmd::new(&updater_path, "run updater"); 180 | updater.env("XDG_CONFIG_HOME", xdg_config_home); 181 | // If we're not running in CI, try to avoid ruining the user's PATH. 182 | if std::env::var("CI").is_err() { 183 | updater.env("INSTALLER_NO_MODIFY_PATH", "1"); 184 | updater.env("AXOLOTLSAY_NO_MODIFY_PATH", "1"); 185 | } 186 | // We'll do that manually 187 | updater.check(false); 188 | let res = updater.output().unwrap(); 189 | let output_stdout = String::from_utf8(res.stdout).unwrap(); 190 | let output_stderr = String::from_utf8(res.stderr).unwrap(); 191 | 192 | // Now let's check the version we just updated to 193 | let new_axolotlsay_path = &bindir.join(format!("axolotlsay{EXE_SUFFIX}")); 194 | assert!( 195 | new_axolotlsay_path.exists(), 196 | "update result was\nstdout\n{}\nstderr\n{}", 197 | output_stdout, 198 | output_stderr 199 | ); 200 | let mut new_axolotlsay = Cmd::new(new_axolotlsay_path, "version test"); 201 | new_axolotlsay.arg("--version"); 202 | let output = new_axolotlsay.output().unwrap(); 203 | let stderr_string = String::from_utf8(output.stdout).unwrap(); 204 | assert!(stderr_string.starts_with("axolotlsay ")); 205 | assert_ne!(stderr_string, format!("axolotlsay {}\n", base_version)); 206 | 207 | Ok(()) 208 | } 209 | 210 | #[test] 211 | fn test_upgrade_allow_prerelease() -> std::io::Result<()> { 212 | let tempdir = TempDir::new()?; 213 | let bindir_path = &tempdir.path().join("bin"); 214 | let bindir = Utf8Path::from_path(bindir_path).unwrap(); 215 | std::fs::create_dir_all(bindir)?; 216 | 217 | let base_version = "0.2.115"; 218 | 219 | let url = axolotlsay_tarball_path(base_version); 220 | let compressed_path = 221 | Utf8PathBuf::from_path_buf(tempdir.path().join("axolotlsay.tar.gz")).unwrap(); 222 | 223 | let client = axoasset::AxoClient::with_reqwest(axoasset::reqwest::Client::new()); 224 | let rt = tokio::runtime::Runtime::new().unwrap(); 225 | rt.block_on(client.load_and_write_to_file(&url, &compressed_path)) 226 | .unwrap(); 227 | 228 | // Write the receipt for the updater to use 229 | write_receipt( 230 | base_version, 231 | "0.11.1", 232 | &bindir.to_path_buf(), 233 | &bindir.to_path_buf(), 234 | )?; 235 | 236 | LocalAsset::untar_gz_all(&compressed_path, bindir).unwrap(); 237 | 238 | // Now install our copy of the updater instead of the one axolotlsay came with 239 | let updater_path = bindir.join(format!("axolotlsay-update{EXE_SUFFIX}")); 240 | std::fs::copy(BIN, &updater_path)?; 241 | 242 | let mut updater = Cmd::new(&updater_path, "run updater"); 243 | // If we're not running in CI, try to avoid ruining the user's PATH. 244 | if std::env::var("CI").is_err() { 245 | updater.env("INSTALLER_NO_MODIFY_PATH", "1"); 246 | updater.env("AXOLOTLSAY_NO_MODIFY_PATH", "1"); 247 | } 248 | updater.env("AXOUPDATER_CONFIG_PATH", bindir); 249 | updater.arg("--prerelease"); 250 | // We'll do that manually 251 | updater.check(false); 252 | let res = updater.output().unwrap(); 253 | let output_stdout = String::from_utf8(res.stdout).unwrap(); 254 | let output_stderr = String::from_utf8(res.stderr).unwrap(); 255 | 256 | // Now let's check the version we just updated to 257 | let new_axolotlsay_path = &bindir.join(format!("axolotlsay{EXE_SUFFIX}")); 258 | assert!( 259 | new_axolotlsay_path.exists(), 260 | "update result was\nstdout\n{}\nstderr\n{}", 261 | output_stdout, 262 | output_stderr 263 | ); 264 | let mut new_axolotlsay = Cmd::new(new_axolotlsay_path, "version test"); 265 | new_axolotlsay.arg("--version"); 266 | let output = new_axolotlsay.output().unwrap(); 267 | let stderr_string = String::from_utf8(output.stdout).unwrap(); 268 | assert!(stderr_string.starts_with("axolotlsay ")); 269 | assert_ne!(stderr_string, format!("axolotlsay {}\n", base_version)); 270 | 271 | Ok(()) 272 | } 273 | 274 | // A similar test to the one above, but it upgrades to a specific version 275 | // instead of whatever's latest. 276 | #[test] 277 | fn test_upgrade_to_specific_version() -> std::io::Result<()> { 278 | let tempdir = TempDir::new()?; 279 | let bindir_path = &tempdir.path().join("bin"); 280 | let bindir = Utf8Path::from_path(bindir_path).unwrap(); 281 | std::fs::create_dir_all(bindir)?; 282 | 283 | let base_version = "0.2.115"; 284 | let target_version = "0.2.116"; 285 | 286 | let url = axolotlsay_tarball_path(base_version); 287 | let compressed_path = 288 | Utf8PathBuf::from_path_buf(tempdir.path().join("axolotlsay.tar.gz")).unwrap(); 289 | 290 | let client = axoasset::AxoClient::with_reqwest(axoasset::reqwest::Client::new()); 291 | let rt = tokio::runtime::Runtime::new().unwrap(); 292 | rt.block_on(client.load_and_write_to_file(&url, &compressed_path)) 293 | .unwrap(); 294 | 295 | // Write the receipt for the updater to use 296 | write_receipt( 297 | base_version, 298 | "0.11.1", 299 | &bindir.to_path_buf(), 300 | &bindir.to_path_buf(), 301 | )?; 302 | 303 | LocalAsset::untar_gz_all(&compressed_path, bindir).unwrap(); 304 | 305 | // Now install our copy of the updater instead of the one axolotlsay came with 306 | let updater_path = bindir.join(format!("axolotlsay-update{EXE_SUFFIX}")); 307 | std::fs::copy(BIN, &updater_path)?; 308 | 309 | let mut updater = Cmd::new(&updater_path, "run updater"); 310 | updater.arg("--version").arg(target_version); 311 | updater.env("AXOUPDATER_CONFIG_PATH", bindir); 312 | // If we're not running in CI, try to avoid ruining the user's PATH. 313 | if std::env::var("CI").is_err() { 314 | updater.env("INSTALLER_NO_MODIFY_PATH", "1"); 315 | updater.env("AXOLOTLSAY_NO_MODIFY_PATH", "1"); 316 | } 317 | // We'll do that manually 318 | updater.check(false); 319 | let _res = updater.output().unwrap(); 320 | 321 | // Now let's check the version we just updated to 322 | let new_axolotlsay_path = &bindir.join(format!("axolotlsay{EXE_SUFFIX}")); 323 | assert!(new_axolotlsay_path.exists()); 324 | let mut new_axolotlsay = Cmd::new(new_axolotlsay_path, "version test"); 325 | new_axolotlsay.arg("--version"); 326 | let output = new_axolotlsay.output().unwrap(); 327 | let stderr_string = String::from_utf8(output.stdout).unwrap(); 328 | assert_eq!(stderr_string, format!("axolotlsay {}\n", target_version)); 329 | 330 | Ok(()) 331 | } 332 | 333 | // A similar test to the one above, but it actually downgrades to an older 334 | // version on request instead of upgrading. 335 | #[test] 336 | fn test_downgrade_to_specific_version() -> std::io::Result<()> { 337 | let tempdir = TempDir::new()?; 338 | let bindir_path = &tempdir.path().join("bin"); 339 | let bindir = Utf8Path::from_path(bindir_path).unwrap(); 340 | std::fs::create_dir_all(bindir)?; 341 | 342 | let base_version = "0.2.116"; 343 | let target_version = "0.2.115"; 344 | 345 | let url = axolotlsay_tarball_path(base_version); 346 | let compressed_path = 347 | Utf8PathBuf::from_path_buf(tempdir.path().join("axolotlsay.tar.gz")).unwrap(); 348 | 349 | let client = axoasset::AxoClient::with_reqwest(axoasset::reqwest::Client::new()); 350 | let rt = tokio::runtime::Runtime::new().unwrap(); 351 | rt.block_on(client.load_and_write_to_file(&url, &compressed_path)) 352 | .unwrap(); 353 | 354 | // Write the receipt for the updater to use 355 | write_receipt( 356 | base_version, 357 | "0.11.1", 358 | &bindir.to_path_buf(), 359 | &bindir.to_path_buf(), 360 | )?; 361 | 362 | LocalAsset::untar_gz_all(&compressed_path, bindir).unwrap(); 363 | 364 | // Now install our copy of the updater instead of the one axolotlsay came with 365 | let updater_path = bindir.join(format!("axolotlsay-update{EXE_SUFFIX}")); 366 | std::fs::copy(BIN, &updater_path)?; 367 | 368 | let mut updater = Cmd::new(&updater_path, "run updater"); 369 | updater.arg("--version").arg(target_version); 370 | updater.env("AXOUPDATER_CONFIG_PATH", bindir); 371 | // If we're not running in CI, try to avoid ruining the user's PATH. 372 | if std::env::var("CI").is_err() { 373 | updater.env("INSTALLER_NO_MODIFY_PATH", "1"); 374 | updater.env("AXOLOTLSAY_NO_MODIFY_PATH", "1"); 375 | } 376 | // We'll do that manually 377 | updater.check(false); 378 | let _res = updater.output().unwrap(); 379 | 380 | // Now let's check the version we just updated to 381 | let new_axolotlsay_path = &bindir.join(format!("axolotlsay{EXE_SUFFIX}")); 382 | assert!(new_axolotlsay_path.exists()); 383 | let mut new_axolotlsay = Cmd::new(new_axolotlsay_path, "version test"); 384 | new_axolotlsay.arg("--version"); 385 | let output = new_axolotlsay.output().unwrap(); 386 | let stderr_string = String::from_utf8(output.stdout).unwrap(); 387 | assert_eq!(stderr_string, format!("axolotlsay {}\n", target_version)); 388 | 389 | Ok(()) 390 | } 391 | 392 | // A similar test to the one above, but it upgrades to a significantly older 393 | // version that's outside the GitHub API's 30 versions. 394 | // This version isn't available for every target, so we only run it for 395 | // certain target triples. 396 | #[test] 397 | fn test_downgrade_to_specific_old_version() -> std::io::Result<()> { 398 | // Only available for x86_64 Darwin and x86_64 Linux 399 | match std::env::consts::OS { 400 | "linux" | "macos" => { 401 | if std::env::consts::ARCH != "x86_64" { 402 | return Ok(()); 403 | } 404 | } 405 | "windows" => return Ok(()), 406 | _ => return Ok(()), 407 | } 408 | 409 | let tempdir = TempDir::new()?; 410 | let bindir_path = &tempdir.path().join("bin"); 411 | let bindir = Utf8Path::from_path(bindir_path).unwrap(); 412 | std::fs::create_dir_all(bindir)?; 413 | 414 | let base_version = "0.2.116"; 415 | let target_version = "0.2.50"; 416 | 417 | let url = axolotlsay_tarball_path(base_version); 418 | let compressed_path = 419 | Utf8PathBuf::from_path_buf(tempdir.path().join("axolotlsay.tar.gz")).unwrap(); 420 | 421 | let client = axoasset::AxoClient::with_reqwest(axoasset::reqwest::Client::new()); 422 | let rt = tokio::runtime::Runtime::new().unwrap(); 423 | rt.block_on(client.load_and_write_to_file(&url, &compressed_path)) 424 | .unwrap(); 425 | 426 | // Write the receipt for the updater to use 427 | write_receipt( 428 | base_version, 429 | "0.11.1", 430 | &bindir.to_path_buf(), 431 | &bindir.to_path_buf(), 432 | )?; 433 | 434 | LocalAsset::untar_gz_all(&compressed_path, bindir).unwrap(); 435 | 436 | // Now install our copy of the updater instead of the one axolotlsay came with 437 | let updater_path = bindir.join(format!("axolotlsay-update{EXE_SUFFIX}")); 438 | std::fs::copy(BIN, &updater_path)?; 439 | 440 | let mut updater = Cmd::new(&updater_path, "run updater"); 441 | updater.arg("--version").arg(target_version); 442 | updater.env("AXOUPDATER_CONFIG_PATH", bindir); 443 | // If we're not running in CI, try to avoid ruining the user's PATH. 444 | if std::env::var("CI").is_err() { 445 | updater.env("INSTALLER_NO_MODIFY_PATH", "1"); 446 | updater.env("AXOLOTLSAY_NO_MODIFY_PATH", "1"); 447 | } 448 | // This installer is so old it doesn't respect the install path, so we 449 | // have to set CARGO_HOME to force it. 450 | updater.env("CARGO_HOME", tempdir.path()); 451 | // We'll do that manually 452 | updater.check(false); 453 | let _res = updater.output().unwrap(); 454 | 455 | // Now let's check the version we just updated to 456 | let new_axolotlsay_path = &bindir.join(format!("axolotlsay{EXE_SUFFIX}")); 457 | assert!(new_axolotlsay_path.exists()); 458 | let mut new_axolotlsay = Cmd::new(new_axolotlsay_path, "version test"); 459 | new_axolotlsay.arg("--version"); 460 | let output = new_axolotlsay.output().unwrap(); 461 | let stderr_string = String::from_utf8(output.stdout).unwrap(); 462 | assert_eq!(stderr_string, format!("axolotlsay {}\n", target_version)); 463 | 464 | Ok(()) 465 | } 466 | 467 | // Similar to `test_upgrade` but tests releases created after the 468 | // cargo-dist receipt prefix bug was fixed; see: 469 | // https://github.com/axodotdev/cargo-dist/pull/1037 470 | #[test] 471 | fn test_upgrade_from_prefix_with_no_bin() -> std::io::Result<()> { 472 | let tempdir = TempDir::new()?; 473 | let prefix = Utf8PathBuf::from_path_buf(tempdir.path().to_path_buf()).unwrap(); 474 | let bindir_path = &tempdir.path().join("bin"); 475 | let bindir = Utf8Path::from_path(bindir_path).unwrap(); 476 | std::fs::create_dir_all(bindir)?; 477 | 478 | // The first cargodisttest release with the "/bin" bug fixed 479 | let base_version = "0.2.133"; 480 | 481 | let url = axolotlsay_tarball_path(base_version); 482 | let compressed_path = 483 | Utf8PathBuf::from_path_buf(tempdir.path().join("axolotlsay.tar.gz")).unwrap(); 484 | 485 | let client = axoasset::AxoClient::with_reqwest(axoasset::reqwest::Client::new()); 486 | let rt = tokio::runtime::Runtime::new().unwrap(); 487 | rt.block_on(client.load_and_write_to_file(&url, &compressed_path)) 488 | .unwrap(); 489 | 490 | // Write the receipt for the updater to use 491 | // 0.15.0 is the first cargo-dist that published fixed installers for the 492 | // /bin bug mentioned above 493 | write_receipt(base_version, "0.15.0", &prefix, &prefix)?; 494 | 495 | LocalAsset::untar_gz_all(&compressed_path, bindir).unwrap(); 496 | 497 | // Now install our copy of the updater instead of the one axolotlsay came with 498 | let updater_path = bindir.join(format!("axolotlsay-update{EXE_SUFFIX}")); 499 | std::fs::copy(BIN, &updater_path)?; 500 | 501 | let mut updater = Cmd::new(&updater_path, "run updater"); 502 | updater.env("AXOUPDATER_CONFIG_PATH", prefix); 503 | // If we're not running in CI, try to avoid ruining the user's PATH. 504 | if std::env::var("CI").is_err() { 505 | updater.env("INSTALLER_NO_MODIFY_PATH", "1"); 506 | updater.env("AXOLOTLSAY_NO_MODIFY_PATH", "1"); 507 | } 508 | // We'll do that manually 509 | updater.check(false); 510 | let res = updater.output().unwrap(); 511 | 512 | let output_stdout = String::from_utf8(res.stdout).unwrap(); 513 | let output_stderr = String::from_utf8(res.stderr).unwrap(); 514 | 515 | // Now let's check the version we just updated to 516 | let new_axolotlsay_path = &bindir.join(format!("axolotlsay{EXE_SUFFIX}")); 517 | assert!( 518 | new_axolotlsay_path.exists(), 519 | "update result was\nstdout\n{}\nstderr\n{}", 520 | output_stdout, 521 | output_stderr 522 | ); 523 | let mut new_axolotlsay = Cmd::new(new_axolotlsay_path, "version test"); 524 | new_axolotlsay.arg("--version"); 525 | let output = new_axolotlsay.output().unwrap(); 526 | let stderr_string = String::from_utf8(output.stdout).unwrap(); 527 | assert!(stderr_string.starts_with("axolotlsay ")); 528 | assert_ne!(stderr_string, format!("axolotlsay {}\n", base_version)); 529 | 530 | Ok(()) 531 | } 532 | -------------------------------------------------------------------------------- /axoupdater/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | #![allow(clippy::result_large_err)] 3 | 4 | //! axoupdater crate 5 | 6 | pub mod errors; 7 | mod receipt; 8 | mod release; 9 | pub mod test; 10 | 11 | pub use errors::*; 12 | pub use release::*; 13 | 14 | use std::{ 15 | env::{self, args}, 16 | ffi::OsStr, 17 | process::Stdio, 18 | }; 19 | 20 | #[cfg(unix)] 21 | use std::{fs::File, os::unix::fs::PermissionsExt}; 22 | 23 | #[cfg(windows)] 24 | use self_replace; 25 | 26 | use axoasset::LocalAsset; 27 | use axoprocess::Cmd; 28 | pub use axotag::Version; 29 | use camino::Utf8PathBuf; 30 | 31 | use tempfile::TempDir; 32 | 33 | /// Version number for this release of axoupdater. 34 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 35 | 36 | /// Provides information about the result of the upgrade operation 37 | pub struct UpdateResult { 38 | /// The old version (pre-upgrade) 39 | pub old_version: Option, 40 | /// The new version (post-upgrade) 41 | pub new_version: Version, 42 | /// The tag the new version was created from 43 | pub new_version_tag: String, 44 | /// The root that the new version was installed to 45 | /// NOTE: This is a prediction, and the underlying installer may ignore it 46 | /// if it's out of date. Installers built with cargo-dist 0.12.0 or later 47 | /// will definitively use this value. 48 | pub install_prefix: Utf8PathBuf, 49 | } 50 | 51 | /// Used to specify what version to upgrade to 52 | #[derive(Clone)] 53 | pub enum UpdateRequest { 54 | /// Always update to the latest 55 | Latest, 56 | /// Always update to the latest, allow prereleases 57 | LatestMaybePrerelease, 58 | /// Upgrade (or downgrade) to this specific version 59 | SpecificVersion(String), 60 | /// Upgrade (or downgrade) to this specific tag 61 | SpecificTag(String), 62 | } 63 | 64 | #[derive(Default)] 65 | pub(crate) struct AuthorizationTokens { 66 | github: Option, 67 | axodotdev: Option, 68 | } 69 | 70 | /// Tool used to produce this install receipt 71 | pub struct Provider { 72 | /// The name of the tool used to create this receipt 73 | pub source: String, 74 | /// The version of the above tool 75 | pub version: Version, 76 | } 77 | 78 | /// Struct representing an updater process 79 | pub struct AxoUpdater { 80 | /// The name of the program to update, if specified 81 | pub name: Option, 82 | /// Information about where updates should be fetched from 83 | pub source: Option, 84 | /// What version should be updated to 85 | version_specifier: UpdateRequest, 86 | /// Information about the latest release; used to determine if an update is needed 87 | requested_release: Option, 88 | /// The current version number 89 | current_version: Option, 90 | /// Version of cargo-dist current version is installed by 91 | current_version_installed_by: Option, 92 | /// Information about the install prefix of the previous version 93 | install_prefix: Option, 94 | /// Whether to display the underlying installer's stdout 95 | print_installer_stdout: bool, 96 | /// Whether to display the underlying installer's stderr 97 | print_installer_stderr: bool, 98 | /// The path to the installer to use for the new version. 99 | /// If not specified, downloads the installer from the release source. 100 | installer_path: Option, 101 | /// A token to use to query releases from GitHub. If not supplied, 102 | /// AxoUpdater will perform unauthorized requests. 103 | tokens: AuthorizationTokens, 104 | /// When set to true, skips performing version checks and always assumes 105 | /// the software is out of date. 106 | always_update: bool, 107 | /// Whether to modify the system path when installing 108 | modify_path: bool, 109 | } 110 | 111 | impl Default for AxoUpdater { 112 | fn default() -> Self { 113 | Self::new() 114 | } 115 | } 116 | 117 | impl AxoUpdater { 118 | /// Creates a new, empty AxoUpdater struct. This struct lacks information 119 | /// necessary to perform the update, so at least the name and source fields 120 | /// will need to be filled in before the update can run. 121 | pub fn new() -> AxoUpdater { 122 | AxoUpdater { 123 | name: None, 124 | source: None, 125 | version_specifier: UpdateRequest::Latest, 126 | requested_release: None, 127 | current_version: None, 128 | current_version_installed_by: None, 129 | install_prefix: None, 130 | print_installer_stdout: true, 131 | print_installer_stderr: true, 132 | installer_path: None, 133 | tokens: AuthorizationTokens::default(), 134 | always_update: false, 135 | modify_path: true, 136 | } 137 | } 138 | 139 | /// Creates a new AxoUpdater struct with an explicitly-specified name. 140 | pub fn new_for(app_name: &str) -> AxoUpdater { 141 | AxoUpdater { 142 | name: Some(app_name.to_owned()), 143 | source: None, 144 | version_specifier: UpdateRequest::Latest, 145 | requested_release: None, 146 | current_version: None, 147 | current_version_installed_by: None, 148 | install_prefix: None, 149 | print_installer_stdout: true, 150 | print_installer_stderr: true, 151 | installer_path: None, 152 | tokens: AuthorizationTokens::default(), 153 | always_update: false, 154 | modify_path: true, 155 | } 156 | } 157 | 158 | /// Creates a new AxoUpdater struct by attempting to autodetect the name 159 | /// of the current executable. This is only meant to be used by standalone 160 | /// updaters, not when this crate is used as a library in another program. 161 | pub fn new_for_updater_executable() -> AxoupdateResult { 162 | let Some(app_name) = get_app_name() else { 163 | return Err(AxoupdateError::NoAppName {}); 164 | }; 165 | 166 | // Happens if the binary didn't get renamed properly 167 | if app_name == "axoupdater" { 168 | return Err(AxoupdateError::UpdateSelf {}); 169 | }; 170 | 171 | Ok(AxoUpdater { 172 | name: Some(app_name.to_owned()), 173 | source: None, 174 | version_specifier: UpdateRequest::Latest, 175 | requested_release: None, 176 | current_version: None, 177 | current_version_installed_by: None, 178 | install_prefix: None, 179 | print_installer_stdout: true, 180 | print_installer_stderr: true, 181 | installer_path: None, 182 | tokens: AuthorizationTokens::default(), 183 | always_update: false, 184 | modify_path: true, 185 | }) 186 | } 187 | 188 | /// Explicitly configures the release source as an alternative to 189 | /// reading it from the install receipt. This can be useful for tasks 190 | /// which want to query the new version without actually performing an 191 | /// upgrade. 192 | pub fn set_release_source(&mut self, source: ReleaseSource) -> &mut AxoUpdater { 193 | self.source = Some(source); 194 | 195 | self 196 | } 197 | 198 | /// Explicitly specifies the current version. 199 | pub fn set_current_version(&mut self, version: Version) -> AxoupdateResult<&mut AxoUpdater> { 200 | self.current_version = Some(version); 201 | 202 | Ok(self) 203 | } 204 | 205 | /// Changes this updater's name to `app_name`, regardless of what it was 206 | /// initialized as and regardless of what was read from the receipt. 207 | pub fn set_name(&mut self, app_name: &str) -> &mut AxoUpdater { 208 | self.name = Some(app_name.to_owned()); 209 | if let Some(source) = &self.source { 210 | let mut our_source = source.clone(); 211 | our_source.app_name = app_name.to_owned(); 212 | self.source = Some(our_source); 213 | } 214 | 215 | self 216 | } 217 | 218 | /// Enables printing the underlying installer's stdout. 219 | pub fn enable_installer_stdout(&mut self) -> &mut AxoUpdater { 220 | self.print_installer_stdout = true; 221 | 222 | self 223 | } 224 | 225 | /// Disables printing the underlying installer's stdout. 226 | pub fn disable_installer_stdout(&mut self) -> &mut AxoUpdater { 227 | self.print_installer_stdout = false; 228 | 229 | self 230 | } 231 | 232 | /// Enables printing the underlying installer's stderr. 233 | pub fn enable_installer_stderr(&mut self) -> &mut AxoUpdater { 234 | self.print_installer_stderr = true; 235 | 236 | self 237 | } 238 | 239 | /// Disables printing the underlying installer's stderr. 240 | pub fn disable_installer_stderr(&mut self) -> &mut AxoUpdater { 241 | self.print_installer_stderr = false; 242 | 243 | self 244 | } 245 | 246 | /// Enables all output for the underlying installer. 247 | pub fn enable_installer_output(&mut self) -> &mut AxoUpdater { 248 | self.print_installer_stdout = true; 249 | self.print_installer_stderr = true; 250 | 251 | self 252 | } 253 | 254 | /// Disables all output for the underlying installer. 255 | pub fn disable_installer_output(&mut self) -> &mut AxoUpdater { 256 | self.print_installer_stdout = false; 257 | self.print_installer_stderr = false; 258 | 259 | self 260 | } 261 | 262 | /// Configures AxoUpdater to use a specific installer for the new release 263 | /// instead of downloading it from the release source. 264 | pub fn configure_installer_path(&mut self, path: impl Into) -> &mut AxoUpdater { 265 | self.installer_path = Some(path.into().to_owned()); 266 | 267 | self 268 | } 269 | 270 | /// Configures AxoUpdater to use the installer from the new release. 271 | /// This is the default setting. 272 | pub fn use_release_installer(&mut self) -> &mut AxoUpdater { 273 | self.installer_path = None; 274 | 275 | self 276 | } 277 | 278 | /// Configures AxoUpdater with the install path to use. This is only needed 279 | /// if installing without an explicit install prefix. 280 | pub fn set_install_dir(&mut self, path: impl Into) -> &mut AxoUpdater { 281 | self.install_prefix = Some(path.into()); 282 | 283 | self 284 | } 285 | 286 | /// Configures axoupdater's update strategy, replacing whatever was 287 | /// previously configured with the strategy in `version_specifier`. 288 | pub fn configure_version_specifier( 289 | &mut self, 290 | version_specifier: UpdateRequest, 291 | ) -> &mut AxoUpdater { 292 | self.version_specifier = version_specifier; 293 | 294 | self 295 | } 296 | 297 | /// Always upgrade, including when already running the latest version or when the current version isn't known 298 | pub fn always_update(&mut self, setting: bool) -> &mut AxoUpdater { 299 | self.always_update = setting; 300 | 301 | self 302 | } 303 | 304 | /// Determines if an update is needed by querying the newest version from 305 | /// the location specified in `source`. 306 | /// This includes a blocking network call, so it may be slow. 307 | /// This can only be performed if the `current_version` field has been 308 | /// set, either by loading the install receipt or by specifying it using 309 | /// `set_current_version`. 310 | /// Note that this also checks to see if the current executable is 311 | /// *eligible* for updates, by checking to see if it's the executable 312 | /// that the install receipt is for. In the case that the executable comes 313 | /// from a different source, it will return before the network call for a 314 | /// new version. 315 | pub async fn is_update_needed(&mut self) -> AxoupdateResult { 316 | if self.always_update { 317 | return Ok(true); 318 | } 319 | 320 | if !self.check_receipt_is_for_this_executable()? { 321 | return Ok(false); 322 | } 323 | 324 | let Some(current_version) = self.current_version.to_owned() else { 325 | return Err(AxoupdateError::NotConfigured { 326 | missing_field: "current_version".to_owned(), 327 | }); 328 | }; 329 | 330 | let release = match &self.requested_release { 331 | Some(r) => r, 332 | None => { 333 | self.fetch_release().await?; 334 | self.requested_release.as_ref().unwrap() 335 | } 336 | }; 337 | 338 | // If we're doing "latest" semantics we need to check cur < new 339 | // If we're doing "specific" semantics we need to check cur != new 340 | let conclusion = match self.version_specifier { 341 | UpdateRequest::Latest | UpdateRequest::LatestMaybePrerelease => { 342 | current_version < release.version 343 | } 344 | UpdateRequest::SpecificVersion(_) | UpdateRequest::SpecificTag(_) => { 345 | current_version != release.version 346 | } 347 | }; 348 | Ok(conclusion) 349 | } 350 | 351 | #[cfg(feature = "blocking")] 352 | /// Identical to Axoupdater::is_update_needed(), but performed synchronously. 353 | pub fn is_update_needed_sync(&mut self) -> AxoupdateResult { 354 | tokio::runtime::Builder::new_current_thread() 355 | .worker_threads(1) 356 | .max_blocking_threads(128) 357 | .enable_all() 358 | .build() 359 | .expect("Initializing tokio runtime failed") 360 | .block_on(self.is_update_needed()) 361 | } 362 | 363 | /// Returns the root of the install prefix, stripping the final `/bin` 364 | /// component if necessary. Works around a bug introduced in cargo-dist 365 | /// where this field was returned inconsistently in receipts for a few 366 | /// versions. 367 | pub fn install_prefix_root(&self) -> AxoupdateResult { 368 | let Some(install_prefix) = &self.install_prefix else { 369 | return Err(AxoupdateError::NotConfigured { 370 | missing_field: "install_prefix".to_owned(), 371 | }); 372 | }; 373 | 374 | let mut install_root = install_prefix.to_owned(); 375 | // Works around a bug in cargo-dist between 0.10.0 and 0.15.0, in which 376 | // prefix-style workspaces like CARGO_HOME had the prefix incorrectly 377 | // set to include the `bin` directory. 378 | if let Some(provider) = &self.current_version_installed_by { 379 | let min = Version::parse("0.10.0-prerelease.1").expect("failed to parse min version?!"); 380 | let max = Version::parse("0.15.0-prerelease.8").expect("failed to parse max version?!"); 381 | if provider.source == "cargo-dist" && provider.version >= min && provider.version < max 382 | { 383 | install_root = root_without_bin(&install_root); 384 | } 385 | } 386 | 387 | Ok(install_root) 388 | } 389 | 390 | /// Returns a normalized version of install_prefix_root, for comparison 391 | fn install_prefix_root_normalized(&self) -> AxoupdateResult { 392 | let raw_root = self.install_prefix_root()?; 393 | // The canonicalize path could fail if the path doesn't exist anymore; 394 | // catch that specific error here and return the original path. 395 | // (We want to leave the UTF8 conversion to the next step so we handle 396 | // those errors separately.) 397 | let canonicalized = if let Ok(path) = raw_root.canonicalize() { 398 | path 399 | } else { 400 | raw_root.into_std_path_buf() 401 | }; 402 | let normalized = Utf8PathBuf::from_path_buf(canonicalized) 403 | .map_err(|path| AxoupdateError::CaminoConversionFailed { path })?; 404 | Ok(normalized) 405 | } 406 | 407 | /// Attempts to perform an update. The return value specifies whether an 408 | /// update was actually performed or not; false indicates "no update was 409 | /// needed", while an error indicates that an update couldn't be performed 410 | /// due to an error. 411 | pub async fn run(&mut self) -> AxoupdateResult> { 412 | if !self.is_update_needed().await? { 413 | return Ok(None); 414 | } 415 | 416 | let release = match &self.requested_release { 417 | Some(r) => r, 418 | None => { 419 | self.fetch_release().await?; 420 | self.requested_release.as_ref().unwrap() 421 | } 422 | }; 423 | let tempdir = TempDir::new()?; 424 | 425 | // If we've been given an installer path to use, skip downloading and 426 | // install from that. 427 | let installer_path = if let Some(path) = &self.installer_path { 428 | path.to_owned() 429 | // Otherwise, proceed with downloading the installer from the release 430 | // we just looked up. 431 | } else { 432 | let app_name = self.name.clone().unwrap_or_default(); 433 | let installer_url = match env::consts::OS { 434 | "macos" | "linux" => release 435 | .assets 436 | .iter() 437 | .find(|asset| asset.name == format!("{app_name}-installer.sh")), 438 | "windows" => release 439 | .assets 440 | .iter() 441 | .find(|asset| asset.name == format!("{app_name}-installer.ps1")), 442 | _ => unreachable!(), 443 | }; 444 | 445 | let installer_url = if let Some(installer_url) = installer_url { 446 | installer_url 447 | } else { 448 | return Err(AxoupdateError::NoInstallerForPackage {}); 449 | }; 450 | 451 | let extension = if cfg!(windows) { ".ps1" } else { ".sh" }; 452 | 453 | let installer_path = 454 | Utf8PathBuf::try_from(tempdir.path().join(format!("installer{extension}")))?; 455 | 456 | #[cfg(unix)] 457 | { 458 | let installer_file = File::create(&installer_path)?; 459 | let mut perms = installer_file.metadata()?.permissions(); 460 | perms.set_mode(0o744); 461 | installer_file.set_permissions(perms)?; 462 | } 463 | 464 | let client = axoasset::reqwest::Client::new(); 465 | let download = client 466 | .get(&installer_url.browser_download_url) 467 | .header( 468 | axoasset::reqwest::header::ACCEPT, 469 | "application/octet-stream", 470 | ) 471 | .send() 472 | .await? 473 | .text() 474 | .await?; 475 | 476 | LocalAsset::write_new_all(&download, &installer_path)?; 477 | 478 | installer_path 479 | }; 480 | 481 | // Before we update, rename ourselves to a temporary name. 482 | // This is necessary because Windows won't let an actively-running 483 | // executable be overwritten. 484 | // If the update fails, we'll move it back to where it was before 485 | // we began the update process. 486 | let to_restore = if cfg!(target_family = "windows") { 487 | let old_filename = std::env::current_exe()?; 488 | 489 | let mut new_filename = old_filename.as_os_str().to_os_string(); 490 | // Filename follows the pattern set here: https://docs.rs/self-replace/1.5.0/self_replace/#implementation 491 | new_filename.push(OsStr::new(".previous.exe")); 492 | std::fs::rename(&old_filename, &new_filename)?; 493 | 494 | Some((new_filename, old_filename)) 495 | } else { 496 | None 497 | }; 498 | 499 | let path = if cfg!(windows) { 500 | "powershell" 501 | } else { 502 | installer_path.as_str() 503 | }; 504 | let mut command = Cmd::new(path, "execute installer"); 505 | if cfg!(windows) { 506 | // don't fall over on default security-policy windows machines 507 | // which require opt-in to execing powershell scripts. 508 | // This doesn't bypass proper organization-set policies. 509 | command.arg("-ExecutionPolicy").arg("ByPass"); 510 | command.arg(&installer_path); 511 | } 512 | if self.print_installer_stdout { 513 | command.stdout(Stdio::inherit()); 514 | } 515 | if self.print_installer_stderr { 516 | command.stderr(Stdio::inherit()); 517 | } 518 | command.check(false); 519 | // On Windows, fixes a bug that occurs if the parent process is 520 | // PowerShell Core. 521 | // https://github.com/PowerShell/PowerShell/issues/18530 522 | command.env_remove("PSModulePath"); 523 | let install_prefix = self.install_prefix_root()?; 524 | // Forces the generated installer to install to exactly this path, 525 | // regardless of how it's configured to install. 526 | command.env("CARGO_DIST_FORCE_INSTALL_DIR", &install_prefix); 527 | 528 | // Also set the app-specific name for this; in the future, the 529 | // CARGO_DIST_ version may be removed. 530 | let app_name = self.name.clone().unwrap_or_default(); 531 | let app_name_env_var = app_name_to_env_var(&app_name); 532 | let app_specific_env_var = format!("{app_name_env_var}_INSTALL_DIR"); 533 | command.env(app_specific_env_var, &install_prefix); 534 | 535 | // If the previous installation didn't modify the path, we shouldn't either 536 | if !self.modify_path { 537 | let app_specific_modify_path = format!("{app_name_env_var}_NO_MODIFY_PATH"); 538 | command.env(app_specific_modify_path, "1"); 539 | } 540 | 541 | let result = command.output(); 542 | 543 | let failed; 544 | let stdout; 545 | let stderr; 546 | let statuscode; 547 | if let Ok(output) = &result { 548 | failed = !output.status.success(); 549 | stdout = if output.stdout.is_empty() { 550 | None 551 | } else { 552 | Some(String::from_utf8_lossy(&output.stdout).to_string()) 553 | }; 554 | stderr = if output.stderr.is_empty() { 555 | None 556 | } else { 557 | Some(String::from_utf8_lossy(&output.stderr).to_string()) 558 | }; 559 | statuscode = output.status.code(); 560 | } else { 561 | failed = true; 562 | stdout = None; 563 | stderr = None; 564 | statuscode = None; 565 | } 566 | 567 | if let Some((ourselves, old_path)) = to_restore { 568 | if failed { 569 | std::fs::rename(ourselves, old_path)?; 570 | } else { 571 | #[cfg(windows)] 572 | self_replace::self_delete_at(&ourselves) 573 | .map_err(|_| AxoupdateError::CleanupFailed {})?; 574 | } 575 | } 576 | 577 | // Return the original AxoprocessError if we failed to launch 578 | // the command at all 579 | result?; 580 | 581 | // Otherwise return a more specific error with status code and 582 | // stdout/err. Note that this stdout/stderr will be None if the 583 | // caller requested us to print stdout/stderr to the terminal. 584 | if failed { 585 | return Err(AxoupdateError::InstallFailed { 586 | status: statuscode, 587 | stdout, 588 | stderr, 589 | }); 590 | } 591 | 592 | let result = UpdateResult { 593 | old_version: self.current_version.clone(), 594 | new_version: release.version.clone(), 595 | new_version_tag: release.tag_name.to_owned(), 596 | install_prefix, 597 | }; 598 | 599 | Ok(Some(result)) 600 | } 601 | 602 | #[cfg(feature = "blocking")] 603 | /// Identical to Axoupdater::run(), but performed synchronously. 604 | pub fn run_sync(&mut self) -> AxoupdateResult> { 605 | tokio::runtime::Builder::new_current_thread() 606 | .worker_threads(1) 607 | .max_blocking_threads(128) 608 | .enable_all() 609 | .build() 610 | .expect("Initializing tokio runtime failed") 611 | .block_on(self.run()) 612 | } 613 | 614 | /// Queries for new releases and then returns the detected version. 615 | pub async fn query_new_version(&mut self) -> AxoupdateResult> { 616 | self.fetch_release().await?; 617 | 618 | if let Some(release) = &self.requested_release { 619 | Ok(Some(&release.version)) 620 | } else { 621 | Ok(None) 622 | } 623 | } 624 | } 625 | 626 | fn get_app_name() -> Option { 627 | if let Ok(name) = env::var("AXOUPDATER_APP_NAME") { 628 | Some(name) 629 | } else if let Some(path) = args().next() { 630 | Utf8PathBuf::from(&path) 631 | .file_name() 632 | .map(|s| s.strip_suffix(".exe").unwrap_or(s)) 633 | .map(|s| s.strip_suffix("-update").unwrap_or(s)) 634 | .map(|s| s.to_owned()) 635 | } else { 636 | None 637 | } 638 | } 639 | 640 | /// Returns an environment variable-compatible version of the app name. 641 | pub fn app_name_to_env_var(app_name: &str) -> String { 642 | app_name.to_ascii_uppercase().replace('-', "_") 643 | } 644 | 645 | fn root_without_bin(path: &Utf8PathBuf) -> Utf8PathBuf { 646 | if path.file_name() == Some("bin") { 647 | if let Some(parent) = path.parent() { 648 | return parent.to_path_buf(); 649 | } 650 | } 651 | 652 | path.to_owned() 653 | } 654 | 655 | #[cfg(test)] 656 | mod tests { 657 | use std::path::{Path, PathBuf}; 658 | 659 | use crate::AxoUpdater; 660 | 661 | #[test] 662 | fn test_installer_path_str() { 663 | let mut updater = AxoUpdater::new(); 664 | updater.configure_installer_path("/tmp"); 665 | } 666 | 667 | #[test] 668 | fn test_installer_path_string() { 669 | let mut updater = AxoUpdater::new(); 670 | updater.configure_installer_path("/tmp".to_string()); 671 | } 672 | 673 | #[test] 674 | fn test_installer_path() { 675 | let mut updater = AxoUpdater::new(); 676 | let path = Path::new("/tmp"); 677 | updater.configure_installer_path(&path.to_string_lossy()); 678 | } 679 | 680 | #[test] 681 | fn test_installer_pathbuf() { 682 | let mut updater = AxoUpdater::new(); 683 | let mut path = PathBuf::new(); 684 | path.push("/tmp"); 685 | updater.configure_installer_path(&path.to_string_lossy()); 686 | } 687 | 688 | #[test] 689 | fn test_install_dir_path_str() { 690 | let mut updater = AxoUpdater::new(); 691 | updater.set_install_dir("/tmp"); 692 | } 693 | 694 | #[test] 695 | fn test_install_dir_path_string() { 696 | let mut updater = AxoUpdater::new(); 697 | updater.set_install_dir("/tmp".to_string()); 698 | } 699 | 700 | #[test] 701 | fn test_install_dir_path() { 702 | let mut updater = AxoUpdater::new(); 703 | let path = Path::new("/tmp"); 704 | updater.set_install_dir(&path.to_string_lossy()); 705 | } 706 | 707 | #[test] 708 | fn test_install_dir_pathbuf() { 709 | let mut updater = AxoUpdater::new(); 710 | let mut path = PathBuf::new(); 711 | path.push("/tmp"); 712 | updater.set_install_dir(&path.to_string_lossy()); 713 | } 714 | } 715 | --------------------------------------------------------------------------------