From dde16cbe2a85d15df1e1cf896b57fcd5605ba2a1 Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 22:08:09 +0100
Subject: [PATCH 01/19] release: prepare v1.0.3 with SDK 0.3.0 and crate
metadata
---
CHANGELOG.md | 19 +++++++++++++++++++
Cargo.lock | 27 ++++++++++++++++++++-------
Cargo.toml | 14 ++++++++++----
docs/INSTALLATION.md | 2 +-
install-prebuilt.ps1 | 2 +-
install-prebuilt.sh | 10 +++++-----
6 files changed, 56 insertions(+), 18 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47610f3..d43e781 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# Changelog
+## 2026-11-03 — v1.0.3
+
+### Dependencies
+
+- Adopted the xfetch SDK 0.3.0: `xfetch-api-common` 0.3.0 plus
+ `xfetch-plugin-api`, `xfetch-extension-api` and `xfetch-effect-api` 0.3.0.
+ The JSON wire protocol remains `1` and the WIT remains `0.1.0`.
+
+### Packaging
+
+- Added crates.io metadata: `repository`, `homepage`, `documentation`,
+ `readme`, `keywords` and `categories`.
+
+### Release
+
+- Bumped the crate version to 1.0.3 and the installer examples to 1.0.3.
+ The published v1.0.2 tag and its assets remain unchanged.
+
+
## 2026-11-03 — v1.0.2
### Security
diff --git a/Cargo.lock b/Cargo.lock
index 24c81f2..2db75cd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2975,9 +2975,19 @@ dependencies = [
"rustix",
]
+[[package]]
+name = "xfetch-api-common"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7a35b6c4f442aea26e73bb821978a637bd31261d80d4e97c11185876dd684c"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "xfetch-cli"
-version = "1.0.2"
+version = "1.0.3"
dependencies = [
"anyhow",
"base64 0.23.1",
@@ -3009,32 +3019,35 @@ dependencies = [
[[package]]
name = "xfetch-effect-api"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69820c4df7636fc23ab84a3b6cb7d3c9b58b523fbc10e0e0d733c8bc5facd009"
+checksum = "0bf8aacbdb2bfeef6a3139f24528786826a0db89a3d5ba2cde47948fa9093534"
dependencies = [
"serde",
"serde_json",
+ "xfetch-api-common",
]
[[package]]
name = "xfetch-extension-api"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f0d4e7f50b3cc65e4ad5a6556354975e24429c430a8df06ff665709ed383cab"
+checksum = "756a1b3e0f36eb3b2c549253a8a5298480a1da02e61a8433e4cbafa9913dca2f"
dependencies = [
"serde",
"serde_json",
+ "xfetch-api-common",
]
[[package]]
name = "xfetch-plugin-api"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54bc785c4c4bb18d4041edff98baeff88a9ff5595b52212a3a01c7d56e74fb8d"
+checksum = "0455e40808edcf2da4ced9c032eaee0c89b130ab76aa214257b8d90d1a5f5df5"
dependencies = [
"serde",
"serde_json",
+ "xfetch-api-common",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 02615f8..0c2f3b4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,9 +1,15 @@
[package]
name = "xfetch-cli"
-version = "1.0.2"
+version = "1.0.3"
edition = "2024"
license = "MIT"
description = "XFetch — a fast, customizable system information fetch tool for the terminal"
+repository = "https://github.com/xfetch-cli/xfetch"
+homepage = "https://xfetch-cli.github.io/web/"
+documentation = "https://docs.rs/xfetch-cli"
+readme = "README.md"
+keywords = ["system-information", "fetch", "cli", "terminal", "neofetch"]
+categories = ["command-line-utilities"]
[[bin]]
name = "xfetch"
@@ -49,9 +55,9 @@ wasmtime-wasi = { version = "50.0.1", optional = true, default-features = false,
"p1",
"p2",
] }
-xfetch-plugin-api = { version = "0.2.0", package = "xfetch-plugin-api" }
-xfetch-extension-api = { version = "0.2.0", package = "xfetch-extension-api" }
-xfetch-effect-api = { version = "0.2.0", package = "xfetch-effect-api" }
+xfetch-plugin-api = { version = "0.3.0", package = "xfetch-plugin-api" }
+xfetch-extension-api = { version = "0.3.0", package = "xfetch-extension-api" }
+xfetch-effect-api = { version = "0.3.0", package = "xfetch-effect-api" }
# Windows-only native probes: CPU/date/battery/GPU are read through the Win32
# API instead of PDH counters or PowerShell subprocesses (see
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
index d499e3e..197e690 100644
--- a/docs/INSTALLATION.md
+++ b/docs/INSTALLATION.md
@@ -313,7 +313,7 @@ makepkg -si
and gh):
-
VERSION=1.0.2
+
VERSION=1.0.3
ASSET="xfetch-${VERSION}-x86_64-unknown-linux-gnu.tar.gz"
BASE="https://github.com/xfetch-cli/xfetch/releases/download/v${VERSION}"
diff --git a/install-prebuilt.ps1 b/install-prebuilt.ps1
index 7cc44f0..956f0bb 100644
--- a/install-prebuilt.ps1
+++ b/install-prebuilt.ps1
@@ -5,7 +5,7 @@
#
# Usage:
# irm https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install-prebuilt.ps1 | iex
-# .\install-prebuilt.ps1 -Version 1.0.2 -BinDir C:\tools\bin
+# .\install-prebuilt.ps1 -Version 1.0.3 -BinDir C:\tools\bin
# .\install-prebuilt.ps1 -NoChecksum -NoPath
param(
diff --git a/install-prebuilt.sh b/install-prebuilt.sh
index 40cb54a..3fed8bf 100755
--- a/install-prebuilt.sh
+++ b/install-prebuilt.sh
@@ -3,7 +3,7 @@
# Prebuilt installer — downloads a precompiled binary from GitHub Releases.
# Fast: no Rust toolchain, no compilation. Requires only curl (and tar/unzip).
# Usage: curl -fsSL https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install-prebuilt.sh | bash
-# bash install-prebuilt.sh --version 1.0.2
+# bash install-prebuilt.sh --version 1.0.3
# bash install-prebuilt.sh --prefix /usr/local --yes
set -euo pipefail
@@ -174,7 +174,7 @@ Options:
-y, --yes Automatic yes to all prompts
-s, --skip-config Skip generating the default config
-q, --quiet Quiet mode (minimal output)
- --version Install a specific version (e.g. 1.0.2); default: latest release
+ --version Install a specific version (e.g. 1.0.3); default: latest release
--require-signature Abort when cosign is unavailable or the signature bundle is missing
--allow-unsigned Accept a signed release that is missing SHA256SUMS.bundle
(SHA-256 only; not recommended)
@@ -200,7 +200,7 @@ Examples:
curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash
# Install a specific version
- curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash -s -- --version 1.0.2
+ curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash -s -- --version 1.0.3
# Non-interactive install
bash install-prebuilt.sh --yes
@@ -255,7 +255,7 @@ parse_args() {
# ──────────────────────────────────────────────
# A release version must be a dotted numeric version, optionally with a
-# prerelease suffix (1.0.2, 1.0.2-rc1, 1.0.2-rc.2). Anything else is rejected
+# prerelease suffix (1.0.3, 1.0.3-rc1, 1.0.3-rc.2). Anything else is rejected
# instead of being silently coerced.
validate_version() {
printf '%s\n' "${1:-}" \
@@ -268,7 +268,7 @@ resolve_asset_url() {
release_url="${REPO_API}/releases/latest"
else
validate_version "${VERSION}" \
- || die "Invalid --version '${VERSION}': expected a dotted numeric release such as 1.0.2."
+ || die "Invalid --version '${VERSION}': expected a dotted numeric release such as 1.0.3."
release_url="${REPO_API}/releases/tags/v${VERSION}"
fi
From 7c2ff5e20de4cb89fb608ac48d8afccaa9e9518f Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 22:56:17 +0100
Subject: [PATCH 02/19] chore: start the crate description with Fast
---
CHANGELOG.md | 1 +
Cargo.toml | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d43e781..2c65fef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@
- Added crates.io metadata: `repository`, `homepage`, `documentation`,
`readme`, `keywords` and `categories`.
+- The crate description now starts with `Fast` instead of `A fast`.
### Release
diff --git a/Cargo.toml b/Cargo.toml
index 0c2f3b4..5581c1d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ name = "xfetch-cli"
version = "1.0.3"
edition = "2024"
license = "MIT"
-description = "XFetch — a fast, customizable system information fetch tool for the terminal"
+description = "Fast, customizable system information fetch tool for the terminal"
repository = "https://github.com/xfetch-cli/xfetch"
homepage = "https://xfetch-cli.github.io/web/"
documentation = "https://docs.rs/xfetch-cli"
From e26c1bc94bc872f856680008a829082ace73c7c4 Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Thu, 5 Nov 2026 12:23:45 +0100
Subject: [PATCH 03/19] feat(update): add Windows self-update support
Detect Windows install sources from install receipts, cargo metadata and WinGet paths. Prebuilt installs download the zip asset, verify SHA256SUMS, keep a backup and replace the running executable with self-replace. WinGet installs are delegated to winget upgrade; source builds are rebuilt from their checkout instead of switching to crates.io.
Split platform policy under src/update/platform and platform archive/provenance code under their own folders. The PowerShell installers now write %LOCALAPPDATA%/xfetch/install.json, and the updater tests run on windows-latest.
---
.github/workflows/rust-tests.yml | 13 ++
CHANGELOG.md | 11 +
Cargo.lock | 123 +++++++++++
Cargo.toml | 4 +-
docs/INSTALLATION.md | 44 +++-
install-prebuilt.ps1 | 28 +++
install.ps1 | 37 ++++
src/cli.rs | 2 +-
src/update/github.rs | 38 +++-
src/update/install.rs | 316 ----------------------------
src/update/install/mod.rs | 188 +++++++++++++++++
src/update/install/unix.rs | 156 ++++++++++++++
src/update/install/windows.rs | 183 ++++++++++++++++
src/update/mod.rs | 266 ++++++++++++++++++------
src/update/platform/mod.rs | 20 ++
src/update/platform/unix.rs | 63 ++++++
src/update/platform/unsupported.rs | 34 +++
src/update/platform/windows.rs | 122 +++++++++++
src/update/provenance.rs | 136 ------------
src/update/provenance/mod.rs | 322 +++++++++++++++++++++++++++++
src/update/provenance/unix.rs | 32 +++
src/update/provenance/windows.rs | 164 +++++++++++++++
src/update/receipt.rs | 194 +++++++++++++++++
uninstall.ps1 | 13 ++
24 files changed, 1980 insertions(+), 529 deletions(-)
delete mode 100644 src/update/install.rs
create mode 100644 src/update/install/mod.rs
create mode 100644 src/update/install/unix.rs
create mode 100644 src/update/install/windows.rs
create mode 100644 src/update/platform/mod.rs
create mode 100644 src/update/platform/unix.rs
create mode 100644 src/update/platform/unsupported.rs
create mode 100644 src/update/platform/windows.rs
delete mode 100644 src/update/provenance.rs
create mode 100644 src/update/provenance/mod.rs
create mode 100644 src/update/provenance/unix.rs
create mode 100644 src/update/provenance/windows.rs
create mode 100644 src/update/receipt.rs
diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml
index 6cb4e49..ca5012c 100644
--- a/.github/workflows/rust-tests.yml
+++ b/.github/workflows/rust-tests.yml
@@ -22,3 +22,16 @@ jobs:
- name: Run tests
run: cargo test --locked
+
+ windows-updater:
+ name: cargo test (windows updater)
+ runs-on: windows-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Install stable Rust toolchain
+ uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # stable
+
+ - name: Run updater tests on Windows
+ run: cargo test --locked --no-default-features --features update --bin xfetch update::
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c65fef..7560f8a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,17 @@
`readme`, `keywords` and `categories`.
- The crate description now starts with `Fast` instead of `A fast`.
+### Windows updater
+
+- `xfetch update` now works on Windows. Prebuilt installs download the
+ `.zip` release asset, verify `SHA256SUMS`, keep `xfetch.exe.bak` and replace
+ the running executable with `self-replace`. winget installs are delegated to
+ `winget upgrade --id Xfetch-cli.Xfetch`; source builds are rebuilt from their
+ checkout instead of being replaced with the crates.io release.
+- The PowerShell installers write `%LOCALAPPDATA%\xfetch\install.json`, so a
+ prebuilt install, a source build and a manually copied binary are no longer
+ confused even when they share a directory.
+
### Release
- Bumped the crate version to 1.0.3 and the installer examples to 1.0.3.
diff --git a/Cargo.lock b/Cargo.lock
index 2db75cd..543974d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -192,6 +192,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
[[package]]
name = "chacha20"
version = "0.10.2"
@@ -887,9 +893,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi",
"rand_core",
+ "wasm-bindgen",
]
[[package]]
@@ -1155,6 +1163,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "js-sys"
+version = "0.3.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
[[package]]
name = "json_comments"
version = "0.2.2"
@@ -1296,6 +1314,18 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "ntapi"
version = "0.4.3"
@@ -1731,6 +1761,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "self-replace"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "609c5606d9d357b26e4ee1a7a01beac396628d0fa01ebbade2a09cefb88e65af"
+dependencies = [
+ "fastrand",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "semver"
version = "1.0.28"
@@ -2142,6 +2183,12 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "typed-path"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -2267,6 +2314,50 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.130"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
+dependencies = [
+ "cfg-if",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.130"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.130"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.130"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
+dependencies = [
+ "unicode-ident",
+]
+
[[package]]
name = "wasm-encoder"
version = "0.261.0"
@@ -2999,6 +3090,7 @@ dependencies = [
"image",
"json_comments",
"libc",
+ "self-replace",
"semver",
"serde",
"serde_json",
@@ -3015,6 +3107,7 @@ dependencies = [
"xfetch-effect-api",
"xfetch-extension-api",
"xfetch-plugin-api",
+ "zip",
]
[[package]]
@@ -3153,6 +3246,24 @@ dependencies = [
"syn 3.0.9",
]
+[[package]]
+name = "zip"
+version = "9.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "getrandom 0.4.3",
+ "indexmap",
+ "libc",
+ "memchr",
+ "nix",
+ "typed-path",
+ "windows-sys 0.61.2",
+ "zopfli",
+]
+
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -3165,6 +3276,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+[[package]]
+name = "zopfli"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
[[package]]
name = "zstd"
version = "0.13.4"
diff --git a/Cargo.toml b/Cargo.toml
index 5581c1d..d5033e4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,7 +20,7 @@ path = "src/main.rs"
# default; build with `--no-default-features` for a minimal binary.
default = ["wasm", "update"]
wasm = ["dep:wasmtime", "dep:wasmtime-wasi", "dep:anyhow", "dep:base64", "dep:url"]
-update = ["dep:flate2", "dep:semver", "dep:tar"]
+update = ["dep:flate2", "dep:semver", "dep:tar", "dep:zip", "dep:self-replace"]
[dependencies]
anyhow = { version = "1", optional = true }
@@ -63,6 +63,8 @@ xfetch-effect-api = { version = "0.3.0", package = "xfetch-effect-api" }
# API instead of PDH counters or PowerShell subprocesses (see
# src/info/platform/windows/). The dependency never enters other targets.
[target.'cfg(windows)'.dependencies]
+self-replace = { version = "1.5", optional = true }
+zip = { version = "9", default-features = false, features = ["deflate"], optional = true }
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
index 197e690..0d14d13 100644
--- a/docs/INSTALLATION.md
+++ b/docs/INSTALLATION.md
@@ -395,11 +395,13 @@ xfetch update --yes # skip the confirmation prompt
- Prebuilt installs (install-prebuilt.sh,
- typically ~/.local/bin): the matching release asset is
- downloaded, verified against the published SHA256SUMS,
- extracted and moved over the current executable with an atomic rename. A
- single xfetch.bak keeps the previous binary and is
+ Prebuilt installs (install-prebuilt.sh on
+ Unix, install-prebuilt.ps1 on Windows): the matching release
+ asset is downloaded, verified against the published
+ SHA256SUMS, extracted and moved over the current executable.
+ Unix uses an atomic rename; Windows uses a self-replace helper because a
+ running .exe cannot be deleted. A single xfetch.bak
+ (or xfetch.exe.bak) keeps the previous binary and is
overwritten on the next update.
- Package-manager installs and local builds: never replaced;
- the command prints the right command instead.
+ Source builds (install.ps1 or
+ cargo install --path): rebuilt from the source checkout when
+ it still exists; otherwise the command prints the rebuild command. They
+ are never replaced with the crates.io release.
+
+
+ winget installs (Windows): delegated to
+ winget upgrade --id Xfetch-cli.Xfetch --exact. xfetch never
+ overwrites a winget-managed file itself. winget may lag behind the GitHub
+ release while its manifest is reviewed; in that case the command reports
+ the pending manifest instead of pretending the update succeeded.
+
+
+ Manual copies and local builds: never replaced
+ automatically; the command prints the right command or the release URL.
+
+ The PowerShell installers write
+ %LOCALAPPDATA%\xfetch\install.json to record whether the binary
+ is a prebuilt install or a source build. This is what distinguishes a
+ script-installed binary from a manual copy in the same directory. Existing
+ installs without a receipt are treated conservatively as unknown.
+
+
xfetch update --prebuilt --bin-dir <dir> forces the
- in-place update of <dir>/xfetch (Unix only). On Windows
- the prebuilt path is not available yet; use
- cargo install xfetch-cli --force or your installer of choice.
+ in-place update of <dir>/xfetch (or
+ <dir>\xfetch.exe on Windows). It refuses to touch
+ binaries owned by cargo or winget. This is also the escape hatch for a
+ manually downloaded binary.
diff --git a/install-prebuilt.ps1 b/install-prebuilt.ps1
index 956f0bb..e00d093 100644
--- a/install-prebuilt.ps1
+++ b/install-prebuilt.ps1
@@ -29,6 +29,33 @@ function Fail($Message) {
exit 1
}
+# Records how this copy was installed. The updater reads it before falling
+# back to path heuristics, so a prebuilt install and a manually copied build
+# in the same directory are not confused.
+function Write-InstallReceipt($Method, $Path, $Version, $Source, $SourcePath, $Repo, $Commit) {
+ if (-not $env:LOCALAPPDATA) { return }
+ try {
+ $receiptDir = Join-Path $env:LOCALAPPDATA "xfetch"
+ New-Item -ItemType Directory -Path $receiptDir -Force | Out-Null
+ $receipt = [ordered]@{
+ schema_version = 1
+ method = $Method
+ path = $Path
+ installed_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
+ }
+ if ($Version) { $receipt.version = $Version }
+ if ($Source) { $receipt.source = $Source }
+ if ($SourcePath) { $receipt.source_path = $SourcePath }
+ if ($Repo) { $receipt.repo = $Repo }
+ if ($Commit) { $receipt.commit = $Commit }
+ $json = $receipt | ConvertTo-Json -Compress
+ $utf8 = New-Object System.Text.UTF8Encoding -ArgumentList $false
+ [System.IO.File]::WriteAllText((Join-Path $receiptDir "install.json"), $json, $utf8)
+ } catch {
+ Write-Warn "Could not write install receipt: $($_.Exception.Message)"
+ }
+}
+
# Windows PowerShell 5.1 defaults to older TLS versions; GitHub needs 1.2.
if ($PSVersionTable.PSVersion.Major -lt 6) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
@@ -128,6 +155,7 @@ try {
Fail "Could not install the binary: $($_.Exception.Message)"
}
Write-Ok "Installed binary: $installPath"
+ Write-InstallReceipt -Method "prebuilt" -Path $installPath -Version $Version -Source "install-prebuilt.ps1"
if (-not $NoPath -and $env:OS -eq "Windows_NT") {
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
diff --git a/install.ps1 b/install.ps1
index 66e2efc..7f164ee 100644
--- a/install.ps1
+++ b/install.ps1
@@ -8,6 +8,32 @@ param(
Write-Host "Installing xfetch..." -ForegroundColor Cyan
+# Records that this binary came from a source checkout. The updater uses it
+# to distinguish a path install from `cargo install xfetch-cli`.
+function Write-InstallReceipt($Method, $Path, $Version, $Source, $SourcePath, $Repo, $Commit) {
+ if (-not $env:LOCALAPPDATA) { return }
+ try {
+ $receiptDir = Join-Path $env:LOCALAPPDATA "xfetch"
+ New-Item -ItemType Directory -Path $receiptDir -Force | Out-Null
+ $receipt = [ordered]@{
+ schema_version = 1
+ method = $Method
+ path = $Path
+ installed_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
+ }
+ if ($Version) { $receipt.version = $Version }
+ if ($Source) { $receipt.source = $Source }
+ if ($SourcePath) { $receipt.source_path = $SourcePath }
+ if ($Repo) { $receipt.repo = $Repo }
+ if ($Commit) { $receipt.commit = $Commit }
+ $json = $receipt | ConvertTo-Json -Compress
+ $utf8 = New-Object System.Text.UTF8Encoding -ArgumentList $false
+ [System.IO.File]::WriteAllText((Join-Path $receiptDir "install.json"), $json, $utf8)
+ } catch {
+ Write-Host "Could not write install receipt: $($_.Exception.Message)" -ForegroundColor Yellow
+ }
+}
+
# Check for Rust/Cargo
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
if ($Yes -or [Console]::IsInputRedirected) {
@@ -78,6 +104,17 @@ if ($UserPath -notlike "*$CargoBin*") {
Write-Host "Added $CargoBin to user PATH." -ForegroundColor Green
}
+# Record the source build before the temporary clone is removed.
+$InstalledVersion = ""
+try {
+ $InstalledVersion = ((& xfetch --version 2>$null) -replace '^xfetch(?:-cli)?\s+', '').Trim()
+} catch {}
+$Commit = ""
+try {
+ $Commit = (git -C $TempDir rev-parse HEAD 2>$null).Trim()
+} catch {}
+Write-InstallReceipt -Method "source" -Path (Join-Path $CargoBin "xfetch.exe") -Version $InstalledVersion -Source "install.ps1" -Repo $RepoUrl -Commit $Commit
+
# Setup Config
$ConfigDir = Join-Path $env:APPDATA "xfetch"
Write-Host "Setting up default config..." -ForegroundColor Cyan
diff --git a/src/cli.rs b/src/cli.rs
index 8a93443..3c5d0ed 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -94,7 +94,7 @@ pub enum Commands {
#[arg(long)]
check: bool,
/// Install the prebuilt release in place, even for local builds
- /// (Unix only).
+ /// (Unix and Windows).
#[arg(long)]
prebuilt: bool,
/// Directory holding the xfetch binary to replace (with --prebuilt).
diff --git a/src/update/github.rs b/src/update/github.rs
index c33ff16..1bcdfe1 100644
--- a/src/update/github.rs
+++ b/src/update/github.rs
@@ -90,7 +90,6 @@ impl Client {
}
/// Downloads a binary asset with a bounded size.
- #[cfg_attr(not(unix), allow(dead_code))]
pub fn get_bytes(&self, url: &str, cap: usize) -> Result, String> {
let mut response = self.request(url, Duration::from_secs(300))?;
let declared = response.content_length.unwrap_or(0);
@@ -196,20 +195,24 @@ pub fn parse_release(body: &str) -> Result {
}
/// Release asset name for one target, as produced by the release workflow.
-#[cfg_attr(not(unix), allow(dead_code))]
+///
+/// Unix targets are published as `.tar.gz`; the Windows target is a `.zip`.
pub fn asset_name(version: &Version, target: &str) -> String {
- format!("xfetch-{}-{}.tar.gz", version, target)
+ let extension = if target.contains("windows") {
+ "zip"
+ } else {
+ "tar.gz"
+ };
+ format!("xfetch-{}-{}.{}", version, target, extension)
}
/// Finds the prebuilt asset for `target`.
-#[cfg_attr(not(unix), allow(dead_code))]
pub fn choose_asset<'a>(release: &'a Release, target: &str) -> Option<&'a Asset> {
let name = asset_name(&release.version, target);
release.assets.iter().find(|asset| asset.name == name)
}
/// Finds the `SHA256SUMS` asset.
-#[cfg_attr(not(unix), allow(dead_code))]
pub fn checksums_asset(release: &Release) -> Option<&Asset> {
release
.assets
@@ -218,7 +221,6 @@ pub fn checksums_asset(release: &Release) -> Option<&Asset> {
}
/// Parses one hash out of a `sha256sum`-style file.
-#[cfg_attr(not(unix), allow(dead_code))]
pub fn parse_checksum(sums: &str, file: &str) -> Option {
for line in sums.lines() {
let mut parts = line.split_whitespace();
@@ -244,6 +246,11 @@ mod tests {
"browser_download_url": "https://example.com/xfetch.tar.gz",
"size": 1234
},
+ {
+ "name": "xfetch-1.2.3-x86_64-pc-windows-msvc.zip",
+ "browser_download_url": "https://example.com/xfetch.zip",
+ "size": 2345
+ },
{
"name": "SHA256SUMS",
"browser_download_url": "https://example.com/SHA256SUMS",
@@ -257,10 +264,14 @@ mod tests {
let release = parse_release(FIXTURE).expect("parse");
assert_eq!(release.version, Version::parse("1.2.3").expect("version"));
assert_eq!(release.tag, "v1.2.3");
- assert_eq!(release.assets.len(), 2);
+ assert_eq!(release.assets.len(), 3);
let asset = choose_asset(&release, "x86_64-unknown-linux-gnu").expect("asset");
assert_eq!(asset.url, "https://example.com/xfetch.tar.gz");
+
+ let windows = choose_asset(&release, "x86_64-pc-windows-msvc").expect("windows asset");
+ assert_eq!(windows.url, "https://example.com/xfetch.zip");
+
assert!(
choose_asset(&release, "aarch64-apple-darwin").is_none(),
"missing targets must not match"
@@ -268,6 +279,19 @@ mod tests {
assert!(checksums_asset(&release).is_some());
}
+ #[test]
+ fn asset_names_use_zip_for_windows() {
+ let version = Version::parse("1.2.3").expect("version");
+ assert_eq!(
+ asset_name(&version, "x86_64-unknown-linux-gnu"),
+ "xfetch-1.2.3-x86_64-unknown-linux-gnu.tar.gz"
+ );
+ assert_eq!(
+ asset_name(&version, "x86_64-pc-windows-msvc"),
+ "xfetch-1.2.3-x86_64-pc-windows-msvc.zip"
+ );
+ }
+
#[test]
fn rejects_bad_tags() {
let body = r#"{"tag_name": "not-a-version", "assets": []}"#;
diff --git a/src/update/install.rs b/src/update/install.rs
deleted file mode 100644
index eb07e86..0000000
--- a/src/update/install.rs
+++ /dev/null
@@ -1,316 +0,0 @@
-//! In-place prebuilt updates (Unix).
-//!
-//! The release tarball is downloaded, verified against the published
-//! `SHA256SUMS`, extracted to a hidden temporary file next to the target and
-//! moved over the current executable with a single atomic rename. The
-//! previous binary is kept once as `xfetch.bak` (overwritten by the next
-//! update, so no version graveyard accumulates).
-
-use super::github::{self, Client, Release};
-use crate::hash::sha256_hex;
-use std::fs;
-use std::io::{Cursor, Read};
-use std::os::unix::fs::PermissionsExt;
-use std::path::{Path, PathBuf};
-use std::time::Duration;
-
-/// Upper bound for a release asset (the binaries are a few MiB).
-const MAX_ASSET: usize = 200 * 1024 * 1024;
-/// Upper bound for the `SHA256SUMS` file.
-const MAX_SUMS: usize = 1024 * 1024;
-
-/// Downloads, verifies and installs the release for this platform.
-pub fn update_prebuilt(
- client: &Client,
- release: &Release,
- target_dir: &Path,
-) -> Result {
- let target = release_target()
- .ok_or_else(|| "Prebuilt updates are not available for this platform".to_string())?;
-
- let asset = github::choose_asset(release, target).ok_or_else(|| {
- format!(
- "Release {} does not publish a prebuilt binary for {}",
- release.tag, target
- )
- })?;
- let sums = github::checksums_asset(release)
- .ok_or_else(|| format!("Release {} does not publish SHA256SUMS", release.tag))?;
-
- let sums_text = client.get_text(&sums.url, MAX_SUMS, Duration::from_secs(30))?;
- let expected = github::parse_checksum(&sums_text, &asset.name).ok_or_else(|| {
- format!(
- "SHA256SUMS does not list {}; refusing to install an unverified binary",
- asset.name
- )
- })?;
-
- let bytes = client.get_bytes(&asset.url, MAX_ASSET)?;
- let actual = sha256_hex(&bytes);
- if actual != expected {
- return Err(format!(
- "Checksum mismatch for {}: expected {}, got {}",
- asset.name, expected, actual
- ));
- }
-
- fs::create_dir_all(target_dir)
- .map_err(|err| format!("Cannot create '{}': {}", target_dir.display(), err))?;
-
- let new_binary = extract_binary(&bytes, target_dir)?;
- match replace_binary(target_dir, &new_binary) {
- Ok(path) => Ok(path),
- Err(err) => {
- let _ = fs::remove_file(&new_binary);
- Err(err)
- }
- }
-}
-
-/// The release workflow target triple for the running platform.
-pub fn release_target() -> Option<&'static str> {
- if cfg!(all(
- target_os = "linux",
- target_arch = "x86_64",
- target_env = "gnu"
- )) {
- Some("x86_64-unknown-linux-gnu")
- } else if cfg!(all(
- target_os = "linux",
- target_arch = "x86_64",
- target_env = "musl"
- )) {
- Some("x86_64-unknown-linux-musl")
- } else if cfg!(all(
- target_os = "linux",
- target_arch = "aarch64",
- target_env = "gnu"
- )) {
- Some("aarch64-unknown-linux-gnu")
- } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
- Some("x86_64-apple-darwin")
- } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
- Some("aarch64-apple-darwin")
- } else {
- None
- }
-}
-
-/// Extracts the `xfetch` entry from the release tarball into a temporary file.
-fn extract_binary(bytes: &[u8], dir: &Path) -> Result {
- let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes));
- let mut archive = tar::Archive::new(decoder);
- let tmp = dir.join(format!(".xfetch-update-{}", std::process::id()));
-
- let entries = archive
- .entries()
- .map_err(|err| format!("Invalid release archive: {}", err))?;
- for entry in entries {
- let mut entry = entry.map_err(|err| format!("Invalid archive entry: {}", err))?;
- let path = entry
- .path()
- .map_err(|err| format!("Invalid archive path: {}", err))?;
- if path.file_name().and_then(|name| name.to_str()) != Some("xfetch") {
- continue;
- }
-
- let mut file = fs::File::create(&tmp)
- .map_err(|err| format!("Cannot create '{}': {}", tmp.display(), err))?;
- std::io::copy(&mut entry, &mut file)
- .map_err(|err| format!("Failed to extract the binary: {}", err))?;
- let _ = file.sync_all();
- fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755))
- .map_err(|err| format!("Cannot mark '{}' executable: {}", tmp.display(), err))?;
- return Ok(tmp);
- }
-
- let _ = fs::remove_file(&tmp);
- Err("The release archive does not contain the xfetch binary".to_string())
-}
-
-/// Keeps one backup and moves the new binary into place atomically.
-fn replace_binary(target_dir: &Path, new_binary: &Path) -> Result {
- let target = target_dir.join("xfetch");
- let backup = target_dir.join("xfetch.bak");
-
- if target.exists() {
- fs::copy(&target, &backup)
- .map_err(|err| format!("Cannot back up '{}': {}", target.display(), err))?;
- }
-
- fs::rename(new_binary, &target)
- .map_err(|err| format!("Cannot replace '{}': {}", target.display(), err))?;
-
- Ok(target)
-}
-
-/// Reads a bounded stream into memory.
-#[allow(dead_code)]
-fn read_bounded(reader: impl Read, cap: usize) -> Result, String> {
- let mut body = Vec::new();
- reader
- .take(cap as u64 + 1)
- .read_to_end(&mut body)
- .map_err(|err| format!("Read failed: {}", err))?;
- if body.len() > cap {
- return Err("Payload exceeds the size limit".to_string());
- }
- Ok(body)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use flate2::Compression;
- use flate2::write::GzEncoder;
- use std::io::Write;
-
- fn scratch(tag: &str) -> PathBuf {
- let dir =
- std::env::temp_dir().join(format!("xfetch-update-test-{}-{}", tag, std::process::id()));
- let _ = fs::remove_dir_all(&dir);
- fs::create_dir_all(&dir).expect("create scratch");
- dir
- }
-
- fn tarball(name: &str, payload: &[u8]) -> Vec {
- let encoder = GzEncoder::new(Vec::new(), Compression::fast());
- let mut builder = tar::Builder::new(encoder);
-
- let mut header = tar::Header::new_gnu();
- header.set_size(payload.len() as u64);
- header.set_mode(0o755);
- header.set_cksum();
- builder
- .append_data(&mut header, format!("./{}", name), payload)
- .expect("append");
-
- builder
- .into_inner()
- .expect("finish tar")
- .finish()
- .expect("finish gzip")
- }
-
- #[test]
- fn sha256_matches_known_vector() {
- assert_eq!(
- sha256_hex(b"abc"),
- "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
- );
- }
-
- #[test]
- fn extracts_xfetch_from_the_archive() {
- let dir = scratch("extract");
- let archive = tarball("xfetch", b"binary-bytes");
- let extracted = extract_binary(&archive, &dir).expect("extract");
- assert_eq!(fs::read(&extracted).expect("read"), b"binary-bytes");
- let mode = fs::metadata(&extracted)
- .expect("metadata")
- .permissions()
- .mode();
- assert_eq!(mode & 0o777, 0o755);
- let _ = fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn rejects_archives_without_the_binary() {
- let dir = scratch("missing");
- let archive = tarball("README.md", b"docs");
- assert!(extract_binary(&archive, &dir).is_err());
- let _ = fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn replaces_the_binary_and_keeps_one_backup() {
- let dir = scratch("replace");
- let target = dir.join("xfetch");
- fs::write(&target, b"old").expect("write old");
- let new_binary = dir.join(".xfetch-update-test-new");
- fs::write(&new_binary, b"new").expect("write new");
-
- let replaced = replace_binary(&dir, &new_binary).expect("replace");
- assert_eq!(replaced, target);
- assert_eq!(fs::read(&target).expect("read"), b"new");
- assert_eq!(fs::read(dir.join("xfetch.bak")).expect("backup"), b"old");
- assert!(!new_binary.exists());
-
- // A second update overwrites the same backup instead of accumulating.
- let newer = dir.join(".xfetch-update-test-newer");
- fs::write(&newer, b"newest").expect("write newest");
- replace_binary(&dir, &newer).expect("replace again");
- assert_eq!(fs::read(dir.join("xfetch.bak")).expect("backup"), b"new");
- let backups: Vec<_> = fs::read_dir(&dir)
- .expect("read dir")
- .filter_map(Result::ok)
- .filter(|entry| entry.file_name().to_string_lossy().contains(".bak"))
- .collect();
- assert_eq!(backups.len(), 1, "only one backup file must exist");
- let _ = fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn reads_bounded_rejects_oversized_payloads() {
- let mut small = &b"abcd"[..];
- assert_eq!(read_bounded(&mut small, 8).expect("small"), b"abcd");
- let mut big = &b"abcdef"[..];
- assert!(read_bounded(&mut big, 3).is_err());
- }
-
- #[test]
- fn release_target_is_known_on_supported_platforms() {
- #[cfg(any(target_os = "linux", target_os = "macos"))]
- assert!(release_target().is_some());
- #[cfg(not(any(target_os = "linux", target_os = "macos")))]
- assert!(release_target().is_none());
- }
-
- #[test]
- fn tarball_helper_roundtrips() {
- let dir = scratch("roundtrip");
- let archive = tarball("xfetch", b"payload");
- let extracted = extract_binary(&archive, &dir).expect("extract");
- assert_eq!(fs::read(extracted).expect("read"), b"payload");
- let _ = fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn encoder_writes_are_flushed() {
- let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
- encoder.write_all(b"x").expect("write");
- assert!(!encoder.finish().expect("finish").is_empty());
- }
-
- /// Full path against a real GitHub release: fetch, download, verify the
- /// checksum, extract and replace. Ignored by default so offline builds and
- /// CI do not depend on the network.
- #[test]
- #[ignore = "requires network access"]
- fn downloads_verifies_and_runs_the_latest_release() {
- let client = Client::new().expect("client");
- let release =
- github::fetch_latest(&client, &super::super::api_base()).expect("fetch latest release");
- let dir = scratch("network");
-
- let installed = update_prebuilt(&client, &release, &dir).expect("update");
- assert_eq!(
- installed.file_name().and_then(|n| n.to_str()),
- Some("xfetch")
- );
-
- let output = std::process::Command::new(&installed)
- .arg("--version")
- .output()
- .expect("run installed binary");
- assert!(output.status.success(), "installed binary failed to run");
- let stdout = String::from_utf8_lossy(&output.stdout);
- assert!(
- stdout.to_ascii_lowercase().contains("xfetch"),
- "unexpected version output: {}",
- stdout
- );
-
- let _ = fs::remove_dir_all(&dir);
- }
-}
diff --git a/src/update/install/mod.rs b/src/update/install/mod.rs
new file mode 100644
index 0000000..16f4c2b
--- /dev/null
+++ b/src/update/install/mod.rs
@@ -0,0 +1,188 @@
+//! In-place prebuilt updates.
+//!
+//! The release asset is downloaded, verified against the published
+//! `SHA256SUMS`, extracted to a hidden temporary file next to the target and
+//! moved over the current executable. The previous binary is kept once as
+//! `xfetch.bak` / `xfetch.exe.bak` (overwritten by the next update, so no
+//! version graveyard accumulates).
+//!
+//! Unix uses an atomic rename. Windows cannot delete a running executable, so
+//! the replacement is delegated to `self-replace`, which renames the running
+//! image aside and moves the new one into place.
+
+#[cfg(unix)]
+mod unix;
+#[cfg(windows)]
+mod windows;
+
+use super::github::{self, Client, Release};
+use super::platform;
+use crate::hash::sha256_hex;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+/// Upper bound for a release asset (the binaries are a few MiB).
+pub(super) const MAX_ASSET: usize = 200 * 1024 * 1024;
+/// Upper bound for the `SHA256SUMS` file.
+const MAX_SUMS: usize = 1024 * 1024;
+
+/// Downloads, verifies and installs the release for this platform.
+pub fn update_prebuilt(
+ client: &Client,
+ release: &Release,
+ target_dir: &Path,
+) -> Result {
+ let target = platform::release_target()
+ .ok_or_else(|| "Prebuilt updates are not available for this platform".to_string())?;
+
+ let asset = github::choose_asset(release, target).ok_or_else(|| {
+ format!(
+ "Release {} does not publish a prebuilt binary for {}",
+ release.tag, target
+ )
+ })?;
+ let sums = github::checksums_asset(release)
+ .ok_or_else(|| format!("Release {} does not publish SHA256SUMS", release.tag))?;
+
+ let sums_text = client.get_text(&sums.url, MAX_SUMS, Duration::from_secs(30))?;
+ let expected = github::parse_checksum(&sums_text, &asset.name).ok_or_else(|| {
+ format!(
+ "SHA256SUMS does not list {}; refusing to install an unverified binary",
+ asset.name
+ )
+ })?;
+
+ let bytes = client.get_bytes(&asset.url, MAX_ASSET)?;
+ let actual = sha256_hex(&bytes);
+ if actual != expected {
+ return Err(format!(
+ "Checksum mismatch for {}: expected {}, got {}",
+ asset.name, expected, actual
+ ));
+ }
+
+ fs::create_dir_all(target_dir)
+ .map_err(|err| format!("Cannot create '{}': {}", target_dir.display(), err))?;
+
+ let new_binary = extract_binary(&bytes, target_dir)?;
+ match replace_binary(target_dir, &new_binary) {
+ Ok(path) => Ok(path),
+ Err(err) => {
+ let _ = fs::remove_file(&new_binary);
+ Err(err)
+ }
+ }
+}
+
+fn extract_binary(bytes: &[u8], dir: &Path) -> Result {
+ #[cfg(unix)]
+ {
+ unix::extract_binary(bytes, dir)
+ }
+ #[cfg(windows)]
+ {
+ windows::extract_binary(bytes, dir)
+ }
+ #[cfg(not(any(unix, windows)))]
+ {
+ let _ = (bytes, dir);
+ Err("Prebuilt updates are not available for this platform".to_string())
+ }
+}
+
+fn replace_binary(target_dir: &Path, new_binary: &Path) -> Result {
+ #[cfg(unix)]
+ {
+ unix::replace_binary(target_dir, new_binary)
+ }
+ #[cfg(windows)]
+ {
+ windows::replace_binary(target_dir, new_binary)
+ }
+ #[cfg(not(any(unix, windows)))]
+ {
+ let _ = (target_dir, new_binary);
+ Err("Prebuilt updates are not available for this platform".to_string())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn sha256_matches_known_vector() {
+ assert_eq!(
+ sha256_hex(b"abc"),
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
+ );
+ }
+
+ #[test]
+ fn release_target_is_known_on_supported_platforms() {
+ #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
+ assert!(platform::release_target().is_some());
+ #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
+ assert!(platform::release_target().is_none());
+ }
+
+ #[test]
+ fn reads_bounded_rejects_oversized_payloads() {
+ use std::io::Read;
+
+ fn read_bounded(reader: impl Read, cap: usize) -> Result, String> {
+ let mut body = Vec::new();
+ reader
+ .take(cap as u64 + 1)
+ .read_to_end(&mut body)
+ .map_err(|err| format!("Read failed: {}", err))?;
+ if body.len() > cap {
+ return Err("Payload exceeds the size limit".to_string());
+ }
+ Ok(body)
+ }
+
+ let mut small = &b"abcd"[..];
+ assert_eq!(read_bounded(&mut small, 8).expect("small"), b"abcd");
+ let mut big = &b"abcdef"[..];
+ assert!(read_bounded(&mut big, 3).is_err());
+ }
+
+ /// Full path against a real GitHub release: fetch, download, verify the
+ /// checksum, extract and replace. Ignored by default so offline builds and
+ /// CI do not depend on the network. Unix-only because running it on
+ /// Windows would replace the test harness itself.
+ #[cfg(unix)]
+ #[test]
+ #[ignore = "requires network access"]
+ fn downloads_verifies_and_runs_the_latest_release() {
+ let client = Client::new().expect("client");
+ let release =
+ github::fetch_latest(&client, &super::super::api_base()).expect("fetch latest release");
+ let dir =
+ std::env::temp_dir().join(format!("xfetch-update-test-network-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).expect("create scratch");
+
+ let installed = update_prebuilt(&client, &release, &dir).expect("update");
+ assert_eq!(
+ installed.file_name().and_then(|n| n.to_str()),
+ Some("xfetch")
+ );
+
+ let output = std::process::Command::new(&installed)
+ .arg("--version")
+ .output()
+ .expect("run installed binary");
+ assert!(output.status.success(), "installed binary failed to run");
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ stdout.to_ascii_lowercase().contains("xfetch"),
+ "unexpected version output: {}",
+ stdout
+ );
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+}
diff --git a/src/update/install/unix.rs b/src/update/install/unix.rs
new file mode 100644
index 0000000..f21c260
--- /dev/null
+++ b/src/update/install/unix.rs
@@ -0,0 +1,156 @@
+//! Unix prebuilt archive extraction and in-place replacement.
+
+use super::super::platform::binary_name;
+use std::fs;
+use std::io::Cursor;
+use std::os::unix::fs::PermissionsExt;
+use std::path::{Path, PathBuf};
+
+/// Extracts the `xfetch` entry from the release tarball into a temporary file.
+pub(super) fn extract_binary(bytes: &[u8], dir: &Path) -> Result {
+ let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes));
+ let mut archive = tar::Archive::new(decoder);
+ let tmp = dir.join(format!(".xfetch-update-{}", std::process::id()));
+
+ let entries = archive
+ .entries()
+ .map_err(|err| format!("Invalid release archive: {}", err))?;
+ for entry in entries {
+ let mut entry = entry.map_err(|err| format!("Invalid archive entry: {}", err))?;
+ let path = entry
+ .path()
+ .map_err(|err| format!("Invalid archive path: {}", err))?;
+ if path.file_name().and_then(|name| name.to_str()) != Some(binary_name()) {
+ continue;
+ }
+
+ let mut file = fs::File::create(&tmp)
+ .map_err(|err| format!("Cannot create '{}': {}", tmp.display(), err))?;
+ std::io::copy(&mut entry, &mut file)
+ .map_err(|err| format!("Failed to extract the binary: {}", err))?;
+ let _ = file.sync_all();
+ fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755))
+ .map_err(|err| format!("Cannot mark '{}' executable: {}", tmp.display(), err))?;
+ return Ok(tmp);
+ }
+
+ let _ = fs::remove_file(&tmp);
+ Err("The release archive does not contain the xfetch binary".to_string())
+}
+
+/// Keeps one backup and moves the new binary into place atomically.
+pub(super) fn replace_binary(target_dir: &Path, new_binary: &Path) -> Result {
+ let target = target_dir.join(binary_name());
+ let backup = target_dir.join("xfetch.bak");
+
+ if target.exists() {
+ fs::copy(&target, &backup)
+ .map_err(|err| format!("Cannot back up '{}': {}", target.display(), err))?;
+ }
+
+ fs::rename(new_binary, &target)
+ .map_err(|err| format!("Cannot replace '{}': {}", target.display(), err))?;
+
+ Ok(target)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use flate2::Compression;
+ use flate2::write::GzEncoder;
+ use std::io::Write;
+
+ fn scratch(tag: &str) -> PathBuf {
+ let dir =
+ std::env::temp_dir().join(format!("xfetch-update-test-{}-{}", tag, std::process::id()));
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).expect("create scratch");
+ dir
+ }
+
+ fn tarball(name: &str, payload: &[u8]) -> Vec {
+ let encoder = GzEncoder::new(Vec::new(), Compression::fast());
+ let mut builder = tar::Builder::new(encoder);
+
+ let mut header = tar::Header::new_gnu();
+ header.set_size(payload.len() as u64);
+ header.set_mode(0o755);
+ header.set_cksum();
+ builder
+ .append_data(&mut header, format!("./{}", name), payload)
+ .expect("append");
+
+ builder
+ .into_inner()
+ .expect("finish tar")
+ .finish()
+ .expect("finish gzip")
+ }
+
+ #[test]
+ fn extracts_xfetch_from_the_archive() {
+ let dir = scratch("extract");
+ let archive = tarball("xfetch", b"binary-bytes");
+ let extracted = extract_binary(&archive, &dir).expect("extract");
+ assert_eq!(fs::read(&extracted).expect("read"), b"binary-bytes");
+ let mode = fs::metadata(&extracted)
+ .expect("metadata")
+ .permissions()
+ .mode();
+ assert_eq!(mode & 0o777, 0o755);
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn rejects_archives_without_the_binary() {
+ let dir = scratch("missing");
+ let archive = tarball("README.md", b"docs");
+ assert!(extract_binary(&archive, &dir).is_err());
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn replaces_the_binary_and_keeps_one_backup() {
+ let dir = scratch("replace");
+ let target = dir.join("xfetch");
+ fs::write(&target, b"old").expect("write old");
+ let new_binary = dir.join(".xfetch-update-test-new");
+ fs::write(&new_binary, b"new").expect("write new");
+
+ let replaced = replace_binary(&dir, &new_binary).expect("replace");
+ assert_eq!(replaced, target);
+ assert_eq!(fs::read(&target).expect("read"), b"new");
+ assert_eq!(fs::read(dir.join("xfetch.bak")).expect("backup"), b"old");
+ assert!(!new_binary.exists());
+
+ // A second update overwrites the same backup instead of accumulating.
+ let newer = dir.join(".xfetch-update-test-newer");
+ fs::write(&newer, b"newest").expect("write newest");
+ replace_binary(&dir, &newer).expect("replace again");
+ assert_eq!(fs::read(dir.join("xfetch.bak")).expect("backup"), b"new");
+ let backups: Vec<_> = fs::read_dir(&dir)
+ .expect("read dir")
+ .filter_map(Result::ok)
+ .filter(|entry| entry.file_name().to_string_lossy().contains(".bak"))
+ .collect();
+ assert_eq!(backups.len(), 1, "only one backup file must exist");
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn tarball_helper_roundtrips() {
+ let dir = scratch("roundtrip");
+ let archive = tarball("xfetch", b"payload");
+ let extracted = extract_binary(&archive, &dir).expect("extract");
+ assert_eq!(fs::read(extracted).expect("read"), b"payload");
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn encoder_writes_are_flushed() {
+ let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
+ encoder.write_all(b"x").expect("write");
+ assert!(!encoder.finish().expect("finish").is_empty());
+ }
+}
diff --git a/src/update/install/windows.rs b/src/update/install/windows.rs
new file mode 100644
index 0000000..f0b495e
--- /dev/null
+++ b/src/update/install/windows.rs
@@ -0,0 +1,183 @@
+//! Windows prebuilt archive extraction and in-place replacement.
+
+use super::super::platform::binary_name;
+use super::MAX_ASSET;
+use std::fs;
+use std::io::Cursor;
+use std::path::{Path, PathBuf};
+use zip::ZipArchive;
+
+/// Extracts `xfetch.exe` from the release zip into a temporary file.
+pub(super) fn extract_binary(bytes: &[u8], dir: &Path) -> Result {
+ let mut archive = ZipArchive::new(Cursor::new(bytes))
+ .map_err(|err| format!("Invalid release archive: {}", err))?;
+ let tmp = dir.join(format!(".xfetch-update-{}.exe", std::process::id()));
+
+ for index in 0..archive.len() {
+ let mut entry = archive
+ .by_index(index)
+ .map_err(|err| format!("Invalid archive entry: {}", err))?;
+ if entry.is_dir() {
+ continue;
+ }
+ let name = entry
+ .name()
+ .map_err(|err| format!("Invalid archive entry name: {}", err))?;
+ if Path::new(name.as_ref())
+ .file_name()
+ .and_then(|name| name.to_str())
+ != Some(binary_name())
+ {
+ continue;
+ }
+ if entry.size() > MAX_ASSET as u64 {
+ return Err("The release binary exceeds the size limit".to_string());
+ }
+
+ let mut file = fs::File::create(&tmp)
+ .map_err(|err| format!("Cannot create '{}': {}", tmp.display(), err))?;
+ std::io::copy(&mut entry, &mut file)
+ .map_err(|err| format!("Failed to extract the binary: {}", err))?;
+ let _ = file.sync_all();
+ return Ok(tmp);
+ }
+
+ let _ = fs::remove_file(&tmp);
+ Err("The release archive does not contain the xfetch binary".to_string())
+}
+
+/// Keeps one backup and replaces the running executable.
+///
+/// `self-replace` renames the running image aside and moves the new binary into
+/// place. Windows permits renaming a running executable, but not deleting it.
+pub(super) fn replace_binary(target_dir: &Path, new_binary: &Path) -> Result {
+ let target = target_dir.join(binary_name());
+ let backup = target_dir.join("xfetch.exe.bak");
+
+ if target.exists() {
+ fs::copy(&target, &backup)
+ .map_err(|err| format!("Cannot back up '{}': {}", target.display(), err))?;
+ }
+
+ if is_current_executable(&target) {
+ // The running image can be renamed but not deleted; let self-replace
+ // perform the Windows-specific swap.
+ self_replace::self_replace(new_binary).map_err(|err| {
+ format!(
+ "Cannot replace '{}': {}. Close other xfetch processes and try again",
+ target.display(),
+ err
+ )
+ })?;
+ let _ = fs::remove_file(new_binary);
+ } else {
+ // `--bin-dir` can point at an installation that is not the running
+ // process. In that case a normal rename is enough (and works when the
+ // target file is not in use).
+ fs::rename(new_binary, &target).map_err(|err| {
+ format!(
+ "Cannot replace '{}': {}. Close any xfetch process using it and try again",
+ target.display(),
+ err
+ )
+ })?;
+ }
+
+ Ok(target)
+}
+
+fn is_current_executable(path: &Path) -> bool {
+ if !path.exists() {
+ return false;
+ }
+ let Ok(current) = std::env::current_exe() else {
+ return false;
+ };
+ let current = fs::canonicalize(¤t).unwrap_or(current);
+ let path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
+ normalized(¤t) == normalized(&path)
+}
+
+fn normalized(path: &Path) -> String {
+ let mut text = path.to_string_lossy().replace('/', "\\");
+ if let Some(rest) = text.strip_prefix("\\\\?\\") {
+ text = rest.to_string();
+ }
+ text.make_ascii_lowercase();
+ text
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+
+ fn scratch(tag: &str) -> PathBuf {
+ let dir =
+ std::env::temp_dir().join(format!("xfetch-update-win-{}-{}", tag, std::process::id()));
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).expect("create scratch");
+ dir
+ }
+
+ fn zip_with(name: &str, payload: &[u8]) -> Vec {
+ let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
+ writer
+ .start_file(name, zip::write::SimpleFileOptions::default())
+ .expect("start zip entry");
+ writer.write_all(payload).expect("write zip entry");
+ writer.finish().expect("finish zip").into_inner()
+ }
+
+ #[test]
+ fn extracts_xfetch_exe_from_the_archive() {
+ let dir = scratch("extract");
+ let archive = zip_with("xfetch.exe", b"binary-bytes");
+ let extracted = extract_binary(&archive, &dir).expect("extract");
+ assert_eq!(fs::read(&extracted).expect("read"), b"binary-bytes");
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn finds_xfetch_exe_in_a_nested_directory() {
+ let dir = scratch("nested");
+ let archive = zip_with("stage/xfetch.exe", b"nested-bytes");
+ let extracted = extract_binary(&archive, &dir).expect("extract");
+ assert_eq!(fs::read(extracted).expect("read"), b"nested-bytes");
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn rejects_archives_without_the_binary() {
+ let dir = scratch("missing");
+ let archive = zip_with("README.md", b"docs");
+ assert!(extract_binary(&archive, &dir).is_err());
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn rejects_invalid_archives() {
+ let dir = scratch("invalid");
+ assert!(extract_binary(b"not a zip", &dir).is_err());
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn replaces_a_non_running_target_and_keeps_one_backup() {
+ let dir = scratch("replace");
+ let target = dir.join("xfetch.exe");
+ fs::write(&target, b"old").expect("write old");
+ let new_binary = dir.join(".xfetch-update-test-new");
+ fs::write(&new_binary, b"new").expect("write new");
+
+ let replaced = replace_binary(&dir, &new_binary).expect("replace");
+ assert_eq!(replaced, target);
+ assert_eq!(fs::read(&target).expect("read"), b"new");
+ assert_eq!(
+ fs::read(dir.join("xfetch.exe.bak")).expect("backup"),
+ b"old"
+ );
+ assert!(!new_binary.exists());
+ let _ = fs::remove_dir_all(&dir);
+ }
+}
diff --git a/src/update/mod.rs b/src/update/mod.rs
index 57a851f..898780b 100644
--- a/src/update/mod.rs
+++ b/src/update/mod.rs
@@ -4,43 +4,47 @@
//! only touches the file when doing so is safe.
//!
//! - `--check` reports whether a newer release exists without installing.
-//! - Installs done with `install-prebuilt.sh` (typically `~/.local/bin`) are
-//! updated in place: the release asset is downloaded, verified against the
+//! - Installs done with the prebuilt installer are updated in place on Unix
+//! and Windows. The release asset is downloaded, verified against the
//! published `SHA256SUMS`, extracted to a temporary file and moved over the
-//! current executable with an atomic rename. A single `xfetch.bak` keeps the
-//! previous binary for rollback (overwritten on every update).
+//! current executable. A single backup (`xfetch.bak` / `xfetch.exe.bak`)
+//! keeps the previous binary for rollback and is overwritten on every update.
//! - `cargo install` binaries are updated through `cargo install --force`.
-//! - Package-manager installs and local builds are never replaced; the
-//! command prints the right command instead.
-//!
-//! Windows prebuilt updates are intentionally not implemented yet: the `zip`
-//! asset published by the release workflow is not reliable, so the command
-//! only reports and suggests `cargo install` or a manual update there.
+//! Binaries built from a checkout (`cargo install --path`, `install.ps1`)
+//! are rebuilt from their source when the checkout still exists.
+//! - winget installs are delegated to `winget upgrade`; xfetch never replaces
+//! a winget-managed file itself.
+//! - Package-manager installs and unrecognized local builds are never
+//! replaced; the command prints the right command instead.
mod github;
-#[cfg(unix)]
mod install;
+mod platform;
mod provenance;
+mod receipt;
use crate::update::github::{Release, fetch_latest};
-use crate::update::provenance::InstallSource;
+use crate::update::provenance::{DetectedInstall, InstallSource};
use semver::Version;
use std::io::{IsTerminal, Write};
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
+use std::process::Command;
/// GitHub API endpoint used to discover the latest release. Overridable for
/// mirrors and tests with `XFETCH_UPDATE_API`.
const DEFAULT_UPDATE_API: &str = "https://api.github.com/repos/xfetch-cli/xfetch/releases/latest";
+/// Package identifier used by the winget manifest.
+pub const WINGET_PACKAGE_ID: &str = "Xfetch-cli.Xfetch";
+
/// Options collected from the CLI.
#[derive(Debug, Default)]
pub struct UpdateOptions {
/// Only report; never install.
pub check: bool,
- /// Force the in-place prebuilt update (Unix only).
+ /// Force the in-place prebuilt update (Unix and Windows).
pub prebuilt: bool,
/// Directory that holds the xfetch binary (with `--prebuilt`).
- #[cfg_attr(not(unix), allow(dead_code))]
pub bin_dir: Option,
/// Answer yes to confirmation prompts.
pub yes: bool,
@@ -84,31 +88,55 @@ pub fn run(options: UpdateOptions) -> Result {
let executable = std::env::current_exe()
.map_err(|err| format!("Cannot locate the running xfetch binary: {}", err))?;
- let source = provenance::detect(&executable);
+ let detected = provenance::detect(&executable);
if options.check {
- let command = recommended_command(source, &latest);
+ let command = recommended_command(&detected, &latest);
return Ok(UpdateOutcome::UpdateAvailable {
current,
latest: latest.version.clone(),
- method: source.label(),
+ method: detected.source.label(),
command,
});
}
if options.prebuilt {
+ if detected.source.is_package_managed() {
+ return Ok(UpdateOutcome::Manual {
+ message: format!(
+ "xfetch {} is available, but this binary is managed by {}.\n\
+ Refusing to replace it with --prebuilt. Use:\n {}",
+ latest.version,
+ detected.source.label(),
+ recommended_command(&detected, &latest)
+ ),
+ });
+ }
return install_prebuilt(&client, &latest, current, &executable, &options);
}
- match source {
+ match detected.source {
InstallSource::Prebuilt => {
install_prebuilt(&client, &latest, current, &executable, &options)
}
- InstallSource::Cargo => run_cargo_update(&latest, current, &options),
+ InstallSource::Cargo => run_cargo_update(&latest, current, &options, None),
+ InstallSource::SourceBuild => match detected.source_path.as_deref() {
+ Some(path) => run_cargo_update(&latest, current, &options, Some(path)),
+ None => Ok(UpdateOutcome::Manual {
+ message: format!(
+ "xfetch {} is available, but this is a source build and the checkout is unknown.\n\
+ Re-run the installer or rebuild with:\n cargo install --path --force --locked",
+ latest.version
+ ),
+ }),
+ },
+ InstallSource::Winget => {
+ platform::run_winget_update(&latest, current, &executable, &options)
+ }
InstallSource::PackageManager => Ok(UpdateOutcome::Manual {
message: format!(
"xfetch {} is available, but this binary is managed by a package manager.\n\
- Update it with your package manager (pacman, apt, brew, winget, ...).",
+ Update it with your package manager (pacman, apt, brew, ...).",
latest.version
),
}),
@@ -128,15 +156,31 @@ fn api_base() -> String {
}
/// Suggested command for `--check` output, tailored to the install source.
-fn recommended_command(source: InstallSource, release: &Release) -> String {
- match source {
- InstallSource::Cargo => "cargo install xfetch-cli --force".to_string(),
+fn recommended_command(detected: &DetectedInstall, release: &Release) -> String {
+ match detected.source {
+ InstallSource::Cargo => "cargo install xfetch-cli --force --locked".to_string(),
+ InstallSource::SourceBuild => detected.source_path.as_deref().map_or_else(
+ || {
+ "rebuild from the source checkout: cargo install --path --force --locked"
+ .to_string()
+ },
+ |path| {
+ format!(
+ "cargo install --path \"{}\" --force --locked",
+ path.display()
+ )
+ },
+ ),
+ InstallSource::Winget => {
+ format!("winget upgrade --id {} --exact", WINGET_PACKAGE_ID)
+ }
InstallSource::PackageManager => {
- "update through your package manager (pacman, apt, brew, winget, ...)".to_string()
+ "update through your package manager (pacman, apt, brew, ...)".to_string()
}
InstallSource::Prebuilt => format!("xfetch update (installs {})", release.version),
InstallSource::Unknown => {
- "run install-prebuilt.sh or: xfetch update --prebuilt --bin-dir ".to_string()
+ "run the prebuilt installer or: xfetch update --prebuilt --bin-dir "
+ .to_string()
}
}
}
@@ -167,25 +211,25 @@ fn confirm(version: &Version, options: &UpdateOptions) -> Result {
Ok(answer.is_empty() || answer == "y" || answer == "yes")
}
-/// Updates a prebuilt install in place (Unix) or explains why it cannot.
+/// Updates a prebuilt install in place.
fn install_prebuilt(
client: &github::Client,
release: &Release,
current: Version,
- executable: &std::path::Path,
+ executable: &Path,
options: &UpdateOptions,
) -> Result {
- #[cfg(not(unix))]
+ #[cfg(not(any(unix, windows)))]
{
let _ = (client, release, current, executable, options);
- Err(format!(
- "Prebuilt updates are not supported on this platform yet. \
+ return Err(format!(
+ "Prebuilt updates are not supported on this platform. \
Install xfetch {} with: cargo install xfetch-cli --force",
release.version
- ))
+ ));
}
- #[cfg(unix)]
+ #[cfg(any(unix, windows))]
{
if !confirm(&release.version, options)? {
return Ok(UpdateOutcome::Manual {
@@ -197,7 +241,7 @@ fn install_prebuilt(
Some(dir) => dir.clone(),
None => executable
.parent()
- .map(std::path::Path::to_path_buf)
+ .map(Path::to_path_buf)
.ok_or_else(|| "Cannot determine the binary directory".to_string())?,
};
let target = install::update_prebuilt(client, release, &target_dir)
@@ -211,18 +255,35 @@ fn install_prebuilt(
}
}
-/// Runs `cargo install xfetch-cli --force --locked` for cargo installs.
+/// Runs `cargo install` for registry installs, or rebuilds a source checkout.
fn run_cargo_update(
release: &Release,
current: Version,
options: &UpdateOptions,
+ source_path: Option<&Path>,
) -> Result {
+ let command = source_path.map_or_else(
+ || "cargo install xfetch-cli --force --locked".to_string(),
+ |path| {
+ format!(
+ "cargo install --path \"{}\" --force --locked",
+ path.display()
+ )
+ },
+ );
+
if !options.yes {
if !std::io::stdin().is_terminal() {
return Ok(UpdateOutcome::Manual {
message: format!(
- "xfetch {} is available; this is a cargo install.\n cargo install xfetch-cli --force",
- release.version
+ "xfetch {} is available; this is a {} install.\n {}",
+ release.version,
+ if source_path.is_some() {
+ "source"
+ } else {
+ "cargo"
+ },
+ command
),
});
}
@@ -233,39 +294,128 @@ fn run_cargo_update(
}
}
- let status = std::process::Command::new("cargo")
- .args(["install", "xfetch-cli", "--force", "--locked"])
- .status()
- .map_err(|err| format!("Failed to run cargo: {}", err))?;
- if !status.success() {
- return Err("cargo install failed".to_string());
+ let status = match source_path {
+ Some(path) => Command::new("cargo")
+ .arg("install")
+ .arg("--path")
+ .arg(path)
+ .args(["--force", "--locked"])
+ .status(),
+ None => Command::new("cargo")
+ .args(["install", "xfetch-cli", "--force", "--locked"])
+ .status(),
+ };
+
+ match status {
+ Ok(status) if status.success() => {
+ let path = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("xfetch"));
+ if installed_version(&path).is_some_and(|version| version >= release.version) {
+ Ok(UpdateOutcome::Updated {
+ from: current,
+ to: release.version.clone(),
+ path,
+ })
+ } else {
+ Ok(UpdateOutcome::Manual {
+ message: format!(
+ "cargo install reported success, but xfetch is still {}.\n\
+ Close any other xfetch processes and run:\n {}",
+ current, command
+ ),
+ })
+ }
+ }
+ Ok(_) => platform::cargo_failure(&command),
+ Err(err) => platform::cargo_spawn_failure(&command, &err.to_string()),
+ }
+}
+
+/// Reads the version printed by `xfetch --version`.
+fn installed_version(executable: &Path) -> Option {
+ let output = Command::new(executable).arg("--version").output().ok()?;
+ if !output.status.success() {
+ return None;
}
+ parse_version_output(&String::from_utf8_lossy(&output.stdout))
+ .or_else(|| parse_version_output(&String::from_utf8_lossy(&output.stderr)))
+}
- let path = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("xfetch"));
- Ok(UpdateOutcome::Updated {
- from: current,
- to: release.version.clone(),
- path,
- })
+fn parse_version_output(output: &str) -> Option {
+ let token = output.split_whitespace().nth(1)?;
+ Version::parse(token.trim_start_matches('v')).ok()
}
#[cfg(test)]
mod tests {
use super::*;
- #[test]
- fn recommended_command_matches_the_install_source() {
- let release = Release {
- tag: "v9.9.9".to_string(),
- version: Version::parse("9.9.9").expect("version"),
+ fn release(version: &str) -> Release {
+ Release {
+ tag: format!("v{}", version),
+ version: Version::parse(version).expect("version"),
assets: Vec::new(),
html_url: String::new(),
- };
- assert!(recommended_command(InstallSource::Cargo, &release).contains("cargo install"));
+ }
+ }
+
+ fn detected(source: InstallSource, path: Option<&str>) -> DetectedInstall {
+ DetectedInstall {
+ source,
+ source_path: path.map(PathBuf::from),
+ }
+ }
+
+ #[test]
+ fn recommended_command_matches_the_install_source() {
+ let release = release("9.9.9");
+ assert!(
+ recommended_command(&detected(InstallSource::Cargo, None), &release)
+ .contains("cargo install")
+ );
+ assert!(
+ recommended_command(
+ &detected(InstallSource::SourceBuild, Some("/src/xfetch")),
+ &release
+ )
+ .contains("--path")
+ );
+ assert!(
+ recommended_command(&detected(InstallSource::Winget, None), &release)
+ .contains("winget upgrade")
+ );
assert!(
- recommended_command(InstallSource::PackageManager, &release)
+ recommended_command(&detected(InstallSource::PackageManager, None), &release)
.contains("package manager")
);
- assert!(recommended_command(InstallSource::Prebuilt, &release).contains("xfetch update"));
+ assert!(
+ recommended_command(&detected(InstallSource::Prebuilt, None), &release)
+ .contains("xfetch update")
+ );
+ }
+
+ #[test]
+ fn parses_version_output() {
+ assert_eq!(
+ parse_version_output("xfetch 1.2.3\n"),
+ Some(Version::parse("1.2.3").expect("version"))
+ );
+ assert_eq!(
+ parse_version_output("xfetch v1.2.3\n"),
+ Some(Version::parse("1.2.3").expect("version"))
+ );
+ assert_eq!(parse_version_output("not a version"), None);
+ }
+
+ #[test]
+ fn package_managed_sources_refuse_forced_prebuilt() {
+ for source in [
+ InstallSource::Cargo,
+ InstallSource::SourceBuild,
+ InstallSource::Winget,
+ ] {
+ assert!(source.is_package_managed(), "{source:?}");
+ }
+ assert!(!InstallSource::Prebuilt.is_package_managed());
+ assert!(!InstallSource::Unknown.is_package_managed());
}
}
diff --git a/src/update/platform/mod.rs b/src/update/platform/mod.rs
new file mode 100644
index 0000000..420c2db
--- /dev/null
+++ b/src/update/platform/mod.rs
@@ -0,0 +1,20 @@
+//! Platform-specific self-update policy.
+//!
+//! Shared orchestration lives in `update::mod`; every OS-specific decision
+//! (release asset target, executable name, cargo failure policy and winget
+//! delegation) lives in its own module so Windows and Unix code never share a
+//! runtime branch.
+
+#[cfg(unix)]
+mod unix;
+#[cfg(not(any(unix, windows)))]
+mod unsupported;
+#[cfg(windows)]
+mod windows;
+
+#[cfg(unix)]
+pub(super) use unix::*;
+#[cfg(not(any(unix, windows)))]
+pub(super) use unsupported::*;
+#[cfg(windows)]
+pub(super) use windows::*;
diff --git a/src/update/platform/unix.rs b/src/update/platform/unix.rs
new file mode 100644
index 0000000..6c8471b
--- /dev/null
+++ b/src/update/platform/unix.rs
@@ -0,0 +1,63 @@
+//! Unix self-update policy.
+
+use crate::update::github::Release;
+use crate::update::{UpdateOptions, UpdateOutcome};
+use semver::Version;
+use std::path::Path;
+
+/// Release target for the running Unix platform.
+pub(crate) fn release_target() -> Option<&'static str> {
+ if cfg!(all(
+ target_os = "linux",
+ target_arch = "x86_64",
+ target_env = "gnu"
+ )) {
+ Some("x86_64-unknown-linux-gnu")
+ } else if cfg!(all(
+ target_os = "linux",
+ target_arch = "x86_64",
+ target_env = "musl"
+ )) {
+ Some("x86_64-unknown-linux-musl")
+ } else if cfg!(all(
+ target_os = "linux",
+ target_arch = "aarch64",
+ target_env = "gnu"
+ )) {
+ Some("aarch64-unknown-linux-gnu")
+ } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
+ Some("x86_64-apple-darwin")
+ } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
+ Some("aarch64-apple-darwin")
+ } else {
+ None
+ }
+}
+
+/// Executable name inside the archive and on disk.
+pub(crate) const fn binary_name() -> &'static str {
+ "xfetch"
+}
+
+/// Unix reports a failed `cargo install` as a hard error.
+pub(crate) fn cargo_failure(_command: &str) -> Result {
+ Err("cargo install failed".to_string())
+}
+
+/// Unix reports a `cargo` spawn failure as a hard error.
+pub(crate) fn cargo_spawn_failure(_command: &str, err: &str) -> Result {
+ Err(format!("Failed to run cargo: {}", err))
+}
+
+/// winget is Windows-only; this arm is unreachable on Unix.
+pub(crate) fn run_winget_update(
+ release: &Release,
+ current: Version,
+ executable: &Path,
+ options: &UpdateOptions,
+) -> Result {
+ let _ = (release, current, executable, options);
+ Ok(UpdateOutcome::Manual {
+ message: "winget is only available on Windows.".to_string(),
+ })
+}
diff --git a/src/update/platform/unsupported.rs b/src/update/platform/unsupported.rs
new file mode 100644
index 0000000..230cbad
--- /dev/null
+++ b/src/update/platform/unsupported.rs
@@ -0,0 +1,34 @@
+//! Fallback policy for operating systems without a native update path.
+
+use crate::update::github::Release;
+use crate::update::{UpdateOptions, UpdateOutcome};
+use semver::Version;
+use std::path::Path;
+
+pub(crate) fn release_target() -> Option<&'static str> {
+ None
+}
+
+pub(crate) const fn binary_name() -> &'static str {
+ "xfetch"
+}
+
+pub(crate) fn cargo_failure(command: &str) -> Result {
+ Err(format!("cargo install failed. Run:\n {}", command))
+}
+
+pub(crate) fn cargo_spawn_failure(_command: &str, err: &str) -> Result {
+ Err(format!("Failed to run cargo: {}", err))
+}
+
+pub(crate) fn run_winget_update(
+ release: &Release,
+ current: Version,
+ executable: &Path,
+ options: &UpdateOptions,
+) -> Result {
+ let _ = (release, current, executable, options);
+ Ok(UpdateOutcome::Manual {
+ message: "winget is only available on Windows.".to_string(),
+ })
+}
diff --git a/src/update/platform/windows.rs b/src/update/platform/windows.rs
new file mode 100644
index 0000000..dcc3d2c
--- /dev/null
+++ b/src/update/platform/windows.rs
@@ -0,0 +1,122 @@
+//! Windows self-update policy.
+
+use crate::update::github::Release;
+use crate::update::{UpdateOptions, UpdateOutcome};
+use semver::Version;
+use std::io::IsTerminal;
+use std::path::Path;
+use std::process::Command;
+
+/// Release target for the Windows build.
+///
+/// The release workflow publishes the MSVC archive. A GNU-built local binary
+/// can still consume it when the user explicitly asks for a prebuilt update.
+pub(crate) fn release_target() -> Option<&'static str> {
+ if cfg!(target_arch = "x86_64") {
+ Some("x86_64-pc-windows-msvc")
+ } else {
+ None
+ }
+}
+
+/// Executable name inside the archive and on disk.
+pub(crate) const fn binary_name() -> &'static str {
+ "xfetch.exe"
+}
+
+/// Windows reports a locked `cargo install` as actionable instructions instead
+/// of a hard failure: the user usually only has to close another xfetch process.
+pub(crate) fn cargo_failure(command: &str) -> Result {
+ Ok(UpdateOutcome::Manual {
+ message: format!(
+ "cargo install could not replace the running binary.\n\
+ Close any other xfetch processes and run:\n {}",
+ command
+ ),
+ })
+}
+
+/// A missing `cargo` is actionable on Windows rather than a hard failure.
+pub(crate) fn cargo_spawn_failure(command: &str, err: &str) -> Result {
+ Ok(UpdateOutcome::Manual {
+ message: format!(
+ "Failed to run cargo: {}\nClose any other xfetch processes and run:\n {}",
+ err, command
+ ),
+ })
+}
+
+/// Delegates a winget install to the package manager.
+pub(crate) fn run_winget_update(
+ release: &Release,
+ current: Version,
+ executable: &Path,
+ options: &UpdateOptions,
+) -> Result {
+ let command = format!(
+ "winget upgrade --id {} --exact --silent --accept-package-agreements --accept-source-agreements",
+ super::super::WINGET_PACKAGE_ID
+ );
+
+ if !options.yes {
+ if !std::io::stdin().is_terminal() {
+ return Ok(UpdateOutcome::Manual {
+ message: format!(
+ "xfetch {} is available; this is a winget install.\n {}",
+ release.version, command
+ ),
+ });
+ }
+ if !super::super::confirm(&release.version, options)? {
+ return Ok(UpdateOutcome::Manual {
+ message: "Update cancelled.".to_string(),
+ });
+ }
+ }
+
+ let status = Command::new("winget")
+ .args([
+ "upgrade",
+ "--id",
+ super::super::WINGET_PACKAGE_ID,
+ "--exact",
+ "--silent",
+ "--accept-package-agreements",
+ "--accept-source-agreements",
+ ])
+ .status();
+
+ match status {
+ Ok(status) if status.success() => {
+ if super::super::installed_version(executable)
+ .is_some_and(|version| version >= release.version)
+ {
+ Ok(UpdateOutcome::Updated {
+ from: current,
+ to: release.version.clone(),
+ path: executable.to_path_buf(),
+ })
+ } else {
+ Ok(UpdateOutcome::Manual {
+ message: format!(
+ "winget reported success, but xfetch is still {}.\n\
+ The winget manifest may not include {} yet. Try again later:\n {}",
+ current, release.version, command
+ ),
+ })
+ }
+ }
+ Ok(_) => Ok(UpdateOutcome::Manual {
+ message: format!(
+ "winget could not upgrade xfetch. Close other xfetch processes and run:\n {}",
+ command
+ ),
+ }),
+ Err(err) => Ok(UpdateOutcome::Manual {
+ message: format!(
+ "winget is not available ({}). Update it manually:\n {}",
+ err, command
+ ),
+ }),
+ }
+}
diff --git a/src/update/provenance.rs b/src/update/provenance.rs
deleted file mode 100644
index dec9979..0000000
--- a/src/update/provenance.rs
+++ /dev/null
@@ -1,136 +0,0 @@
-//! Detects how the running binary was installed.
-//!
-//! The updater uses this to decide whether replacing the file is safe. The
-//! check is intentionally conservative: an unrecognized path is never
-//! overwritten automatically.
-
-use std::path::{Path, PathBuf};
-
-/// Where the current executable appears to come from.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum InstallSource {
- /// Installed with `cargo install` (`~/.cargo/bin`).
- Cargo,
- /// Owned by a package manager (`/usr/bin`, Homebrew, ...).
- PackageManager,
- /// Installed by `install-prebuilt.sh` (`~/.local/bin`).
- Prebuilt,
- /// A local build or an unrecognized location.
- Unknown,
-}
-
-impl InstallSource {
- /// Short label used in reports.
- pub const fn label(self) -> &'static str {
- match self {
- Self::Cargo => "cargo install",
- Self::PackageManager => "package manager",
- Self::Prebuilt => "prebuilt install",
- Self::Unknown => "local build or unknown",
- }
- }
-}
-
-/// Classifies `executable` using the real home directory.
-pub fn detect(executable: &Path) -> InstallSource {
- detect_with_home(executable, dirs::home_dir().as_deref())
-}
-
-/// Classifies `executable` against an explicit home directory (testable).
-pub fn detect_with_home(executable: &Path, home: Option<&Path>) -> InstallSource {
- if let Some(home) = home
- && executable.starts_with(home.join(".cargo").join("bin"))
- {
- return InstallSource::Cargo;
- }
-
- if is_package_manager_path(executable) {
- return InstallSource::PackageManager;
- }
-
- if let Some(home) = home
- && executable.starts_with(home.join(".local").join("bin"))
- {
- return InstallSource::Prebuilt;
- }
-
- InstallSource::Unknown
-}
-
-/// Paths owned by common package managers.
-#[cfg(unix)]
-fn is_package_manager_path(executable: &Path) -> bool {
- ["/usr/bin", "/bin", "/opt/homebrew", "/usr/local/Cellar"]
- .iter()
- .any(|prefix| executable.starts_with(prefix))
-}
-
-/// Windows installs are never treated as package-managed here.
-#[cfg(not(unix))]
-fn is_package_manager_path(_executable: &Path) -> bool {
- false
-}
-
-/// Resolves a home-relative path for tests and reports.
-#[allow(dead_code)]
-pub fn home_path(home: &Path, rest: &str) -> PathBuf {
- home.join(rest)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn classify(path: &str, home: &str) -> InstallSource {
- detect_with_home(Path::new(path), Some(Path::new(home)))
- }
-
- #[test]
- fn detects_cargo_installs() {
- assert_eq!(
- classify("/home/u/.cargo/bin/xfetch", "/home/u"),
- InstallSource::Cargo
- );
- }
-
- #[test]
- fn detects_prebuilt_installs() {
- assert_eq!(
- classify("/home/u/.local/bin/xfetch", "/home/u"),
- InstallSource::Prebuilt
- );
- }
-
- #[test]
- fn detects_local_builds() {
- assert_eq!(
- classify("/home/u/repos/xfetch/target/release/xfetch", "/home/u"),
- InstallSource::Unknown
- );
- }
-
- #[cfg(unix)]
- #[test]
- fn detects_package_manager_paths() {
- assert_eq!(
- classify("/usr/bin/xfetch", "/home/u"),
- InstallSource::PackageManager
- );
- assert_eq!(
- classify("/bin/xfetch", "/home/u"),
- InstallSource::PackageManager
- );
- assert_eq!(
- classify("/opt/homebrew/bin/xfetch", "/home/u"),
- InstallSource::PackageManager
- );
- }
-
- #[test]
- fn missing_home_falls_back_to_unknown() {
- assert_eq!(
- detect_with_home(Path::new("/random/xfetch"), None),
- InstallSource::Unknown
- );
- }
-}
diff --git a/src/update/provenance/mod.rs b/src/update/provenance/mod.rs
new file mode 100644
index 0000000..623b905
--- /dev/null
+++ b/src/update/provenance/mod.rs
@@ -0,0 +1,322 @@
+//! Detects how the running binary was installed.
+//!
+//! The updater never guesses when it can avoid it: install receipts written by
+//! the PowerShell installers win over path heuristics, and `cargo install
+//! --list` distinguishes a crates.io install from a source checkout. An
+//! unrecognized path is never replaced automatically.
+
+#[cfg(unix)]
+mod unix;
+#[cfg(any(windows, test))]
+mod windows;
+
+use crate::update::receipt::InstallReceipt;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+/// Where the current executable appears to come from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum InstallSource {
+ /// Installed with `cargo install xfetch-cli` (`~/.cargo/bin`).
+ Cargo,
+ /// Built from a source checkout, usually by `install.ps1`.
+ SourceBuild,
+ /// Installed with winget on Windows.
+ #[cfg_attr(not(windows), allow(dead_code))]
+ Winget,
+ /// Owned by a Unix package manager (`/usr/bin`, Homebrew, ...).
+ #[cfg_attr(not(unix), allow(dead_code))]
+ PackageManager,
+ /// Installed by the prebuilt installer (`~/.local/bin`, `%LOCALAPPDATA%`).
+ Prebuilt,
+ /// A local build, a manual copy, or an unrecognized location.
+ Unknown,
+}
+
+impl InstallSource {
+ /// Short label used in reports.
+ pub const fn label(self) -> &'static str {
+ match self {
+ Self::Cargo => "cargo install",
+ Self::SourceBuild => "source build",
+ Self::Winget => "winget",
+ Self::PackageManager => "package manager",
+ Self::Prebuilt => "prebuilt install",
+ Self::Unknown => "local build or unknown",
+ }
+ }
+
+ /// Sources whose files belong to a package manager or build tool.
+ pub const fn is_package_managed(self) -> bool {
+ matches!(self, Self::Cargo | Self::SourceBuild | Self::Winget)
+ }
+}
+
+/// Result of provenance detection.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DetectedInstall {
+ /// Detected installation channel.
+ pub source: InstallSource,
+ /// Source checkout for [`InstallSource::SourceBuild`], when known.
+ pub source_path: Option,
+}
+
+impl DetectedInstall {
+ fn new(source: InstallSource) -> Self {
+ Self {
+ source,
+ source_path: None,
+ }
+ }
+}
+
+/// Paths used by the platform classifiers.
+pub(crate) struct InstallPaths {
+ pub home: Option,
+ #[cfg_attr(not(any(windows, test)), allow(dead_code))]
+ pub local_app_data: Option,
+ #[cfg_attr(not(any(windows, test)), allow(dead_code))]
+ pub program_files: Option,
+ #[cfg_attr(not(any(windows, test)), allow(dead_code))]
+ pub program_files_x86: Option,
+}
+
+impl InstallPaths {
+ fn from_env() -> Self {
+ Self {
+ home: dirs::home_dir(),
+ local_app_data: dirs::data_local_dir(),
+ program_files: std::env::var_os("ProgramFiles").map(PathBuf::from),
+ program_files_x86: std::env::var_os("ProgramFiles(x86)").map(PathBuf::from),
+ }
+ }
+}
+
+/// Classifies `executable` using the real home directory and environment.
+pub fn detect(executable: &Path) -> DetectedInstall {
+ detect_with(
+ executable,
+ &InstallPaths::from_env(),
+ InstallReceipt::load(),
+ )
+}
+
+fn detect_with(
+ executable: &Path,
+ paths: &InstallPaths,
+ receipt: Option,
+) -> DetectedInstall {
+ if let Some(receipt) = receipt
+ && receipt.matches_executable(executable)
+ {
+ let source = match receipt.method {
+ crate::update::receipt::Method::Prebuilt => InstallSource::Prebuilt,
+ crate::update::receipt::Method::Source => InstallSource::SourceBuild,
+ crate::update::receipt::Method::Cargo => InstallSource::Cargo,
+ crate::update::receipt::Method::Winget => InstallSource::Winget,
+ crate::update::receipt::Method::Manual => InstallSource::Unknown,
+ };
+ return DetectedInstall {
+ source,
+ source_path: receipt.source_path,
+ };
+ }
+
+ let source = classify_path(executable, paths);
+ if source == InstallSource::Cargo {
+ return refine_cargo_install();
+ }
+ DetectedInstall::new(source)
+}
+
+fn classify_path(executable: &Path, paths: &InstallPaths) -> InstallSource {
+ #[cfg(unix)]
+ {
+ unix::classify(executable, paths)
+ }
+ #[cfg(windows)]
+ {
+ windows::classify(executable, paths)
+ }
+ #[cfg(not(any(unix, windows)))]
+ {
+ let _ = (executable, paths);
+ InstallSource::Unknown
+ }
+}
+
+/// The two shapes reported by `cargo install --list` for `xfetch-cli`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum CargoInstallKind {
+ /// Installed from crates.io.
+ Registry,
+ /// Installed from a checkout (`cargo install --path`, `install.ps1`).
+ Source(PathBuf),
+}
+
+fn refine_cargo_install() -> DetectedInstall {
+ match cargo_install_kind() {
+ Some(CargoInstallKind::Source(path)) => DetectedInstall {
+ source: InstallSource::SourceBuild,
+ source_path: Some(path),
+ },
+ Some(CargoInstallKind::Registry) | None => DetectedInstall::new(InstallSource::Cargo),
+ }
+}
+
+fn cargo_install_kind() -> Option {
+ let output = crate::subprocess::run_cmd_with_stdin_timeout(
+ Path::new("cargo"),
+ &["install", "--list"],
+ None,
+ Some(Duration::from_secs(15)),
+ )?;
+ if !output.status.success() {
+ return None;
+ }
+ parse_cargo_install_list(&String::from_utf8_lossy(&output.stdout))
+}
+
+/// Parses the `cargo install --list` output for the `xfetch-cli` entry.
+pub(crate) fn parse_cargo_install_list(output: &str) -> Option {
+ for line in output.lines() {
+ let line = line.trim();
+ if !line.starts_with("xfetch-cli v") {
+ continue;
+ }
+ if let (Some(start), Some(end)) = (line.find('('), line.rfind(')'))
+ && end > start
+ {
+ return Some(CargoInstallKind::Source(PathBuf::from(
+ &line[start + 1..end],
+ )));
+ }
+ return Some(CargoInstallKind::Registry);
+ }
+ None
+}
+
+#[cfg(test)]
+fn detect_with_home(executable: &Path, home: Option<&Path>) -> DetectedInstall {
+ let paths = InstallPaths {
+ home: home.map(Path::to_path_buf),
+ local_app_data: None,
+ program_files: None,
+ program_files_x86: None,
+ };
+ detect_with(executable, &paths, None)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn classify(path: &str, home: &str) -> InstallSource {
+ detect_with_home(Path::new(path), Some(Path::new(home))).source
+ }
+
+ #[test]
+ fn detects_cargo_installs() {
+ assert_eq!(
+ classify("/home/u/.cargo/bin/xfetch", "/home/u"),
+ InstallSource::Cargo
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn detects_prebuilt_installs() {
+ assert_eq!(
+ classify("/home/u/.local/bin/xfetch", "/home/u"),
+ InstallSource::Prebuilt
+ );
+ }
+
+ #[test]
+ fn detects_local_builds() {
+ assert_eq!(
+ classify("/home/u/repos/xfetch/target/release/xfetch", "/home/u"),
+ InstallSource::Unknown
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn detects_package_manager_paths() {
+ assert_eq!(
+ classify("/usr/bin/xfetch", "/home/u"),
+ InstallSource::PackageManager
+ );
+ assert_eq!(
+ classify("/bin/xfetch", "/home/u"),
+ InstallSource::PackageManager
+ );
+ assert_eq!(
+ classify("/opt/homebrew/bin/xfetch", "/home/u"),
+ InstallSource::PackageManager
+ );
+ }
+
+ #[test]
+ fn missing_home_falls_back_to_unknown() {
+ assert_eq!(
+ detect_with_home(Path::new("/random/xfetch"), None).source,
+ InstallSource::Unknown
+ );
+ }
+
+ #[test]
+ fn parses_registry_and_path_cargo_installs() {
+ let registry = "xfetch-cli v1.0.3:\n xfetch\n";
+ assert_eq!(
+ parse_cargo_install_list(registry),
+ Some(CargoInstallKind::Registry)
+ );
+
+ let source = "xfetch-cli v1.0.3 (C:\\Users\\u\\repos\\xfetch):\n xfetch.exe\n";
+ assert_eq!(
+ parse_cargo_install_list(source),
+ Some(CargoInstallKind::Source(PathBuf::from(
+ "C:\\Users\\u\\repos\\xfetch"
+ )))
+ );
+
+ assert_eq!(parse_cargo_install_list("other v1.0.0:\n"), None);
+ }
+
+ #[test]
+ fn package_managed_sources_are_flagged() {
+ assert!(InstallSource::Cargo.is_package_managed());
+ assert!(InstallSource::SourceBuild.is_package_managed());
+ assert!(InstallSource::Winget.is_package_managed());
+ assert!(!InstallSource::Prebuilt.is_package_managed());
+ assert!(!InstallSource::Unknown.is_package_managed());
+ }
+
+ #[test]
+ fn receipt_wins_over_path_heuristics() {
+ let receipt = InstallReceipt {
+ schema_version: 1,
+ method: crate::update::receipt::Method::Prebuilt,
+ path: PathBuf::from("/home/u/.cargo/bin/xfetch"),
+ source_path: None,
+ version: None,
+ source: None,
+ repo: None,
+ commit: None,
+ installed_at: None,
+ };
+ let paths = InstallPaths {
+ home: Some(PathBuf::from("/home/u")),
+ local_app_data: None,
+ program_files: None,
+ program_files_x86: None,
+ };
+ let detected = detect_with(
+ Path::new("/home/u/.cargo/bin/xfetch"),
+ &paths,
+ Some(receipt),
+ );
+ assert_eq!(detected.source, InstallSource::Prebuilt);
+ }
+}
diff --git a/src/update/provenance/unix.rs b/src/update/provenance/unix.rs
new file mode 100644
index 0000000..aad2d5e
--- /dev/null
+++ b/src/update/provenance/unix.rs
@@ -0,0 +1,32 @@
+//! Unix path-based install provenance.
+
+use super::{InstallPaths, InstallSource};
+use std::path::Path;
+
+/// Classifies a Unix executable path.
+pub(crate) fn classify(executable: &Path, paths: &InstallPaths) -> InstallSource {
+ if let Some(home) = &paths.home
+ && executable.starts_with(home.join(".cargo").join("bin"))
+ {
+ return InstallSource::Cargo;
+ }
+
+ if is_package_manager_path(executable) {
+ return InstallSource::PackageManager;
+ }
+
+ if let Some(home) = &paths.home
+ && executable.starts_with(home.join(".local").join("bin"))
+ {
+ return InstallSource::Prebuilt;
+ }
+
+ InstallSource::Unknown
+}
+
+/// Paths owned by common package managers.
+fn is_package_manager_path(executable: &Path) -> bool {
+ ["/usr/bin", "/bin", "/opt/homebrew", "/usr/local/Cellar"]
+ .iter()
+ .any(|prefix| executable.starts_with(prefix))
+}
diff --git a/src/update/provenance/windows.rs b/src/update/provenance/windows.rs
new file mode 100644
index 0000000..1d90478
--- /dev/null
+++ b/src/update/provenance/windows.rs
@@ -0,0 +1,164 @@
+//! Windows path-based install provenance.
+//!
+//! The classifier is deliberately conservative. Windows has no single
+//! package-ownership database for portable binaries, so unknown locations are
+//! never treated as writable prebuilt installs. The PowerShell installers
+//! write a receipt that resolves the default prebuilt directory without
+//! guessing.
+
+use super::{InstallPaths, InstallSource};
+use std::path::{Path, PathBuf};
+
+/// Classifies a Windows executable path.
+pub(crate) fn classify(executable: &Path, paths: &InstallPaths) -> InstallSource {
+ if is_winget_path(executable, paths) {
+ return InstallSource::Winget;
+ }
+
+ if is_under(executable, paths.home.as_deref(), ".cargo/bin") {
+ return InstallSource::Cargo;
+ }
+
+ // `install-prebuilt.ps1` uses this directory, but so can a manually copied
+ // build. Without an install receipt this is intentionally unknown: the
+ // user can force an in-place update with `--prebuilt`.
+ if is_under(
+ executable,
+ paths.local_app_data.as_deref(),
+ "Programs/xfetch/bin",
+ ) || is_under(executable, paths.home.as_deref(), ".local/bin")
+ {
+ return InstallSource::Unknown;
+ }
+
+ InstallSource::Unknown
+}
+
+fn is_winget_path(executable: &Path, paths: &InstallPaths) -> bool {
+ let candidates = [
+ (paths.local_app_data.as_deref(), "Microsoft/WinGet/Links"),
+ (paths.local_app_data.as_deref(), "Microsoft/WinGet/Packages"),
+ (paths.program_files.as_deref(), "WinGet/Links"),
+ (paths.program_files.as_deref(), "WinGet/Packages"),
+ (paths.program_files_x86.as_deref(), "WinGet/Packages"),
+ ];
+ candidates
+ .iter()
+ .any(|(base, rest)| is_under(executable, *base, rest))
+}
+
+fn is_under(executable: &Path, base: Option<&Path>, rest: &str) -> bool {
+ let Some(base) = base else {
+ return false;
+ };
+ let mut prefix: PathBuf = base.to_path_buf();
+ for component in rest.split('/') {
+ prefix.push(component);
+ }
+ starts_with_ignore_case(executable, &prefix)
+}
+
+fn starts_with_ignore_case(path: &Path, prefix: &Path) -> bool {
+ let path = normalized(path);
+ let prefix = normalized(prefix);
+ path == prefix || path.starts_with(&format!("{prefix}\\"))
+}
+
+fn normalized(path: &Path) -> String {
+ let mut text = path.to_string_lossy().replace('/', "\\");
+ if let Some(rest) = text.strip_prefix("\\\\?\\UNC\\") {
+ text = format!("\\\\{}", rest);
+ } else if let Some(rest) = text.strip_prefix("\\\\?\\") {
+ text = rest.to_string();
+ }
+ text.make_ascii_lowercase();
+ text.trim_end_matches('\\').to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn paths() -> InstallPaths {
+ InstallPaths {
+ home: Some(PathBuf::from("C:\\Users\\u")),
+ local_app_data: Some(PathBuf::from("C:\\Users\\u\\AppData\\Local")),
+ program_files: Some(PathBuf::from("C:\\Program Files")),
+ program_files_x86: Some(PathBuf::from("C:\\Program Files (x86)")),
+ }
+ }
+
+ fn classify_path(path: &str) -> InstallSource {
+ classify(Path::new(path), &paths())
+ }
+
+ #[test]
+ fn detects_winget_links_and_packages() {
+ assert_eq!(
+ classify_path("C:\\Users\\u\\AppData\\Local\\Microsoft\\WinGet\\Links\\xfetch.exe"),
+ InstallSource::Winget
+ );
+ assert_eq!(
+ classify_path(
+ "C:\\Users\\u\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Xfetch-cli.Xfetch_Microsoft.Winget.Source_8wekyb3d8bbwe\\xfetch.exe"
+ ),
+ InstallSource::Winget
+ );
+ assert_eq!(
+ classify_path("C:\\Program Files\\WinGet\\Links\\xfetch.exe"),
+ InstallSource::Winget
+ );
+ assert_eq!(
+ classify_path("C:\\Program Files\\WinGet\\Packages\\Xfetch-cli.Xfetch_foo\\xfetch.exe"),
+ InstallSource::Winget
+ );
+ }
+
+ #[test]
+ fn detects_cargo_installs() {
+ assert_eq!(
+ classify_path("C:\\Users\\u\\.cargo\\bin\\xfetch.exe"),
+ InstallSource::Cargo
+ );
+ }
+
+ #[test]
+ fn does_not_guess_for_prebuilt_directories_without_a_receipt() {
+ assert_eq!(
+ classify_path("C:\\Users\\u\\AppData\\Local\\Programs\\xfetch\\bin\\xfetch.exe"),
+ InstallSource::Unknown
+ );
+ assert_eq!(
+ classify_path("C:\\Users\\u\\.local\\bin\\xfetch.exe"),
+ InstallSource::Unknown
+ );
+ }
+
+ #[test]
+ fn leaves_local_and_manual_builds_unknown() {
+ assert_eq!(
+ classify_path("C:\\Users\\u\\Documents\\repos\\xfetch\\target\\release\\xfetch.exe"),
+ InstallSource::Unknown
+ );
+ assert_eq!(
+ classify_path("C:\\Users\\u\\Downloads\\xfetch.exe"),
+ InstallSource::Unknown
+ );
+ }
+
+ #[test]
+ fn path_matching_is_case_insensitive_and_handles_verbatim_paths() {
+ assert_eq!(
+ classify_path("c:\\users\\U\\appdata\\local\\microsoft\\winget\\links\\XFETCH.EXE"),
+ InstallSource::Winget
+ );
+ assert_eq!(
+ classify_path("\\\\?\\C:\\Users\\u\\.cargo\\bin\\xfetch.exe"),
+ InstallSource::Cargo
+ );
+ assert_eq!(
+ classify_path("C:/Users/u/AppData/Local/Microsoft/WinGet/Links/xfetch.exe"),
+ InstallSource::Winget
+ );
+ }
+}
diff --git a/src/update/receipt.rs b/src/update/receipt.rs
new file mode 100644
index 0000000..5b9662e
--- /dev/null
+++ b/src/update/receipt.rs
@@ -0,0 +1,194 @@
+//! Install receipts written by the platform installers.
+//!
+//! A path alone cannot always tell how a binary was installed: the default
+//! prebuilt directory on Windows can also receive a manually copied build, and
+//! a `cargo install --path` binary lives next to a crates.io install. The
+//! installers therefore leave a small JSON receipt that the updater reads
+//! first. Receipts are advisory: a missing or malformed file falls back to the
+//! path-based detection in `provenance`.
+
+use serde::Deserialize;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+/// Receipt schema accepted by this version of xfetch.
+pub const SCHEMA_VERSION: u32 = 1;
+
+/// How the binary was installed.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Method {
+ /// Installed by `install-prebuilt.ps1` or `install-prebuilt.sh`.
+ Prebuilt,
+ /// Built from a source checkout, usually by `install.ps1`.
+ Source,
+ /// Installed with `cargo install xfetch-cli`.
+ Cargo,
+ /// Installed with winget.
+ Winget,
+ /// Copied manually by the user.
+ Manual,
+}
+
+/// Contents of `install.json`.
+#[allow(dead_code)]
+#[derive(Debug, Clone, Deserialize)]
+pub struct InstallReceipt {
+ /// Must match [`SCHEMA_VERSION`].
+ pub schema_version: u32,
+ /// Installation method recorded by the installer.
+ pub method: Method,
+ /// Path of the installed executable.
+ pub path: PathBuf,
+ /// Source checkout when `method` is `source`.
+ #[serde(default)]
+ pub source_path: Option,
+ /// Installed version, when the installer knows it.
+ #[serde(default)]
+ pub version: Option,
+ /// Human-readable installer name, for diagnostics.
+ #[serde(default)]
+ pub source: Option,
+ /// Repository URL for source installs.
+ #[serde(default)]
+ pub repo: Option,
+ /// Git commit for source installs.
+ #[serde(default)]
+ pub commit: Option,
+ /// RFC 3339 timestamp written by the installer.
+ #[serde(default)]
+ pub installed_at: Option,
+}
+
+impl InstallReceipt {
+ /// Reads the receipt next to the user's data directory, if present.
+ pub fn load() -> Option {
+ Self::load_from(&receipt_path()?)
+ }
+
+ /// Reads and validates a receipt from an explicit path.
+ pub fn load_from(path: &Path) -> Option {
+ let text = fs::read_to_string(path).ok()?;
+ let receipt: Self = serde_json::from_str(&text).ok()?;
+ (receipt.schema_version == SCHEMA_VERSION).then_some(receipt)
+ }
+
+ /// Whether this receipt describes `executable`.
+ ///
+ /// Paths are canonicalized when possible; Windows comparisons are
+ /// case-insensitive and tolerate the `\\?\` verbatim prefix.
+ pub fn matches_executable(&self, executable: &Path) -> bool {
+ same_path(&self.path, executable)
+ }
+}
+
+/// Location of the receipt for the current user.
+pub fn receipt_path() -> Option {
+ #[cfg(windows)]
+ {
+ dirs::data_local_dir().map(|dir| dir.join("xfetch").join("install.json"))
+ }
+ #[cfg(not(windows))]
+ {
+ dirs::config_dir().map(|dir| dir.join("xfetch").join("install.json"))
+ }
+}
+
+fn same_path(left: &Path, right: &Path) -> bool {
+ let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
+ let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
+ path_key(&left) == path_key(&right)
+}
+
+#[cfg(windows)]
+fn path_key(path: &Path) -> String {
+ let mut text = path.to_string_lossy().replace('/', "\\");
+ if let Some(rest) = text.strip_prefix("\\\\?\\UNC\\") {
+ text = format!("\\\\{}", rest);
+ } else if let Some(rest) = text.strip_prefix("\\\\?\\") {
+ text = rest.to_string();
+ }
+ text.make_ascii_lowercase();
+ text.trim_end_matches('\\').to_string()
+}
+
+#[cfg(not(windows))]
+fn path_key(path: &Path) -> String {
+ path.to_string_lossy().trim_end_matches('/').to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn scratch(tag: &str) -> PathBuf {
+ let dir =
+ std::env::temp_dir().join(format!("xfetch-receipt-{}-{}", tag, std::process::id()));
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).expect("create scratch");
+ dir
+ }
+
+ #[test]
+ fn parses_a_prebuilt_receipt() {
+ let text = r#"{
+ "schema_version": 1,
+ "method": "prebuilt",
+ "path": "C:\\Users\\u\\AppData\\Local\\Programs\\xfetch\\bin\\xfetch.exe",
+ "version": "1.0.3",
+ "source": "install-prebuilt.ps1",
+ "installed_at": "2026-11-05T10:00:00Z"
+ }"#;
+ let receipt: InstallReceipt = serde_json::from_str(text).expect("parse");
+ assert_eq!(receipt.schema_version, 1);
+ assert_eq!(receipt.method, Method::Prebuilt);
+ assert_eq!(receipt.version.as_deref(), Some("1.0.3"));
+ assert!(receipt.source_path.is_none());
+ }
+
+ #[test]
+ fn parses_a_source_receipt() {
+ let text = r#"{
+ "schema_version": 1,
+ "method": "source",
+ "path": "/home/u/.cargo/bin/xfetch",
+ "source_path": "/home/u/repos/xfetch",
+ "repo": "https://github.com/xfetch-cli/xfetch.git",
+ "commit": "abc123"
+ }"#;
+ let receipt: InstallReceipt = serde_json::from_str(text).expect("parse");
+ assert_eq!(receipt.method, Method::Source);
+ assert_eq!(
+ receipt.source_path.as_deref(),
+ Some(Path::new("/home/u/repos/xfetch"))
+ );
+ }
+
+ #[test]
+ fn rejects_unsupported_schema_versions() {
+ let dir = scratch("schema");
+ let path = dir.join("install.json");
+ fs::write(
+ &path,
+ r#"{"schema_version": 2, "method": "prebuilt", "path": "xfetch"}"#,
+ )
+ .expect("write");
+ assert!(InstallReceipt::load_from(&path).is_none());
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn matches_the_recorded_executable() {
+ let dir = scratch("match");
+ let binary = dir.join("xfetch");
+ fs::write(&binary, b"binary").expect("write");
+ let text = format!(
+ r#"{{"schema_version":1,"method":"prebuilt","path":"{}"}}"#,
+ binary.to_string_lossy().replace('\\', "\\\\")
+ );
+ let receipt: InstallReceipt = serde_json::from_str(&text).expect("parse");
+ assert!(receipt.matches_executable(&binary));
+ assert!(!receipt.matches_executable(&dir.join("other")));
+ let _ = fs::remove_dir_all(&dir);
+ }
+}
diff --git a/uninstall.ps1 b/uninstall.ps1
index 0f56bfd..b596cba 100644
--- a/uninstall.ps1
+++ b/uninstall.ps1
@@ -48,6 +48,19 @@ if (Test-Path $PrebuiltBinary) {
}
}
+# Remove the install receipt written by the installers.
+if ($env:LOCALAPPDATA) {
+ $Receipt = Join-Path $env:LOCALAPPDATA "xfetch\install.json"
+ if (Test-Path $Receipt) {
+ Remove-Item $Receipt -Force
+ Write-Host "Removed install receipt: $Receipt" -ForegroundColor Green
+ $ReceiptDir = Split-Path $Receipt -Parent
+ if ((Test-Path $ReceiptDir) -and -not (Get-ChildItem $ReceiptDir -Recurse -File)) {
+ Remove-Item $ReceiptDir -Recurse -Force
+ }
+ }
+}
+
# Remove config (only with -Purge)
$ConfigDir = Join-Path $env:APPDATA "xfetch"
if ($Purge) {
From af4a54cd4f4e27e0889af13f9b01d3d287e82f8b Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Thu, 5 Nov 2026 14:01:30 +0100
Subject: [PATCH 04/19] refactor(update): tighten platform separation and
remove dead code
Gate WinGet and Unix package-manager variants by platform, keep install-path fields Windows-only, drop receipt fields the updater never reads and remove unused release metadata. No behavior change; all Linux and Windows checks pass.
---
src/update/github.rs | 10 -------
src/update/mod.rs | 18 ++++++++-----
src/update/platform/unix.rs | 18 +------------
src/update/platform/unsupported.rs | 17 +-----------
src/update/provenance/mod.rs | 43 +++++++++++++++++++++---------
src/update/receipt.rs | 21 +++------------
6 files changed, 49 insertions(+), 78 deletions(-)
diff --git a/src/update/github.rs b/src/update/github.rs
index 1bcdfe1..0bb04b4 100644
--- a/src/update/github.rs
+++ b/src/update/github.rs
@@ -14,8 +14,6 @@ use std::time::Duration;
pub struct Asset {
pub name: String,
pub url: String,
- #[allow(dead_code)]
- pub size: u64,
}
/// A release with its parsed version.
@@ -24,16 +22,12 @@ pub struct Release {
pub tag: String,
pub version: Version,
pub assets: Vec,
- #[allow(dead_code)]
- pub html_url: String,
}
#[derive(Debug, Deserialize)]
struct WireRelease {
tag_name: String,
#[serde(default)]
- html_url: String,
- #[serde(default)]
assets: Vec,
}
@@ -41,8 +35,6 @@ struct WireRelease {
struct WireAsset {
name: String,
browser_download_url: String,
- #[serde(default)]
- size: u64,
}
struct HttpResponse {
@@ -182,7 +174,6 @@ pub fn parse_release(body: &str) -> Result {
.map(|asset| Asset {
name: asset.name,
url: asset.browser_download_url,
- size: asset.size,
})
.collect();
@@ -190,7 +181,6 @@ pub fn parse_release(body: &str) -> Result {
tag: wire.tag_name,
version,
assets,
- html_url: wire.html_url,
})
}
diff --git a/src/update/mod.rs b/src/update/mod.rs
index 898780b..02809b4 100644
--- a/src/update/mod.rs
+++ b/src/update/mod.rs
@@ -35,6 +35,7 @@ use std::process::Command;
const DEFAULT_UPDATE_API: &str = "https://api.github.com/repos/xfetch-cli/xfetch/releases/latest";
/// Package identifier used by the winget manifest.
+#[cfg(windows)]
pub const WINGET_PACKAGE_ID: &str = "Xfetch-cli.Xfetch";
/// Options collected from the CLI.
@@ -130,9 +131,11 @@ pub fn run(options: UpdateOptions) -> Result {
),
}),
},
+ #[cfg(windows)]
InstallSource::Winget => {
platform::run_winget_update(&latest, current, &executable, &options)
}
+ #[cfg(unix)]
InstallSource::PackageManager => Ok(UpdateOutcome::Manual {
message: format!(
"xfetch {} is available, but this binary is managed by a package manager.\n\
@@ -171,9 +174,11 @@ fn recommended_command(detected: &DetectedInstall, release: &Release) -> String
)
},
),
+ #[cfg(windows)]
InstallSource::Winget => {
format!("winget upgrade --id {} --exact", WINGET_PACKAGE_ID)
}
+ #[cfg(unix)]
InstallSource::PackageManager => {
"update through your package manager (pacman, apt, brew, ...)".to_string()
}
@@ -354,7 +359,6 @@ mod tests {
tag: format!("v{}", version),
version: Version::parse(version).expect("version"),
assets: Vec::new(),
- html_url: String::new(),
}
}
@@ -379,10 +383,12 @@ mod tests {
)
.contains("--path")
);
+ #[cfg(windows)]
assert!(
recommended_command(&detected(InstallSource::Winget, None), &release)
.contains("winget upgrade")
);
+ #[cfg(unix)]
assert!(
recommended_command(&detected(InstallSource::PackageManager, None), &release)
.contains("package manager")
@@ -408,13 +414,13 @@ mod tests {
#[test]
fn package_managed_sources_refuse_forced_prebuilt() {
- for source in [
- InstallSource::Cargo,
- InstallSource::SourceBuild,
- InstallSource::Winget,
- ] {
+ for source in [InstallSource::Cargo, InstallSource::SourceBuild] {
assert!(source.is_package_managed(), "{source:?}");
}
+ #[cfg(windows)]
+ assert!(InstallSource::Winget.is_package_managed());
+ #[cfg(unix)]
+ assert!(!InstallSource::PackageManager.is_package_managed());
assert!(!InstallSource::Prebuilt.is_package_managed());
assert!(!InstallSource::Unknown.is_package_managed());
}
diff --git a/src/update/platform/unix.rs b/src/update/platform/unix.rs
index 6c8471b..6c0c8c1 100644
--- a/src/update/platform/unix.rs
+++ b/src/update/platform/unix.rs
@@ -1,9 +1,6 @@
//! Unix self-update policy.
-use crate::update::github::Release;
-use crate::update::{UpdateOptions, UpdateOutcome};
-use semver::Version;
-use std::path::Path;
+use crate::update::UpdateOutcome;
/// Release target for the running Unix platform.
pub(crate) fn release_target() -> Option<&'static str> {
@@ -48,16 +45,3 @@ pub(crate) fn cargo_failure(_command: &str) -> Result {
pub(crate) fn cargo_spawn_failure(_command: &str, err: &str) -> Result {
Err(format!("Failed to run cargo: {}", err))
}
-
-/// winget is Windows-only; this arm is unreachable on Unix.
-pub(crate) fn run_winget_update(
- release: &Release,
- current: Version,
- executable: &Path,
- options: &UpdateOptions,
-) -> Result {
- let _ = (release, current, executable, options);
- Ok(UpdateOutcome::Manual {
- message: "winget is only available on Windows.".to_string(),
- })
-}
diff --git a/src/update/platform/unsupported.rs b/src/update/platform/unsupported.rs
index 230cbad..169470a 100644
--- a/src/update/platform/unsupported.rs
+++ b/src/update/platform/unsupported.rs
@@ -1,9 +1,6 @@
//! Fallback policy for operating systems without a native update path.
-use crate::update::github::Release;
-use crate::update::{UpdateOptions, UpdateOutcome};
-use semver::Version;
-use std::path::Path;
+use crate::update::UpdateOutcome;
pub(crate) fn release_target() -> Option<&'static str> {
None
@@ -20,15 +17,3 @@ pub(crate) fn cargo_failure(command: &str) -> Result {
pub(crate) fn cargo_spawn_failure(_command: &str, err: &str) -> Result {
Err(format!("Failed to run cargo: {}", err))
}
-
-pub(crate) fn run_winget_update(
- release: &Release,
- current: Version,
- executable: &Path,
- options: &UpdateOptions,
-) -> Result {
- let _ = (release, current, executable, options);
- Ok(UpdateOutcome::Manual {
- message: "winget is only available on Windows.".to_string(),
- })
-}
diff --git a/src/update/provenance/mod.rs b/src/update/provenance/mod.rs
index 623b905..372d40f 100644
--- a/src/update/provenance/mod.rs
+++ b/src/update/provenance/mod.rs
@@ -7,7 +7,7 @@
#[cfg(unix)]
mod unix;
-#[cfg(any(windows, test))]
+#[cfg(windows)]
mod windows;
use crate::update::receipt::InstallReceipt;
@@ -22,10 +22,10 @@ pub enum InstallSource {
/// Built from a source checkout, usually by `install.ps1`.
SourceBuild,
/// Installed with winget on Windows.
- #[cfg_attr(not(windows), allow(dead_code))]
+ #[cfg(windows)]
Winget,
/// Owned by a Unix package manager (`/usr/bin`, Homebrew, ...).
- #[cfg_attr(not(unix), allow(dead_code))]
+ #[cfg(unix)]
PackageManager,
/// Installed by the prebuilt installer (`~/.local/bin`, `%LOCALAPPDATA%`).
Prebuilt,
@@ -39,7 +39,9 @@ impl InstallSource {
match self {
Self::Cargo => "cargo install",
Self::SourceBuild => "source build",
+ #[cfg(windows)]
Self::Winget => "winget",
+ #[cfg(unix)]
Self::PackageManager => "package manager",
Self::Prebuilt => "prebuilt install",
Self::Unknown => "local build or unknown",
@@ -48,7 +50,14 @@ impl InstallSource {
/// Sources whose files belong to a package manager or build tool.
pub const fn is_package_managed(self) -> bool {
- matches!(self, Self::Cargo | Self::SourceBuild | Self::Winget)
+ match self {
+ Self::Cargo | Self::SourceBuild => true,
+ #[cfg(windows)]
+ Self::Winget => true,
+ #[cfg(unix)]
+ Self::PackageManager => false,
+ Self::Prebuilt | Self::Unknown => false,
+ }
}
}
@@ -73,11 +82,11 @@ impl DetectedInstall {
/// Paths used by the platform classifiers.
pub(crate) struct InstallPaths {
pub home: Option,
- #[cfg_attr(not(any(windows, test)), allow(dead_code))]
+ #[cfg(windows)]
pub local_app_data: Option,
- #[cfg_attr(not(any(windows, test)), allow(dead_code))]
+ #[cfg(windows)]
pub program_files: Option,
- #[cfg_attr(not(any(windows, test)), allow(dead_code))]
+ #[cfg(windows)]
pub program_files_x86: Option,
}
@@ -85,8 +94,11 @@ impl InstallPaths {
fn from_env() -> Self {
Self {
home: dirs::home_dir(),
+ #[cfg(windows)]
local_app_data: dirs::data_local_dir(),
+ #[cfg(windows)]
program_files: std::env::var_os("ProgramFiles").map(PathBuf::from),
+ #[cfg(windows)]
program_files_x86: std::env::var_os("ProgramFiles(x86)").map(PathBuf::from),
}
}
@@ -113,7 +125,10 @@ fn detect_with(
crate::update::receipt::Method::Prebuilt => InstallSource::Prebuilt,
crate::update::receipt::Method::Source => InstallSource::SourceBuild,
crate::update::receipt::Method::Cargo => InstallSource::Cargo,
+ #[cfg(windows)]
crate::update::receipt::Method::Winget => InstallSource::Winget,
+ #[cfg(not(windows))]
+ crate::update::receipt::Method::Winget => InstallSource::Unknown,
crate::update::receipt::Method::Manual => InstallSource::Unknown,
};
return DetectedInstall {
@@ -200,8 +215,11 @@ pub(crate) fn parse_cargo_install_list(output: &str) -> Option
fn detect_with_home(executable: &Path, home: Option<&Path>) -> DetectedInstall {
let paths = InstallPaths {
home: home.map(Path::to_path_buf),
+ #[cfg(windows)]
local_app_data: None,
+ #[cfg(windows)]
program_files: None,
+ #[cfg(windows)]
program_files_x86: None,
};
detect_with(executable, &paths, None)
@@ -288,7 +306,10 @@ mod tests {
fn package_managed_sources_are_flagged() {
assert!(InstallSource::Cargo.is_package_managed());
assert!(InstallSource::SourceBuild.is_package_managed());
+ #[cfg(windows)]
assert!(InstallSource::Winget.is_package_managed());
+ #[cfg(unix)]
+ assert!(!InstallSource::PackageManager.is_package_managed());
assert!(!InstallSource::Prebuilt.is_package_managed());
assert!(!InstallSource::Unknown.is_package_managed());
}
@@ -300,16 +321,14 @@ mod tests {
method: crate::update::receipt::Method::Prebuilt,
path: PathBuf::from("/home/u/.cargo/bin/xfetch"),
source_path: None,
- version: None,
- source: None,
- repo: None,
- commit: None,
- installed_at: None,
};
let paths = InstallPaths {
home: Some(PathBuf::from("/home/u")),
+ #[cfg(windows)]
local_app_data: None,
+ #[cfg(windows)]
program_files: None,
+ #[cfg(windows)]
program_files_x86: None,
};
let detected = detect_with(
diff --git a/src/update/receipt.rs b/src/update/receipt.rs
index 5b9662e..a1e2b03 100644
--- a/src/update/receipt.rs
+++ b/src/update/receipt.rs
@@ -31,7 +31,10 @@ pub enum Method {
}
/// Contents of `install.json`.
-#[allow(dead_code)]
+///
+/// Installers may write extra diagnostic fields (version, source, repo,
+/// commit, installed_at); unknown fields are intentionally ignored so the
+/// receipt schema can grow without breaking older binaries.
#[derive(Debug, Clone, Deserialize)]
pub struct InstallReceipt {
/// Must match [`SCHEMA_VERSION`].
@@ -43,21 +46,6 @@ pub struct InstallReceipt {
/// Source checkout when `method` is `source`.
#[serde(default)]
pub source_path: Option,
- /// Installed version, when the installer knows it.
- #[serde(default)]
- pub version: Option,
- /// Human-readable installer name, for diagnostics.
- #[serde(default)]
- pub source: Option,
- /// Repository URL for source installs.
- #[serde(default)]
- pub repo: Option,
- /// Git commit for source installs.
- #[serde(default)]
- pub commit: Option,
- /// RFC 3339 timestamp written by the installer.
- #[serde(default)]
- pub installed_at: Option,
}
impl InstallReceipt {
@@ -142,7 +130,6 @@ mod tests {
let receipt: InstallReceipt = serde_json::from_str(text).expect("parse");
assert_eq!(receipt.schema_version, 1);
assert_eq!(receipt.method, Method::Prebuilt);
- assert_eq!(receipt.version.as_deref(), Some("1.0.3"));
assert!(receipt.source_path.is_none());
}
From e15e20847473d9527d2e78878a8338a1e369b5dd Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Thu, 5 Nov 2026 14:15:08 +0100
Subject: [PATCH 05/19] docs(roadmap): record winget and Windows updater
support
Mark winget publication, Windows self-update and Windows CI updater tests as complete.
---
ROADMAP.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/ROADMAP.md b/ROADMAP.md
index 3c39c0f..420d354 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -100,6 +100,8 @@
- [x] Create binary releases for Linux x86_64 (#103)
- [x] Create binary releases for macOS (Intel & ARM) (#104)
- [x] Create binary releases for Windows (#105)
+- [x] Publish Windows packages to winget (#441119)
+- [x] Add Windows self-update for winget, cargo and prebuilt installs
- [x] Setup AUR package for Arch Linux (#106)
- [x] Setup Homebrew tap for macOS (#107)
- [x] Setup PyPI or cargo registry for distribution (#108)
@@ -125,6 +127,7 @@
- [x] Setup code formatter (rustfmt) (#122)
- [x] Implement platform-specific tests for each OS (#123)
- [x] Add cross-platform testing suite (#124)
+- [x] Run updater tests on Windows CI
- [x] Setup code coverage reporting (#125)
## Phase 12 · Advanced Features
From 744afb973a6905a92415558712d25471bff20106 Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Thu, 5 Nov 2026 14:15:22 +0100
Subject: [PATCH 06/19] docs(roadmap): link Windows updater work to PR #201
---
ROADMAP.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 420d354..f4d1095 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -101,7 +101,7 @@
- [x] Create binary releases for macOS (Intel & ARM) (#104)
- [x] Create binary releases for Windows (#105)
- [x] Publish Windows packages to winget (#441119)
-- [x] Add Windows self-update for winget, cargo and prebuilt installs
+- [x] Add Windows self-update for winget, cargo and prebuilt installs (#201)
- [x] Setup AUR package for Arch Linux (#106)
- [x] Setup Homebrew tap for macOS (#107)
- [x] Setup PyPI or cargo registry for distribution (#108)
@@ -127,7 +127,7 @@
- [x] Setup code formatter (rustfmt) (#122)
- [x] Implement platform-specific tests for each OS (#123)
- [x] Add cross-platform testing suite (#124)
-- [x] Run updater tests on Windows CI
+- [x] Run updater tests on Windows CI (#201)
- [x] Setup code coverage reporting (#125)
## Phase 12 · Advanced Features
From 88d5347c6fc7abd1382b77d2c4590352ef95c5f8 Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Thu, 5 Nov 2026 14:16:32 +0100
Subject: [PATCH 07/19] ci: quote the Windows updater test filter
The unquoted update:: filter made the workflow YAML invalid, so the Rust Tests workflow never started on the feature branch.
---
.github/workflows/rust-tests.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml
index ca5012c..3197abc 100644
--- a/.github/workflows/rust-tests.yml
+++ b/.github/workflows/rust-tests.yml
@@ -34,4 +34,4 @@ jobs:
uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # stable
- name: Run updater tests on Windows
- run: cargo test --locked --no-default-features --features update --bin xfetch update::
+ run: 'cargo test --locked --no-default-features --features update --bin xfetch update::'
From d149ad573c0f5654661ffb4c61a26527a39cd509 Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Fri, 6 Nov 2026 13:12:15 +0100
Subject: [PATCH 08/19] feat(addons): add install receipts, update and rollback
Record addon install metadata, expose update/rollback for plugins, effects and extensions, and add install --all. Updates rebuild native addons from the recorded source, keep the previous artifact and receipt as .bak, and support catalog repo/ref/sdk/min-version metadata.
---
CHANGELOG.md | 16 ++
README.md | 2 +
docs/CONFIGURATION.md | 6 +-
docs/EFFECTS.md | 9 +
docs/EXTENSIONS.md | 9 +
docs/PLUGINS.md | 19 +-
src/addons/catalog.rs | 43 +++-
src/addons/mod.rs | 2 +
src/addons/model.rs | 27 +++
src/addons/receipt.rs | 241 +++++++++++++++++++++
src/addons/report.rs | 9 +
src/addons/update.rs | 441 +++++++++++++++++++++++++++++++++++++++
src/cli.rs | 77 ++++++-
src/effects/manage.rs | 20 +-
src/effects/mod.rs | 14 +-
src/extensions/manage.rs | 22 +-
src/extensions/mod.rs | 14 +-
src/main.rs | 255 +++++++++++++++-------
src/plugins/manage.rs | 20 +-
src/plugins/mod.rs | 14 +-
20 files changed, 1158 insertions(+), 102 deletions(-)
create mode 100644 src/addons/receipt.rs
create mode 100644 src/addons/update.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7560f8a..2f1d747 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,22 @@
`readme`, `keywords` and `categories`.
- The crate description now starts with `Fast` instead of `A fast`.
+### Addon management
+
+- Installs now write an `.install.json` receipt beside each native or wasm
+ addon. The receipt records the installed version, source, repository, path,
+ catalog reference, SDK version and artifact SHA-256.
+- Added `update` and `rollback` for plugins, effects and extensions:
+ `--check` reports available versions, `--all` updates every installed addon,
+ and `--force` rebuilds even when the catalog version is not newer. Native
+ addons are rebuilt from their recorded source; wasm addons keep their
+ artifact/build flow. The previous artifact and receipt are kept as `.bak`
+ for rollback.
+- Added `install --all` for plugins, effects and extensions to install every
+ catalog entry, skipping already installed addons unless `--force` is used.
+- Catalogs accept optional `repo`, `ref`, `sdk_version` and
+ `min_xfetch_version` fields for source resolution and compatibility checks.
+
### Windows updater
- `xfetch update` now works on Windows. Prebuilt installs download the
diff --git a/README.md b/README.md
index 4a19391..dafc612 100644
--- a/README.md
+++ b/README.md
@@ -197,6 +197,8 @@ xfetch --config path/to/config.jsonc
xfetch --gen-config # generate a starter config
xfetch --clean-cache # clear the module cache
xfetch plugin install # install a plugin
+xfetch plugin install --all # install every catalog plugin
+xfetch plugin update --all # update every installed plugin
xfetch plugin search # search the plugin catalog
xfetch plugin available # list the plugin catalog
xfetch extension install # install an extension
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index ab6d858..4eaeaaf 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -45,10 +45,10 @@
xfetch --list-layouts
Print the layout names accepted by --layout.
xfetch --clean-cache
Clear the module cache.
xfetch --benchmark
Print benchmarking info for the info gathering step.
diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md
index 0110aa8..784b663 100644
--- a/docs/PLUGINS.md
+++ b/docs/PLUGINS.md
@@ -26,11 +26,14 @@
End users should install official plugins by name from the remote repository:
The core downloads the plugin source from the official remote, builds it, and installs
- the resulting binary into the xfetch plugin directory.
+ the resulting binary into the xfetch plugin directory. install --all
+ installs every plugin advertised by the catalog and skips plugins that are already
+ installed; add --force to reinstall them.
@@ -48,11 +51,23 @@ XFETCH_PLUGIN_REPO=https://github.com/user/plugins.git xfetch plugin install my-
an extra development directory to that search path.
+
+ Every successful install writes an .install.json receipt next to
+ the plugin. It records the version, source, repository and artifact hash used
+ by update. Updates rebuild native plugins from the recorded
+ source and keep the previous binary as .bak for
+ rollback.
+
+
Manage installed plugins
xfetch plugin list
xfetch plugin list --verbose
xfetch plugin info <name>
+xfetch plugin update <name>
+xfetch plugin update --all
+xfetch plugin update --all --check
+xfetch plugin rollback <name>
xfetch plugin search <query>
xfetch plugin available
xfetch plugin remove <name>
diff --git a/src/addons/catalog.rs b/src/addons/catalog.rs
index 513a2a6..77df311 100644
--- a/src/addons/catalog.rs
+++ b/src/addons/catalog.rs
@@ -39,10 +39,22 @@ pub struct CatalogEntry {
pub guest_runtime: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option,
+ /// Repository URL override; the installer falls back to its family default.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub repo: Option,
+ /// Optional tag, branch or commit to build from.
+ #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
+ pub reference: Option,
+ /// SDK version used by the addon, for diagnostics and receipts.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub sdk_version: Option,
+ /// Minimum xfetch core version required by the addon.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub min_xfetch_version: Option,
}
impl CatalogEntry {
- fn install_name(&self) -> &str {
+ pub fn install_name(&self) -> &str {
self.install.as_deref().unwrap_or(&self.name)
}
@@ -88,6 +100,19 @@ pub fn search(kind: AddonKind, query: &str) -> Result, String>
.collect())
}
+/// Returns every entry in the catalog for `kind`.
+pub fn all(kind: AddonKind) -> Result, String> {
+ Ok(load(kind)?.entries)
+}
+
+/// Finds one catalog entry by its package name or install name.
+pub fn find(kind: AddonKind, name: &str) -> Result
+
+ The same actions are available through the unified form
+ xfetch addon extension <action>. See
+ CLI.md for the complete command and JSON reference.
+
+
List, inspect and remove installed extensions:
xfetch extension list
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
index 0d14d13..46d9e4a 100644
--- a/docs/INSTALLATION.md
+++ b/docs/INSTALLATION.md
@@ -455,7 +455,12 @@ xfetch update --yes # skip the confirmation prompt
+ The same actions are available through the unified form
+ xfetch addon plugin <action>. See
+ CLI.md for the complete command and JSON reference.
+
+
Manage installed plugins
xfetch plugin list
diff --git a/docs/THEMES.md b/docs/THEMES.md
index cbcaf9f..6f8566b 100644
--- a/docs/THEMES.md
+++ b/docs/THEMES.md
@@ -136,8 +136,32 @@ xfetch theme remove nord
# Export current visual config as a reusable theme file
xfetch theme export my-theme
+
+# Apply a theme to one run without editing the config
+xfetch --theme nord
+
+# Machine-readable theme list
+xfetch --json theme list
+
+# Remote registry: search, inspect, install and update
+xfetch theme search dark
+xfetch theme info nord
+xfetch theme install nord
+xfetch theme update --all --check
+xfetch theme update nord
+
+ All theme commands accept the global --json flag. Remote commands
+ use the registry at
+ https://raw.githubusercontent.com/xfetch-cli/themes/main/index.json
+ (override with XFETCH_THEME_INDEX) and are blocked by
+ --offline. Installed themes keep a
+ .<name>.xfetch-meta.json sidecar with version and source.
+ theme update --check exits 1 when an update is available. See
+ CLI.md for the JSON shapes and exit codes.
+