Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ce1094b90 | ||
|
|
0b9f19cb08 | ||
|
|
948c2d3960 | ||
|
|
0149b24a33 | ||
|
|
52fc656669 | ||
|
|
e9b04c0a84 | ||
|
|
bc1f4b1482 | ||
|
|
5036e34d3c |
@@ -245,7 +245,38 @@ jobs:
|
|||||||
echo "Created new release for ${TAG}"
|
echo "Created new release for ${TAG}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload release archive
|
- name: Build updater asset and sign
|
||||||
|
env:
|
||||||
|
UPDATER_SIGNING_KEY: ${{ secrets.UPDATER_SIGNING_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: "${VERSION:?VERSION missing from version-guard step}"
|
||||||
|
export PATH="${HOME}/go/bin:${PATH}"
|
||||||
|
: "${UPDATER_SIGNING_KEY:?Missing secret UPDATER_SIGNING_KEY. Add the updater private key under Settings → Secrets → Actions.}"
|
||||||
|
# Bare updater binary: the updater swaps os.Executable() in place,
|
||||||
|
# so it takes the single binary, not the user tarball (whose
|
||||||
|
# top-level directory would fail the single-entry rule).
|
||||||
|
mkdir -p updater-dist
|
||||||
|
cp build/bin/AniTrack updater-dist/AniTrack-linux-amd64
|
||||||
|
printf '%s' "${UPDATER_SIGNING_KEY}" > updater-dist/updater.key
|
||||||
|
chmod 600 updater-dist/updater.key
|
||||||
|
wails3 updater sign -key updater-dist/updater.key updater-dist/AniTrack-linux-amd64 > updater-dist/sign.json
|
||||||
|
# Split the sign output into the sidecar assets the Gitea provider
|
||||||
|
# fetches: <name>.sha512 (sha512sum format) + <name>.sig (base64).
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import base64, binascii, json
|
||||||
|
entries = json.load(open('updater-dist/sign.json'))
|
||||||
|
entry = next(e for e in entries if e['filename'].endswith('AniTrack-linux-amd64'))
|
||||||
|
assert entry['digestAlgo'] == 'sha512', entry
|
||||||
|
assert entry['signatureAlgo'] == 'ed25519ph', entry
|
||||||
|
digest_hex = binascii.hexlify(base64.b64decode(entry['digest'])).decode()
|
||||||
|
open('updater-dist/AniTrack-linux-amd64.sha512', 'w').write(f"{digest_hex} AniTrack-linux-amd64\n")
|
||||||
|
open('updater-dist/AniTrack-linux-amd64.sig', 'w').write(entry['signature'].strip() + '\n')
|
||||||
|
EOF
|
||||||
|
shred -u updater-dist/updater.key
|
||||||
|
ls -la updater-dist/
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
REPO: ${{ gitea.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
@@ -255,27 +286,30 @@ jobs:
|
|||||||
: "${ARCHIVE:?ARCHIVE missing from packaging step}"
|
: "${ARCHIVE:?ARCHIVE missing from packaging step}"
|
||||||
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||||
AUTH="Authorization: token ${TOKEN}"
|
AUTH="Authorization: token ${TOKEN}"
|
||||||
test -f "${ARCHIVE}" || { echo "::error::${ARCHIVE} not found" >&2; exit 1; }
|
for ASSET in "${ARCHIVE}" updater-dist/AniTrack-linux-amd64 updater-dist/AniTrack-linux-amd64.sha512 updater-dist/AniTrack-linux-amd64.sig; do
|
||||||
|
test -f "${ASSET}" || { echo "::error::${ASSET} not found" >&2; exit 1; }
|
||||||
|
|
||||||
RID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty')"
|
RID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty')"
|
||||||
if [ -z "${RID}" ]; then
|
if [ -z "${RID}" ]; then
|
||||||
echo "::error::No release found for tag ${TAG} after create step" >&2
|
echo "::error::No release found for tag ${TAG} after create step" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Replace a same-named asset so re-runs stay idempotent.
|
# Replace a same-named asset so re-runs stay idempotent.
|
||||||
AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ARCHIVE}" '.[] | select(.name==$n) | .id // empty')"
|
ANAME="$(basename "${ASSET}")"
|
||||||
if [ -n "${AID}" ]; then
|
AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ANAME}" '.[] | select(.name==$n) | .id // empty')"
|
||||||
curl -sS -X DELETE -H "${AUTH}" "${API}/${RID}/assets/${AID}" >/dev/null
|
if [ -n "${AID}" ]; then
|
||||||
echo "Deleted existing asset id=${AID} (${ARCHIVE})"
|
curl -sS -X DELETE -H "${AUTH}" "${API}/${RID}/assets/${AID}" >/dev/null
|
||||||
fi
|
echo "Deleted existing asset id=${AID} (${ANAME})"
|
||||||
|
fi
|
||||||
|
|
||||||
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
|
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
|
||||||
-F "attachment=@${ARCHIVE}" "${API}/${RID}/assets?name=${ARCHIVE}")"
|
-F "attachment=@${ASSET}" "${API}/${RID}/assets?name=${ANAME}")"
|
||||||
code="$(printf '%s' "${resp}" | tail -n1)"
|
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||||
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||||
if [ "${code}" -ge 400 ]; then
|
if [ "${code}" -ge 400 ]; then
|
||||||
echo "::error::Asset upload ${code}: ${rbody}" >&2
|
echo "::error::Asset upload ${code}: ${rbody}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Uploaded ${ARCHIVE} to release id=${RID}"
|
echo "Uploaded ${ANAME} to release id=${RID}"
|
||||||
|
done
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ frontend/package.json.md5
|
|||||||
.idea
|
.idea
|
||||||
.env
|
.env
|
||||||
environment.go
|
environment.go
|
||||||
|
# Updater signing private key. The public half (updater.pub) is committed and
|
||||||
|
# embedded; the private key lives in the password manager + CI secrets only.
|
||||||
|
updater.key
|
||||||
|
|
||||||
# REST (http files)
|
# REST (http files)
|
||||||
http-client.private.env.json
|
http-client.private.env.json
|
||||||
@@ -41,3 +44,5 @@ http-client.private.env.json
|
|||||||
*.tar
|
*.tar
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
/AniTrack
|
/AniTrack
|
||||||
|
# Updater CI staging (bare binary + sidecars, runner-ephemeral)
|
||||||
|
updater-dist/
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ release:
|
|||||||
# The regex targets only the indented info.version line, never the
|
# The regex targets only the indented info.version line, never the
|
||||||
# top-level schema `version: '3'`.
|
# top-level schema `version: '3'`.
|
||||||
@echo "Bumping build/config.yml to $(VERSION)..."
|
@echo "Bumping build/config.yml to $(VERSION)..."
|
||||||
@python3 -c "import re; p='build/config.yml'; s=open(p).read(); s2=re.sub(r'^ version: \"[^\"]*\"', ' version: \"$(VERSION)\"', s, count=1, flags=re.M); assert s2 != s, 'info.version not found'; open(p,'w').write(s2)"
|
@python3 -c "import re; p='build/config.yml'; s=open(p).read(); m=re.search(r'^ version: \"([^\"]*)\"', s, flags=re.M); assert m, 'info.version not found'; print('build/config.yml', m.group(1), '-> $(VERSION)' if m.group(1) != '$(VERSION)' else '(already at version)'); open(p,'w').write(re.sub(r'^ version: \"[^\"]*\"', ' version: \"$(VERSION)\"', s, count=1, flags=re.M))"
|
||||||
@git add wails.json build/config.yml
|
@git add wails.json build/config.yml
|
||||||
@git commit -m "chore(release): bump version to $(VERSION)"
|
@git diff --cached --quiet && echo "versions already at $(VERSION), skipping bump commit" || git commit -m "chore(release): bump version to $(VERSION)"
|
||||||
@echo "Generating release notes for $(VERSION)..."
|
@echo "Generating release notes for $(VERSION)..."
|
||||||
@git tag "$(VERSION)" HEAD && \
|
@git tag "$(VERSION)" HEAD && \
|
||||||
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ To run as is, please feel free to download a binary from the releases page.
|
|||||||
> **To run:** install the WebKitGTK 6 runtime first — it is not preinstalled
|
> **To run:** install the WebKitGTK 6 runtime first — it is not preinstalled
|
||||||
> on most distros, and the app will not start without it
|
> on most distros, and the app will not start without it
|
||||||
> (`error while loading shared libraries: libwebkitgtk-6.0.so.4`).
|
> (`error while loading shared libraries: libwebkitgtk-6.0.so.4`).
|
||||||
|
> The release tarball's `install_linux.sh` installs it automatically
|
||||||
|
> (Arch, Debian/Ubuntu, Fedora, Void, OpenMandriva). Manual fallback —
|
||||||
> Arch: `sudo pacman -S webkitgtk-6.0` ·
|
> Arch: `sudo pacman -S webkitgtk-6.0` ·
|
||||||
> Debian/Ubuntu: `sudo apt install libwebkitgtk-6.0-4`.
|
> Debian/Ubuntu: `sudo apt install libwebkitgtk-6.0-4`.
|
||||||
|
|
||||||
@@ -58,3 +60,10 @@ The `wailsv3` branch moves AniTrack from Wails `v2` → `v3` and from `99designs
|
|||||||
## Building
|
## Building
|
||||||
|
|
||||||
To build a redistributable, production mode package, use `make build`. The binary lands at `build/bin/AniTrack`.
|
To build a redistributable, production mode package, use `make build`. The binary lands at `build/bin/AniTrack`.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
Desktop Linux releases self-update: the app checks for a newer release at
|
||||||
|
startup and offers it in-app (signed, verified before install). Only the
|
||||||
|
binary updates itself — icons and the `.desktop` file still come from the
|
||||||
|
release tarball. Details in `docs/V3_MIGRATION.md: Self-updates`.
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ info:
|
|||||||
productIdentifier: "com.linuxhg.anitrack"
|
productIdentifier: "com.linuxhg.anitrack"
|
||||||
description: "Track anime watchlists across AniList, MyAnimeList, and Simkl"
|
description: "Track anime watchlists across AniList, MyAnimeList, and Simkl"
|
||||||
copyright: "John O'Keefe"
|
copyright: "John O'Keefe"
|
||||||
version: "1.99.0"
|
version: "1.99.1"
|
||||||
|
|
||||||
# Dev mode configuration
|
# Dev mode configuration
|
||||||
dev_mode:
|
dev_mode:
|
||||||
|
|||||||
@@ -1,4 +1,98 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -u
|
||||||
|
|
||||||
|
# Step 0: WebKitGTK 6 runtime ...................................................
|
||||||
|
# The release binary dynamically links libwebkitgtk-6.0 and libgtk-4, which are
|
||||||
|
# not preinstalled on most distros and are not shipped in the tarball. Install
|
||||||
|
# them first so the app can start; abort before copying anything otherwise.
|
||||||
|
#
|
||||||
|
# OS_RELEASE_FILE override exists for testing the distro mapping only.
|
||||||
|
|
||||||
|
print_manual_instructions() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Install the WebKitGTK 6 runtime for your distribution, then re-run this script:
|
||||||
|
Arch / Manjaro / EndeavourOS: sudo pacman -S webkitgtk-6.0
|
||||||
|
Debian / Ubuntu / Mint / Pop: sudo apt install libwebkitgtk-6.0-4
|
||||||
|
Fedora / Nobara: sudo dnf install webkitgtk6.0
|
||||||
|
OpenMandriva: sudo dnf install lib64webkit2gtk6.0
|
||||||
|
Void (glibc): sudo xbps-install -S libwebkitgtk60
|
||||||
|
Without it the app fails at launch: error while loading shared libraries: libwebkitgtk-6.0.so.4
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
install_webkit_runtime() {
|
||||||
|
# 0a. musl guard: the prebuilt binary is glibc-linked and cannot run on
|
||||||
|
# musl systems (Alpine, Void-musl). No package fixes that — refuse loudly.
|
||||||
|
if ldd --version 2>&1 | grep -qi musl; then
|
||||||
|
echo "ERROR: AniTrack ships a glibc-linked binary, but this system uses musl libc." >&2
|
||||||
|
echo "The prebuilt release cannot run here. Build from source instead (see README)." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 0b. Every package manager below needs privilege.
|
||||||
|
local sudo_cmd=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
if ! command -v sudo >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: root privileges are required to install system packages," >&2
|
||||||
|
echo "but sudo is not available. Install the WebKitGTK 6 runtime manually:" >&2
|
||||||
|
print_manual_instructions
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sudo_cmd="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 0c. Distro mapping. ID_LIKE catches derivatives (Mint/Pop -> ubuntu,
|
||||||
|
# Manjaro/EndeavourOS -> arch, Nobara -> fedora) without enumerating them.
|
||||||
|
local os_release="${OS_RELEASE_FILE:-/etc/os-release}"
|
||||||
|
local dist_id="" dist_like=""
|
||||||
|
if [ -r "$os_release" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$os_release"
|
||||||
|
dist_id="${ID:-}"
|
||||||
|
dist_like="${ID_LIKE:-}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
need_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
local ids=" ${dist_id} ${dist_like} "
|
||||||
|
case "$ids" in
|
||||||
|
*" arch "*|*" manjaro "*|*" endeavouros "*|*" garuda "*|*" cachyos "*)
|
||||||
|
need_cmd pacman || { echo "ERROR: pacman not found on Arch-family system." >&2; return 1; }
|
||||||
|
$sudo_cmd pacman -S --needed --noconfirm webkitgtk-6.0 || return 1
|
||||||
|
;;
|
||||||
|
*" debian "*|*" ubuntu "*|*" pop "*|*" linuxmint "*)
|
||||||
|
need_cmd apt-get || { echo "ERROR: apt-get not found on Debian-family system." >&2; return 1; }
|
||||||
|
$sudo_cmd apt-get update && $sudo_cmd apt-get install -y libwebkitgtk-6.0-4 || return 1
|
||||||
|
;;
|
||||||
|
*" fedora "*|*" nobara "*)
|
||||||
|
need_cmd dnf || { echo "ERROR: dnf not found on Fedora-family system." >&2; return 1; }
|
||||||
|
$sudo_cmd dnf install -y webkitgtk6.0 || return 1
|
||||||
|
;;
|
||||||
|
*" openmandriva "*)
|
||||||
|
need_cmd dnf || { echo "ERROR: dnf not found on OpenMandriva system." >&2; return 1; }
|
||||||
|
$sudo_cmd dnf install -y lib64webkit2gtk6.0 || return 1
|
||||||
|
;;
|
||||||
|
*" void "*)
|
||||||
|
need_cmd xbps-install || { echo "ERROR: xbps-install not found on Void system." >&2; return 1; }
|
||||||
|
$sudo_cmd xbps-install -Sy libwebkitgtk60 || return 1
|
||||||
|
;;
|
||||||
|
*" alpine "*)
|
||||||
|
echo "ERROR: Alpine Linux uses musl libc, which the prebuilt glibc-linked binary cannot run on." >&2
|
||||||
|
echo "Build from source instead (see README)." >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: unsupported or unrecognised distribution (ID='${dist_id}' ID_LIKE='${dist_like}')." >&2
|
||||||
|
print_manual_instructions
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
unset -f need_cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
install_webkit_runtime || exit 1
|
||||||
|
|
||||||
# copy desktop file
|
# copy desktop file
|
||||||
if [ ! -f "$HOME/.local/share/applications/AniTrack.desktop" ]; then
|
if [ ! -f "$HOME/.local/share/applications/AniTrack.desktop" ]; then
|
||||||
|
|||||||
+20
-3
@@ -34,16 +34,33 @@ On Linux, `v2` tokens lived in an `AniTrack` Secret Service collection as labele
|
|||||||
|
|
||||||
In practice: running both versions side-by-side works fine — each reads its own collection, neither overwrites the other. If you fully move to `v3`, just log into AniList / MAL / Simkl once there; the `v2` entries become stale but harmless (11 small items). See the notice in `README.md: Migrating from v2` — no automatic deletion, on purpose.
|
In practice: running both versions side-by-side works fine — each reads its own collection, neither overwrites the other. If you fully move to `v3`, just log into AniList / MAL / Simkl once there; the `v2` entries become stale but harmless (11 small items). See the notice in `README.md: Migrating from v2` — no automatic deletion, on purpose.
|
||||||
|
|
||||||
|
## Self-updates (desktop Linux, 1.99.x)
|
||||||
|
|
||||||
|
Releases self-update in place via the Wails v3 updater (`app.Updater`): a
|
||||||
|
custom Gitea provider (`updater/gitea` — Wails ships none for Gitea) checks
|
||||||
|
the Gitea releases API, and CI publishes a signed bare-binary asset
|
||||||
|
(`AniTrack-linux-amd64` + `.sha512`/`.sig` sidecars from
|
||||||
|
`wails3 updater sign`) next to the user tarball. Verification is fail-closed;
|
||||||
|
a release missing any of the three files is skipped. Gated to desktop
|
||||||
|
production builds (`application.System.IsDesktop()` + `-tags production`) —
|
||||||
|
mobile stays on Obtainium, dev builds never check. The startup check is
|
||||||
|
headless; the builtin window opens only when an update is found.
|
||||||
|
`ANITRACK_UPDATER_CHANNEL=beta` includes `-rc` pre-releases (testing only).
|
||||||
|
Only the binary self-swaps; icons/`.desktop` still come from the tarball's
|
||||||
|
`install_linux.sh`. Signing: public half `updater.pub` is embedded; the
|
||||||
|
private key lives in the password manager + the `UPDATER_SIGNING_KEY` CI
|
||||||
|
secret, never in the repo (see `.gitignore`).
|
||||||
|
|
||||||
## Future plans on v3
|
## Future plans on v3
|
||||||
|
|
||||||
**Not yet done — staged after the desktop migration, in order** (each with its home branch, so nothing lands in the wrong place):
|
**Not yet done — staged after the desktop migration, in order** (each with its home branch, so nothing lands in the wrong place):
|
||||||
|
|
||||||
1. **Stabilize desktop `v3` (on `wailsv3` — this branch's whole purpose):** real-world dogfooding (logins, watchlist sync, error modals), then merge to `main` when Wails `v3` hits stable.
|
1. **Stabilize desktop `v3`:** real-world dogfooding of `main` (logins, watchlist sync, error modals). Merged to `main` as the `1.99.x` beta series while Wails `v3` is still beta upstream.
|
||||||
2. **Frontend toolchain refresh (on `wailsv3`, before the merge):** Svelte 4 → 5, Vite 4 → 8, `vite-plugin-svelte 2 → 7`, Tailwind 3 → 4 — majors deferred intentionally; they pair naturally with `v3`’s Svelte 5 templates and touch the same desktop files, so no separate branch.
|
2. **Frontend toolchain refresh (on `wailsv3`, before the merge):** Svelte 4 → 5, Vite 4 → 8, `vite-plugin-svelte 2 → 7`, Tailwind 3 → 4 — majors deferred intentionally; they pair naturally with `v3`’s Svelte 5 templates and touch the same desktop files, so no separate branch.
|
||||||
3. **Android (short-lived feature branch off `wailsv3`, e.g. `wailsv3-android`, merged back):** personal sideload, no Play/official F-Droid. Per-file `//go:build android` shims — `zalando` → `Android.Secure*` (`EncryptedSharedPreferences`), and `localhost:6734` OAuth callbacks → deep-link `anitrack://callback` + intent filter; start with one provider (AniList) to prove the pattern. Responsive / safe-area polish. Distribution via `adb install` + GitHub Releases + Obtainium. Kept off `wailsv3` proper so the mobile scaffolding (`build/android/`, manifests, gradle files) doesn't pollute the desktop-to-`main` merge.
|
3. **Android (short-lived feature branch off `wailsv3`, e.g. `wailsv3-android`, merged back):** personal sideload, no Play/official F-Droid. Per-file `//go:build android` shims — `zalando` → `Android.Secure*` (`EncryptedSharedPreferences`), and `localhost:6734` OAuth callbacks → deep-link `anitrack://callback` + intent filter; start with one provider (AniList) to prove the pattern. Responsive / safe-area polish. Distribution via `adb install` + GitHub Releases + Obtainium. Kept off `wailsv3` proper so the mobile scaffolding (`build/android/`, manifests, gradle files) doesn't pollute the desktop-to-`main` merge.
|
||||||
4. **CI follow-ups (wherever the work they support lives):** none required for desktop; the current 4 caches + the fixed runner cache backend already bring releases from ~12 min to ~3 min. Builder image only if the 2 min apt floor becomes annoying.
|
4. **CI follow-ups (wherever the work they support lives):** none required for desktop; the current 4 caches + the fixed runner cache backend already bring releases from ~12 min to ~3 min. Builder image only if the 2 min apt floor becomes annoying.
|
||||||
|
|
||||||
## Branch map
|
## Branch map
|
||||||
|
|
||||||
- `main` — still `v2`, stable, ships `1.6.8` releases.
|
- `main` — v3 desktop, ships `1.99.x` beta-series releases.
|
||||||
- `wailsv3` — `v3` desktop, unpushed until dogfooding passes, ahead of `main` by the commits above plus `2bf8d38` (`frontend/package-lock.json`) and `efe45f3`/`a19080e`/`336a1f3` from the CI/cache work. Merge `main` forward before each `v3` push.
|
- `wailsv3` — retained as an alias tracking `main` for now.
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func main() {
|
|||||||
svc = NewApp(app)
|
svc = NewApp(app)
|
||||||
app.RegisterService(application.NewService(svc))
|
app.RegisterService(application.NewService(svc))
|
||||||
|
|
||||||
|
// Self-updates: desktop production builds only (no-op elsewhere).
|
||||||
|
maybeEnableUpdater(app)
|
||||||
|
|
||||||
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
Title: appTitle(),
|
Title: appTitle(),
|
||||||
Width: 1024,
|
Width: 1024,
|
||||||
|
|||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/updater"
|
||||||
|
|
||||||
|
giteaprovider "AniTrack/updater/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed updater.pub
|
||||||
|
var updaterPublicKey []byte
|
||||||
|
|
||||||
|
// maybeEnableUpdater wires self-updates: desktop production builds only.
|
||||||
|
// Mobile stays on Obtainium; dev builds never phone home. The startup check
|
||||||
|
// is headless — the builtin window opens only when an update is found, via a
|
||||||
|
// second CheckAndInstall (one redundant index round-trip, negligible).
|
||||||
|
func maybeEnableUpdater(app *application.App) {
|
||||||
|
if !updaterAutoCheck {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !application.System.IsDesktop() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gh, err := giteaprovider.New(giteaprovider.Config{
|
||||||
|
BaseURL: "https://git.linuxhg.com",
|
||||||
|
Owner: "john-okeefe",
|
||||||
|
Repo: "Anitrack",
|
||||||
|
AssetName: "AniTrack-linux-amd64",
|
||||||
|
// Beta channel (rc tags) for testing: ANITRACK_UPDATER_CHANNEL=beta.
|
||||||
|
AllowPrerelease: os.Getenv("ANITRACK_UPDATER_CHANNEL") == "beta",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("updater: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.Updater.Init(updater.Config{
|
||||||
|
CurrentVersion: appVersion(),
|
||||||
|
Providers: []updater.Provider{gh},
|
||||||
|
PublicKey: updaterPublicKey,
|
||||||
|
Window: &updater.BuiltinWindow{
|
||||||
|
CSS: ":root { --accent: #4d9fff; }",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("updater: init: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
rel, err := app.Updater.Check(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("updater: check: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("updater: %s available, opening installer", rel.Version)
|
||||||
|
if err := app.Updater.CheckAndInstall(context.Background()); err != nil {
|
||||||
|
log.Printf("updater: install: %s", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAWWF3cDxJKM668FqWsFsA9e7r7aXU2uDTUF+ZKKcxLP0=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// Package gitea implements updater.Provider for Gitea releases.
|
||||||
|
//
|
||||||
|
// Releases live on a self-hosted Gitea instance, for which Wails ships no
|
||||||
|
// in-tree provider, so this mirrors the in-tree github provider against
|
||||||
|
// Gitea's releases API. Everything else (verification, staging, swap,
|
||||||
|
// restart, window) stays the framework's job.
|
||||||
|
//
|
||||||
|
// Asset layout per release (see .gitea/workflows/release.yml):
|
||||||
|
//
|
||||||
|
// AniTrack-linux-amd64 bare updater binary, exact name match
|
||||||
|
// AniTrack-linux-amd64.sha512 "<hex> <filename>" (sha512sum format)
|
||||||
|
// AniTrack-linux-amd64.sig base64 ed25519ph signature from
|
||||||
|
// `wails3 updater sign`
|
||||||
|
//
|
||||||
|
// A release missing any of the three is skipped: verification fails closed.
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures the Gitea provider.
|
||||||
|
type Config struct {
|
||||||
|
// BaseURL is the instance root, e.g. https://git.linuxhg.com.
|
||||||
|
BaseURL string
|
||||||
|
// Owner and Repo identify the repository, e.g. john-okeefe/Anitrack.
|
||||||
|
Owner string
|
||||||
|
Repo string
|
||||||
|
// AssetName is the exact updater artifact name, e.g. AniTrack-linux-amd64.
|
||||||
|
AssetName string
|
||||||
|
// HTTPClient overrides the default client (30s timeout). Optional.
|
||||||
|
HTTPClient *http.Client
|
||||||
|
// AllowPrerelease includes pre-release-tagged releases. Default false:
|
||||||
|
// -rc tags stay invisible (stable channel). Beta/test flows opt in.
|
||||||
|
AllowPrerelease bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is an updater.Provider backed by Gitea releases.
|
||||||
|
type Provider struct {
|
||||||
|
cfg Config
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New validates config and returns a Provider.
|
||||||
|
func New(cfg Config) (*Provider, error) {
|
||||||
|
cfg.BaseURL = strings.TrimSuffix(cfg.BaseURL, "/")
|
||||||
|
if cfg.BaseURL == "" || cfg.Owner == "" || cfg.Repo == "" || cfg.AssetName == "" {
|
||||||
|
return nil, fmt.Errorf("gitea: BaseURL, Owner, Repo and AssetName are all required")
|
||||||
|
}
|
||||||
|
client := cfg.HTTPClient
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
return &Provider{cfg: cfg, client: client}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name identifies the provider in logs and event payloads.
|
||||||
|
func (p *Provider) Name() string { return "gitea" }
|
||||||
|
|
||||||
|
// giteaRelease mirrors the fields we read from the Gitea releases API.
|
||||||
|
type giteaRelease struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
PublishedAt string `json:"published_at"`
|
||||||
|
Assets []giteaAsset `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// giteaAsset mirrors one release attachment.
|
||||||
|
type giteaAsset struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check finds the newest usable release newer than req.CurrentVersion.
|
||||||
|
// (nil, nil) means up-to-date.
|
||||||
|
func (p *Provider) Check(ctx context.Context, req updater.CheckRequest) (*updater.Release, error) {
|
||||||
|
releases, err := p.listReleases(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var best *giteaRelease
|
||||||
|
var bestAsset *giteaAsset
|
||||||
|
for i := range releases {
|
||||||
|
r := &releases[i]
|
||||||
|
if r.Draft {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Prerelease && !p.cfg.AllowPrerelease {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if compareSemver(r.TagName, req.CurrentVersion) <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
asset := findAsset(r.Assets, p.cfg.AssetName)
|
||||||
|
if asset == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if best == nil || compareSemver(r.TagName, best.TagName) > 0 {
|
||||||
|
best = r
|
||||||
|
bestAsset = asset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
verification, err := p.fetchVerification(ctx, *bestAsset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rel := &updater.Release{
|
||||||
|
Version: strings.TrimPrefix(strings.TrimPrefix(best.TagName, "v"), "V"),
|
||||||
|
Channel: "stable",
|
||||||
|
Name: best.Name,
|
||||||
|
Notes: best.Body,
|
||||||
|
Artifact: updater.Artifact{
|
||||||
|
Filename: bestAsset.Name,
|
||||||
|
Size: bestAsset.Size,
|
||||||
|
Platform: req.Platform,
|
||||||
|
Arch: req.Arch,
|
||||||
|
},
|
||||||
|
Verification: verification,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"downloadURL": bestAsset.BrowserDownloadURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if rel.Channel == "stable" && (best.Prerelease) {
|
||||||
|
rel.Channel = "beta"
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, best.PublishedAt); err == nil {
|
||||||
|
rel.PublishedAt = t
|
||||||
|
}
|
||||||
|
return rel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams the release artifact to dst, reporting progress.
|
||||||
|
func (p *Provider) Download(ctx context.Context, r *updater.Release, dst io.Writer, onProgress func(written, total int64)) error {
|
||||||
|
url, _ := r.Metadata["downloadURL"].(string)
|
||||||
|
if url == "" {
|
||||||
|
return fmt.Errorf("gitea: release %s has no download URL", r.Version)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "AniTrack-updater")
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("gitea: download %s: %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
total := r.Artifact.Size
|
||||||
|
if total <= 0 {
|
||||||
|
total = resp.ContentLength
|
||||||
|
}
|
||||||
|
var written int64
|
||||||
|
buf := make([]byte, 128*1024)
|
||||||
|
for {
|
||||||
|
n, rerr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
written += int64(n)
|
||||||
|
onProgress(written, total)
|
||||||
|
}
|
||||||
|
if rerr == io.EOF {
|
||||||
|
onProgress(written, total)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
return rerr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listReleases fetches the releases index (newest first page is enough).
|
||||||
|
func (p *Provider) listReleases(ctx context.Context) ([]giteaRelease, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=50", p.cfg.BaseURL, p.cfg.Owner, p.cfg.Repo)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "AniTrack-updater")
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitea: list releases: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("gitea: list releases: %s", resp.Status)
|
||||||
|
}
|
||||||
|
var releases []giteaRelease
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||||
|
return nil, fmt.Errorf("gitea: decode releases: %w", err)
|
||||||
|
}
|
||||||
|
return releases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAsset returns the attachment with exactly assetName, or nil.
|
||||||
|
func findAsset(assets []giteaAsset, assetName string) *giteaAsset {
|
||||||
|
for i := range assets {
|
||||||
|
if assets[i].Name == assetName && assets[i].BrowserDownloadURL != "" {
|
||||||
|
return &assets[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchVerification downloads the .sha512 + .sig sidecars next to the asset.
|
||||||
|
// This mirrors `wails3 updater sign` (SHA-512 digest, ed25519ph signature).
|
||||||
|
// Anything missing or malformed is an error: verification fails closed.
|
||||||
|
func (p *Provider) fetchVerification(ctx context.Context, asset giteaAsset) (*updater.Verification, error) {
|
||||||
|
base := strings.TrimSuffix(asset.BrowserDownloadURL, asset.Name)
|
||||||
|
digestHex, err := p.fetchText(ctx, base+asset.Name+".sha512")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitea: digest sidecar: %w", err)
|
||||||
|
}
|
||||||
|
sigB64, err := p.fetchText(ctx, base+asset.Name+".sig")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitea: signature sidecar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<hex> <filename>" (sha512sum format); tolerate bare hex too.
|
||||||
|
digestHex = strings.Fields(digestHex)[0]
|
||||||
|
digest, err := hex.DecodeString(digestHex)
|
||||||
|
if err != nil || len(digest) != 64 {
|
||||||
|
return nil, fmt.Errorf("gitea: malformed digest sidecar for %s", asset.Name)
|
||||||
|
}
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sigB64))
|
||||||
|
if err != nil || len(sig) == 0 {
|
||||||
|
return nil, fmt.Errorf("gitea: malformed signature sidecar for %s", asset.Name)
|
||||||
|
}
|
||||||
|
return &updater.Verification{
|
||||||
|
DigestAlgo: "sha512",
|
||||||
|
Digest: digest,
|
||||||
|
SignatureAlgo: "ed25519ph",
|
||||||
|
Signature: sig,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchText GETs url and returns the trimmed body.
|
||||||
|
func (p *Provider) fetchText(ctx context.Context, url string) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "AniTrack-updater")
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("%s: %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(body)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareSemver compares plain versions ("1.99.0", optional v prefix).
|
||||||
|
// Pre-release suffixes (-rc1) sort below the plain release. Returns
|
||||||
|
// -1, 0 or +1. Unparseable input compares as 0.0.0 (never newest).
|
||||||
|
func compareSemver(a, b string) int {
|
||||||
|
an, ap := splitSemver(a)
|
||||||
|
bn, bp := splitSemver(b)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if an[i] != bn[i] {
|
||||||
|
if an[i] < bn[i] {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case ap == bp:
|
||||||
|
return 0
|
||||||
|
case ap == "":
|
||||||
|
return 1
|
||||||
|
case bp == "":
|
||||||
|
return -1
|
||||||
|
case ap < bp:
|
||||||
|
return -1
|
||||||
|
case ap > bp:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitSemver splits "v1.99.0-rc1" into ([1 99 0], "rc1").
|
||||||
|
func splitSemver(v string) ([3]int, string) {
|
||||||
|
var nums [3]int
|
||||||
|
v = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(v), "v"), "V")
|
||||||
|
core, pre, _ := strings.Cut(v, "-")
|
||||||
|
for i, part := range strings.Split(core, ".") {
|
||||||
|
if i >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(part)
|
||||||
|
if err != nil {
|
||||||
|
return [3]int{}, pre
|
||||||
|
}
|
||||||
|
nums[i] = n
|
||||||
|
}
|
||||||
|
return nums, pre
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompareSemver(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"1.99.0", "1.6.8", 1},
|
||||||
|
{"1.6.8", "1.99.0", -1},
|
||||||
|
{"1.99.0", "1.99.0", 0},
|
||||||
|
{"v1.99.0", "1.99.0", 0},
|
||||||
|
{"2.0.0", "1.99.9", 1},
|
||||||
|
{"1.99.0-rc1", "1.99.0", -1},
|
||||||
|
{"1.99.0", "1.99.0-rc1", 1},
|
||||||
|
{"1.99.0-rc1", "1.99.0-rc2", -1},
|
||||||
|
{"1.10.0", "1.9.0", 1}, // numeric, not lexicographic
|
||||||
|
{"garbage", "1.0.0", -1},
|
||||||
|
{"1.0.0", "garbage", 1},
|
||||||
|
{"", "", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := compareSemver(c.a, c.b); got != c.want {
|
||||||
|
t.Errorf("compareSemver(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testAsset = "AniTrack-linux-amd64"
|
||||||
|
const testData = "artifact-bytes"
|
||||||
|
|
||||||
|
type releaseSpec struct {
|
||||||
|
tag string
|
||||||
|
draft bool
|
||||||
|
prerelease bool
|
||||||
|
withAsset bool
|
||||||
|
withSidecar bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServer serves a canned releases index. Every spec with withAsset gets
|
||||||
|
// the test artifact; sidecars (.sha512/.sig, correctly formed) are served
|
||||||
|
// only if at least one spec sets withSidecar.
|
||||||
|
func buildServer(t *testing.T, specs []releaseSpec) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data := []byte(testData)
|
||||||
|
sum := sha512.Sum512(data)
|
||||||
|
sha := hex.EncodeToString(sum[:]) + " " + testAsset + "\n"
|
||||||
|
sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, sum[:])) + "\n"
|
||||||
|
serveSidecars := false
|
||||||
|
for _, s := range specs {
|
||||||
|
if s.withSidecar {
|
||||||
|
serveSidecars = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var srv *httptest.Server
|
||||||
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/releases"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("[")
|
||||||
|
for i, s := range specs {
|
||||||
|
assets := ""
|
||||||
|
if s.withAsset {
|
||||||
|
assets = fmt.Sprintf(`{"name":%q,"size":%d,"browser_download_url":%q}`,
|
||||||
|
testAsset, len(data), srv.URL+"/dl/"+testAsset)
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(",")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `{"tag_name":%q,"name":%q,"body":"notes","draft":%v,"prerelease":%v,"published_at":"2026-09-16T00:00:00Z","assets":[%s]}`,
|
||||||
|
s.tag, s.tag, s.draft, s.prerelease, assets)
|
||||||
|
}
|
||||||
|
b.WriteString("]")
|
||||||
|
fmt.Fprint(w, b.String())
|
||||||
|
case serveSidecars && r.URL.Path == "/dl/"+testAsset+".sha512":
|
||||||
|
fmt.Fprint(w, sha)
|
||||||
|
case serveSidecars && r.URL.Path == "/dl/"+testAsset+".sig":
|
||||||
|
fmt.Fprint(w, sig)
|
||||||
|
case serveSidecars && r.URL.Path == "/dl/"+testAsset:
|
||||||
|
w.Write(data)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProvider(srv *httptest.Server, allowPre bool) *Provider {
|
||||||
|
p, err := New(Config{
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
Owner: "o",
|
||||||
|
Repo: "r",
|
||||||
|
AssetName: testAsset,
|
||||||
|
AllowPrerelease: allowPre,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckFindsNewest(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{
|
||||||
|
{tag: "1.6.8", withAsset: true, withSidecar: true},
|
||||||
|
{tag: "1.99.0", withAsset: true, withSidecar: true},
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
rel, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel == nil {
|
||||||
|
t.Fatal("expected a release, got up-to-date")
|
||||||
|
}
|
||||||
|
if rel.Version != "1.99.0" {
|
||||||
|
t.Errorf("version = %q, want 1.99.0", rel.Version)
|
||||||
|
}
|
||||||
|
if rel.Verification == nil || rel.Verification.DigestAlgo != "sha512" || rel.Verification.SignatureAlgo != "ed25519ph" {
|
||||||
|
t.Errorf("verification not populated: %+v", rel.Verification)
|
||||||
|
}
|
||||||
|
if len(rel.Verification.Digest) != 64 || len(rel.Verification.Signature) == 0 {
|
||||||
|
t.Errorf("verification malformed: %+v", rel.Verification)
|
||||||
|
}
|
||||||
|
if rel.Notes != "notes" {
|
||||||
|
t.Errorf("notes = %q", rel.Notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckUpToDate(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{{tag: "1.99.0", withAsset: true, withSidecar: true}})
|
||||||
|
defer srv.Close()
|
||||||
|
rel, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.99.0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel != nil {
|
||||||
|
t.Errorf("expected up-to-date, got %s", rel.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSkipsDraftPrereleaseAndMissingAsset(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{
|
||||||
|
{tag: "1.99.2", draft: true, withAsset: true, withSidecar: true},
|
||||||
|
{tag: "1.99.1", prerelease: true, withAsset: true, withSidecar: true},
|
||||||
|
{tag: "1.99.0", withAsset: false},
|
||||||
|
{tag: "1.6.9", withAsset: true, withSidecar: true},
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
rel, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel == nil || rel.Version != "1.6.9" {
|
||||||
|
t.Fatalf("version = %v, want 1.6.9 (draft/prerelease/asset-less skipped)", rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAllowPrerelease(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{
|
||||||
|
{tag: "1.99.1-rc1", prerelease: true, withAsset: true, withSidecar: true},
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
rel, err := testProvider(srv, true).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.99.0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel == nil || rel.Version != "1.99.1-rc1" {
|
||||||
|
t.Fatalf("version = %v, want 1.99.1-rc1", rel)
|
||||||
|
}
|
||||||
|
if rel.Channel != "beta" {
|
||||||
|
t.Errorf("channel = %q, want beta", rel.Channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckFailsClosedWithoutSidecars(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{{tag: "1.99.0", withAsset: true, withSidecar: false}})
|
||||||
|
defer srv.Close()
|
||||||
|
_, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing sidecars, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
_, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 500, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadStreamsWithProgress(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{{tag: "1.99.0", withAsset: true, withSidecar: true}})
|
||||||
|
defer srv.Close()
|
||||||
|
p := testProvider(srv, false)
|
||||||
|
rel, err := p.Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"})
|
||||||
|
if err != nil || rel == nil {
|
||||||
|
t.Fatalf("check: %v %v", rel, err)
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp("", "dl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
var calls int
|
||||||
|
var last int64
|
||||||
|
err = p.Download(context.Background(), rel, f, func(written, total int64) {
|
||||||
|
calls++
|
||||||
|
last = written
|
||||||
|
if total != int64(len(testData)) {
|
||||||
|
t.Errorf("total = %d", total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls == 0 || last != int64(len(testData)) {
|
||||||
|
t.Errorf("progress calls=%d last=%d", calls, last)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(f.Name())
|
||||||
|
if string(got) != testData {
|
||||||
|
t.Errorf("body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValidatesConfig(t *testing.T) {
|
||||||
|
if _, err := New(Config{}); err == nil {
|
||||||
|
t.Error("expected error for empty config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLiveGiteaCheck hits the real instance. Opt-in only:
|
||||||
|
// ANITRACK_LIVE_TEST=1 go test ./updater/gitea/ -run TestLiveGiteaCheck -v
|
||||||
|
func TestLiveGiteaCheck(t *testing.T) {
|
||||||
|
if os.Getenv("ANITRACK_LIVE_TEST") == "" {
|
||||||
|
t.Skip("set ANITRACK_LIVE_TEST=1 to run")
|
||||||
|
}
|
||||||
|
p, err := New(Config{
|
||||||
|
BaseURL: "https://git.linuxhg.com",
|
||||||
|
Owner: "john-okeefe",
|
||||||
|
Repo: "Anitrack",
|
||||||
|
AssetName: "AniTrack-linux-amd64",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rel, err := p.Check(context.Background(), updater.CheckRequest{CurrentVersion: "0.0.0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel == nil {
|
||||||
|
// Correct until CI publishes the bare-binary asset: no release
|
||||||
|
// carries AniTrack-linux-amd64 yet, so there is nothing to offer.
|
||||||
|
// This still proves API reachability + response parsing live.
|
||||||
|
t.Log("no matching asset published yet (expected until CI ships AniTrack-linux-amd64)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("latest=%s notes=%d bytes", rel.Version, len(rel.Notes))
|
||||||
|
var n int64
|
||||||
|
err = p.Download(context.Background(), rel, io.Discard, func(w, _ int64) { n = w })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("downloaded %d bytes", n)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !production
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// updaterAutoCheck is false outside production builds (dev, vet, plain
|
||||||
|
// go build): the updater is compiled in but never runs.
|
||||||
|
const updaterAutoCheck = false
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// End-to-end updater flow test (headless): Gitea fixture provider ->
|
||||||
|
// framework Check -> DownloadAndInstall -> REAL signature verification
|
||||||
|
// against a test key. Proves our sidecar shape is exactly what the
|
||||||
|
// framework verifier accepts. No network, no display, no Run().
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/updater"
|
||||||
|
|
||||||
|
giteaprovider "AniTrack/updater/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
const flowAsset = "AniTrack-linux-amd64"
|
||||||
|
const flowData = "flow-test-binary-bytes"
|
||||||
|
|
||||||
|
// flowServer serves one signed release for version 1.99.0. When *tampered
|
||||||
|
// is true the .sig sidecar is corrupted so verification must fail. The flag
|
||||||
|
// is read per-request so one server covers both phases (the framework keeps
|
||||||
|
// a process-wide app singleton, so only one application.New per test binary).
|
||||||
|
func flowServer(t *testing.T, pub ed25519.PublicKey, priv ed25519.PrivateKey, tampered *bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
data := []byte(flowData)
|
||||||
|
sum := sha512.Sum512(data)
|
||||||
|
sigBytes, err := priv.Sign(rand.Reader, sum[:], &ed25519.Options{Hash: crypto.SHA512})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sha := hex.EncodeToString(sum[:]) + " " + flowAsset + "\n"
|
||||||
|
mkSig := func() string {
|
||||||
|
s := sigBytes
|
||||||
|
if *tampered {
|
||||||
|
s = append([]byte(nil), sigBytes...)
|
||||||
|
s[0] ^= 0xff
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(s) + "\n"
|
||||||
|
}
|
||||||
|
var srv *httptest.Server
|
||||||
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/releases"):
|
||||||
|
fmt.Fprintf(w, `[{"tag_name":"1.99.0","name":"1.99.0","body":"n","draft":false,"prerelease":false,"published_at":"2026-09-16T00:00:00Z","assets":[{"name":%q,"size":%d,"browser_download_url":%q}]}]`,
|
||||||
|
flowAsset, len(data), srv.URL+"/dl/"+flowAsset)
|
||||||
|
case r.URL.Path == "/dl/"+flowAsset+".sha512":
|
||||||
|
fmt.Fprint(w, sha)
|
||||||
|
case r.URL.Path == "/dl/"+flowAsset+".sig":
|
||||||
|
fmt.Fprint(w, mkSig())
|
||||||
|
case r.URL.Path == "/dl/"+flowAsset:
|
||||||
|
w.Write(data)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
_ = pub
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdaterFlowDownloadsAndVerifies(t *testing.T) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = priv
|
||||||
|
tampered := false
|
||||||
|
srv := flowServer(t, pub, priv, &tampered)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
gh, err := giteaprovider.New(giteaprovider.Config{
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
Owner: "o",
|
||||||
|
Repo: "r",
|
||||||
|
AssetName: flowAsset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := application.New(application.Options{Name: "AniTrackFlowTest"})
|
||||||
|
if err := app.Updater.Init(updater.Config{
|
||||||
|
CurrentVersion: "1.6.8",
|
||||||
|
Providers: []updater.Provider{gh},
|
||||||
|
PublicKey: []byte(pub),
|
||||||
|
Window: updater.WindowNone,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Phase 1: good signature -> staged bytes match the fixture.
|
||||||
|
rel, err := app.Updater.Check(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel == nil {
|
||||||
|
t.Fatal("expected an update from 1.6.8 to 1.99.0")
|
||||||
|
}
|
||||||
|
if err := app.Updater.DownloadAndInstall(ctx); err != nil {
|
||||||
|
t.Fatalf("DownloadAndInstall (incl. real signature verify): %s", err)
|
||||||
|
}
|
||||||
|
staged := app.Updater.DownloadedPath()
|
||||||
|
if staged == "" {
|
||||||
|
t.Fatal("no staged path after install")
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(staged)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != flowData {
|
||||||
|
t.Errorf("staged bytes = %q, want fixture", got)
|
||||||
|
}
|
||||||
|
os.Remove(staged)
|
||||||
|
|
||||||
|
// Phase 2: tampered signature -> install must fail closed.
|
||||||
|
tampered = true
|
||||||
|
if _, err := app.Updater.Check(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Updater.DownloadAndInstall(ctx); err == nil {
|
||||||
|
t.Fatal("expected verification failure for tampered signature, got nil")
|
||||||
|
} else {
|
||||||
|
t.Logf("rejected as expected: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build production
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// updaterAutoCheck gates the startup update check to production builds:
|
||||||
|
// `wails3 build` sets -tags production, `wails3 dev` does not, so dev
|
||||||
|
// binaries in build/bin never phone home.
|
||||||
|
const updaterAutoCheck = true
|
||||||
+1
-1
@@ -13,6 +13,6 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "AniTrack",
|
"productName": "AniTrack",
|
||||||
"productVersion": "1.99.0"
|
"productVersion": "1.99.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user