Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ab5a080a9 | ||
|
|
60ff980809 | ||
|
|
e2c8ba10ce | ||
|
|
515678f035 | ||
|
|
e8a5768a2f | ||
|
|
2a6397b964 | ||
|
|
de808dbb67 | ||
|
|
f63563ce16 | ||
|
|
8d5d8ac2d7 | ||
|
|
59b81396f9 | ||
|
|
4087599629 | ||
|
|
577c7d0d5b | ||
|
|
5ce1094b90 | ||
|
|
0b9f19cb08 | ||
|
|
948c2d3960 | ||
|
|
0149b24a33 | ||
|
|
52fc656669 | ||
|
|
e9b04c0a84 | ||
|
|
bc1f4b1482 | ||
|
|
5036e34d3c |
@@ -12,9 +12,10 @@ run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
|
||||
# work-in-progress commits never ship.
|
||||
#
|
||||
# Local flow: `./release 1.6.7` (or `make release VERSION=1.6.7`) bumps
|
||||
# wails.json, commits the bump, creates an annotated tag carrying the
|
||||
# git-cliff notes, and pushes commit + tag. This workflow verifies
|
||||
# wails.json matches the tag before building.
|
||||
# build/config.yml (and the generated version.go), commits the bump,
|
||||
# creates an annotated tag carrying the git-cliff notes, and pushes
|
||||
# commit + tag. This workflow verifies build/config.yml matches the tag
|
||||
# before building.
|
||||
#
|
||||
# Secrets test (verifies environment.go wiring without shipping): tag the
|
||||
# current bumped commit with a suffix and push, e.g.
|
||||
@@ -49,7 +50,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
|
||||
|
||||
- name: Guard wails.json matches tag
|
||||
- name: Guard build/config.yml matches tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${TAG:?TAG is required}"
|
||||
@@ -58,21 +59,21 @@ jobs:
|
||||
NORM="${TAG#v}"
|
||||
NORM="${NORM#AniTrack-}"
|
||||
BASE="${NORM%%-*}"
|
||||
WAILS_VER="$(python3 -c "import json; print(json.load(open('wails.json'))['info']['productVersion'])")"
|
||||
CFG_VER="$(python3 -c "import re; print(re.search(r'^ version: \"([^\"]*)\"', open('build/config.yml').read(), flags=re.M).group(1))")"
|
||||
if [ "${NORM}" = "${BASE}" ]; then
|
||||
if [ "${WAILS_VER}" != "${NORM}" ]; then
|
||||
echo "::error::wails.json productVersion (${WAILS_VER}) != tag (${NORM}). Bump via ./release ${NORM} first." >&2
|
||||
if [ "${CFG_VER}" != "${NORM}" ]; then
|
||||
echo "::error::build/config.yml info.version (${CFG_VER}) != tag (${NORM}). Bump via ./release ${NORM} first." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Pre-release (e.g. 1.6.7-rc1): wails.json must match the base version.
|
||||
if [ "${WAILS_VER}" != "${BASE}" ]; then
|
||||
echo "::error::wails.json productVersion (${WAILS_VER}) != tag base (${BASE}). Bump via ./release ${BASE} first." >&2
|
||||
# Pre-release (e.g. 1.6.7-rc1): build/config.yml must match the base version.
|
||||
if [ "${CFG_VER}" != "${BASE}" ]; then
|
||||
echo "::error::build/config.yml info.version (${CFG_VER}) != tag base (${BASE}). Bump via ./release ${BASE} first." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "VERSION=${NORM}" >> "${GITHUB_ENV}"
|
||||
echo "Version guard passed: wails.json=${WAILS_VER} tag=${NORM}"
|
||||
echo "Version guard passed: build/config.yml=${CFG_VER} tag=${NORM}"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -119,13 +120,13 @@ jobs:
|
||||
# The compiled CLI binary. Bump the key whenever the @version pin
|
||||
# below changes, or the old CLI will be silently reused.
|
||||
path: ~/go/bin/wails3
|
||||
key: wails3-v3.0.0-beta.20-${{ runner.os }}
|
||||
key: wails3-v3.0.0-beta.22-${{ runner.os }}
|
||||
|
||||
- name: Install Wails CLI
|
||||
if: steps.wails-cli.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.20
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.22
|
||||
|
||||
- name: Add Go bin to PATH
|
||||
run: echo "${HOME}/go/bin" >> "${GITHUB_PATH}"
|
||||
@@ -245,7 +246,38 @@ jobs:
|
||||
echo "Created new release for ${TAG}"
|
||||
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:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -255,27 +287,30 @@ jobs:
|
||||
: "${ARCHIVE:?ARCHIVE missing from packaging step}"
|
||||
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||
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')"
|
||||
if [ -z "${RID}" ]; then
|
||||
echo "::error::No release found for tag ${TAG} after create step" >&2
|
||||
exit 1
|
||||
fi
|
||||
RID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty')"
|
||||
if [ -z "${RID}" ]; then
|
||||
echo "::error::No release found for tag ${TAG} after create step" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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')"
|
||||
if [ -n "${AID}" ]; then
|
||||
curl -sS -X DELETE -H "${AUTH}" "${API}/${RID}/assets/${AID}" >/dev/null
|
||||
echo "Deleted existing asset id=${AID} (${ARCHIVE})"
|
||||
fi
|
||||
# Replace a same-named asset so re-runs stay idempotent.
|
||||
ANAME="$(basename "${ASSET}")"
|
||||
AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ANAME}" '.[] | select(.name==$n) | .id // empty')"
|
||||
if [ -n "${AID}" ]; then
|
||||
curl -sS -X DELETE -H "${AUTH}" "${API}/${RID}/assets/${AID}" >/dev/null
|
||||
echo "Deleted existing asset id=${AID} (${ANAME})"
|
||||
fi
|
||||
|
||||
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
|
||||
-F "attachment=@${ARCHIVE}" "${API}/${RID}/assets?name=${ARCHIVE}")"
|
||||
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||
if [ "${code}" -ge 400 ]; then
|
||||
echo "::error::Asset upload ${code}: ${rbody}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Uploaded ${ARCHIVE} to release id=${RID}"
|
||||
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
|
||||
-F "attachment=@${ASSET}" "${API}/${RID}/assets?name=${ANAME}")"
|
||||
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||
if [ "${code}" -ge 400 ]; then
|
||||
echo "::error::Asset upload ${code}: ${rbody}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Uploaded ${ANAME} to release id=${RID}"
|
||||
done
|
||||
|
||||
@@ -33,6 +33,9 @@ frontend/package.json.md5
|
||||
.idea
|
||||
.env
|
||||
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)
|
||||
http-client.private.env.json
|
||||
@@ -41,3 +44,5 @@ http-client.private.env.json
|
||||
*.tar
|
||||
*.tar.gz
|
||||
/AniTrack
|
||||
# Updater CI staging (bare binary + sidecars, runner-ephemeral)
|
||||
updater-dist/
|
||||
|
||||
@@ -11,22 +11,26 @@ build:
|
||||
clean:
|
||||
rm -rf build/bin/*
|
||||
|
||||
# Create a version commit (wails.json bump, committed and pushed) plus an
|
||||
# Create a version commit (version bump, committed and pushed) plus an
|
||||
# annotated version tag carrying auto-generated release notes (git-cliff),
|
||||
# and push both. The tag push triggers .gitea/workflows/release.yml, which
|
||||
# verifies wails.json matches the tag, builds via `make build`, packages
|
||||
# verifies build/config.yml matches the tag, builds via `make build`, packages
|
||||
# AniTrack-<VERSION>.tar.gz from build/, and publishes a Gitea Release
|
||||
# named AniTrack-<VERSION> with the archive attached. Notes come entirely
|
||||
# from Conventional Commits — no hand-written message required.
|
||||
#
|
||||
# The single version source is build/config.yml info.version. `make release`
|
||||
# bumps it and regenerates version.go (the compiled-in copy) from it, so the
|
||||
# two can never drift; both ride the same bump commit.
|
||||
#
|
||||
# git-cliff's --latest needs the tag to exist to scope the notes, so we
|
||||
# create a throwaway lightweight tag, generate the notes, replace it with
|
||||
# an annotated tag, then push. --cleanup=verbatim keeps the markdown "###"
|
||||
# group headers (git's default cleanup would strip lines starting with "#").
|
||||
#
|
||||
# Tags are plain versions (1.6.7) to match wails.json productVersion and the
|
||||
# existing tag history. Pre-releases are created by tagging manually with a
|
||||
# suffix (e.g. 1.6.7-rc1 on top of the bumped commit) — the workflow marks
|
||||
# Tags are plain versions (1.6.7) to match build/config.yml info.version and
|
||||
# the existing tag history. Pre-releases are created by tagging manually with
|
||||
# a suffix (e.g. 1.6.7-rc1 on top of the bumped commit) — the workflow marks
|
||||
# those as prerelease. Use ./release as a shortcut.
|
||||
#
|
||||
# Requires git-cliff: https://git-cliff.org/install
|
||||
@@ -35,18 +39,15 @@ release:
|
||||
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=1.6.7"; exit 1; }
|
||||
@echo "$(VERSION)" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "VERSION must be plain semver like 1.6.7 (no v prefix, no AniTrack- prefix, no suffix)"; exit 1; }
|
||||
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
|
||||
@command -v python3 >/dev/null 2>&1 || { echo "python3 not found — required to bump wails.json"; exit 1; }
|
||||
@command -v python3 >/dev/null 2>&1 || { echo "python3 not found — required to bump versions"; exit 1; }
|
||||
@git diff --quiet && git diff --cached --quiet || { echo "Working tree dirty — commit or stash first"; exit 1; }
|
||||
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
|
||||
@echo "Bumping wails.json to $(VERSION)..."
|
||||
@python3 -c "import json; p='wails.json'; d=json.load(open(p)); d['info']['productVersion']='$(VERSION)'; json.dump(d, open(p,'w'), indent=2); open(p,'a').write('\n')"
|
||||
# TEMP until wails.json (v2 leftover) is retired: bump build/config.yml too.
|
||||
# The regex targets only the indented info.version line, never the
|
||||
# top-level schema `version: '3'`.
|
||||
@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)"
|
||||
@git add wails.json build/config.yml
|
||||
@git commit -m "chore(release): bump version to $(VERSION)"
|
||||
@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))"
|
||||
@echo "Regenerating version.go from build/config.yml..."
|
||||
@python3 -c "import re; v=re.search(r'^ version: \"([^\"]*)\"', open('build/config.yml').read(), flags=re.M).group(1); open('version.go','w').write('package main\n\n// Code generated by \`make release\` from build/config.yml info.version.\n// DO NOT EDIT.\nconst appVersionString = \"%s\"\n' % v)"
|
||||
@git add build/config.yml version.go
|
||||
@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)..."
|
||||
@git tag "$(VERSION)" HEAD && \
|
||||
(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
|
||||
> on most distros, and the app will not start without it
|
||||
> (`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` ·
|
||||
> 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
|
||||
|
||||
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,16 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
//go:embed wails.json
|
||||
var wailsJSON string
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
app *application.App
|
||||
@@ -21,9 +16,10 @@ func NewApp(app *application.App) *App {
|
||||
return &App{app: app}
|
||||
}
|
||||
|
||||
// appVersion reads the product version from wails.json.
|
||||
// appVersion is the release version, stamped into version.go by
|
||||
// `make release` from build/config.yml.
|
||||
func appVersion() string {
|
||||
return gjson.Get(wailsJSON, "info.productVersion").String()
|
||||
return appVersionString
|
||||
}
|
||||
|
||||
// appTitle is the window title: "AniTrack <version>".
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# Wails v3 project configuration.
|
||||
# NOTE: `make release` bumps `info.version` below alongside wails.json.
|
||||
# NOTE: `info.version` below is the single version source: `make release`
|
||||
# bumps it and regenerates version.go (the compiled-in copy) from it.
|
||||
# Never touch the top-level `version: '3'` — that is the config file schema,
|
||||
# not the app version.
|
||||
version: '3'
|
||||
@@ -10,7 +11,7 @@ info:
|
||||
productIdentifier: "com.linuxhg.anitrack"
|
||||
description: "Track anime watchlists across AniList, MyAnimeList, and Simkl"
|
||||
copyright: "John O'Keefe"
|
||||
version: "1.99.0"
|
||||
version: "2.0.0"
|
||||
|
||||
# Dev mode configuration
|
||||
dev_mode:
|
||||
|
||||
@@ -1,4 +1,98 @@
|
||||
#!/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
|
||||
if [ ! -f "$HOME/.local/share/applications/AniTrack.desktop" ]; then
|
||||
|
||||
+22
-5
@@ -21,7 +21,7 @@ This document summarizes the `wailsv3` branch (Wails `v2.15.0` → `v3.0.0-beta.
|
||||
|
||||
**Build + CI:**
|
||||
- `Taskfile.yml` + `build/Taskfile.yml` (stock `common`) + `build/linux/Taskfile.yml` (stock minus `common:generate:icons` — macOS/Windows icon generation targets `build/appicon.png` which this Linux-only project doesn’t have) + `build/config.yml` (AniTrack metadata, `Version 1.6.8`). Root output stays `build/bin/AniTrack` so release packaging is untouched. `wails.json` → `v3` `frontend` block, `info.productVersion` retained.
|
||||
- `Makefile`: `wails dev/build -tags webkit2_41` → `wails3 dev -port 5173` / `wails3 build` (no tags; `v3` defaults to `GTK4/WebKitGTK 6.0`, same `2.52.x` engine generation). Added `.task/` to `.gitignore`. Removed `build/darwin/`, `build/windows/`, `frontend/package.json.md5`. `make release` bumps both `wails.json` and `build/config.yml:info.version` (TEMP until `wails.json` is retired).
|
||||
- `Makefile`: `wails dev/build -tags webkit2_41` → `wails3 dev -port 5173` / `wails3 build` (no tags; `v3` defaults to `GTK4/WebKitGTK 6.0`, same `2.52.x` engine generation). Added `.task/` to `.gitignore`. Removed `build/darwin/`, `build/windows/`, `frontend/package.json.md5`. Version flow: single source `build/config.yml:info.version`, `make release` bumps it and regenerates `version.go` (compiled-in copy) in the same commit — `wails.json` retired entirely (proof: `wails3 build` + `wails3 dev` both green without it; a first-boot `dev` failure turned out to be Vite 8 cold-start vs the app retry budget, fixed by rerunning warm, not by the file).
|
||||
- `.gitea/workflows/release.yml`: Wails CLI `wails@v2.15.0` → `wails3@v3.0.0-beta.20` (path + cache key), apt `libgtk-3-dev/libwebkit2gtk-4.1-dev` → `libgtk-4-dev/libwebkitgtk-6.0-dev`, `make build` unchanged. Go/npm/go-build/Wails-CLI caches remain valid (npm cache now resolves since `frontend/package-lock.json` is committed).
|
||||
|
||||
**Verification:** `go vet` clean, `wails3 build` green (`build/bin/AniTrack` ~13 MB), `wails3 dev` boots end-to-end (bindings regen → `build:dev` → vite on `:5173` → `Connected to frontend dev server` → `AniTrack 1.6.8` window), second instance exits 0 via the lock. `svelte-check` was already red on `main` (`wailsjs/go/models.ts` anonymous structs) and remains so — not introduced by the migration.
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
**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.
|
||||
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.
|
||||
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:** done on `wailsv3` — Svelte 4.2 → 5.57, Vite 4.5 → 8.3, `vite-plugin-svelte 2 → 7`, Tailwind 3 → 4 (CSS-first), flowbite-svelte 0.46 → 1.33, router 4 → 5. `svelte-check` went 453 errors → 0/0; stores retyped to the generated bindings models; AnimeTable rewritten dependency-free (`svelte-headless-table` peers Svelte 4 only). Deliberately held: TypeScript at 5.9 (`svelte-check` peers `^5||^6`), `@wailsio/runtime` pinned to the Go framework version (lockstep upgrades only).
|
||||
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.
|
||||
|
||||
## Branch map
|
||||
|
||||
- `main` — still `v2`, stable, ships `1.6.8` 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.
|
||||
- `main` — v3 desktop, ships `1.99.x` beta-series releases.
|
||||
- `wailsv3` — retained as an alias tracking `main` for now.
|
||||
|
||||
@@ -8,9 +8,5 @@
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="./src/main.ts" type="module"></script>
|
||||
<script
|
||||
src="./node_modules/flowbite/dist/flowbite.js"
|
||||
type="module"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1465
-2007
File diff suppressed because it is too large
Load Diff
+15
-18
@@ -11,26 +11,23 @@
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^2.4.1",
|
||||
"@tsconfig/svelte": "^4.0.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.45",
|
||||
"svelte": "^4.0.0",
|
||||
"svelte-check": "^3.4.3",
|
||||
"svelte-headless-table": "^0.18.3",
|
||||
"svelte-preprocess": "^5.0.3",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.5.5"
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"svelte": "^5.57.0",
|
||||
"svelte-check": "^4.7.6",
|
||||
"svelte-spa-router": "^5.1.1",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@wailsio/runtime": "3.0.0-beta.20",
|
||||
"flowbite": "^2.5.1",
|
||||
"flowbite-svelte": "^0.46.16",
|
||||
"moment": "^2.30.1"
|
||||
"@wailsio/runtime": "3.0.0-beta.22",
|
||||
"flowbite": "^4.0.2",
|
||||
"flowbite-svelte": "^1.33.1",
|
||||
"moment": "^2.31.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+25
-17
@@ -21,13 +21,12 @@
|
||||
import { CheckIfMALLoggedInAndSetUser } from "./helperModules/CheckIfMyAnimeListLoggedIn.svelte";
|
||||
import { CheckIfSimklLoggedInAndSetUser } from "./helperModules/CheckIsSimklLoggedIn.svelte";
|
||||
import {App} from "../bindings/AniTrack";
|
||||
import { loc } from "svelte-spa-router";
|
||||
import ErrorModal from "./helperComponents/ErrorModal.svelte";
|
||||
|
||||
onMount(async () => {
|
||||
let isAniListLoggedIn: boolean;
|
||||
let isMALLoggedIn: boolean;
|
||||
let isSimklLoggedIn: boolean;
|
||||
let isAniListLoggedIn!: boolean;
|
||||
let isMALLoggedIn!: boolean;
|
||||
let isSimklLoggedIn!: boolean;
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
malLoggedIn.subscribe((value) => (isMALLoggedIn = value));
|
||||
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
|
||||
@@ -39,26 +38,35 @@
|
||||
!isSimklLoggedIn && (await CheckIfSimklLoggedInAndSetUser());
|
||||
});
|
||||
|
||||
$: if ($loc?.location === "/" && $watchlistNeedsRefresh) {
|
||||
(async () => {
|
||||
if ($aniListLoggedIn && $aniListPrimary) {
|
||||
await CheckIfAniListLoggedInAndLoadWatchList();
|
||||
}
|
||||
if ($malLoggedIn && $malPrimary) {
|
||||
await App.GetMyAnimeList(1000).then((w) => malWatchList.set(w));
|
||||
}
|
||||
if ($simklLoggedIn && $simklPrimary) {
|
||||
await App.SimklGetUserWatchlist().then((w) => simklWatchList.set(w));
|
||||
}
|
||||
import { get } from "svelte/store";
|
||||
|
||||
watchlistNeedsRefresh.set(false);
|
||||
})();
|
||||
// Reloads all watchlists when returning home with the refresh flag set
|
||||
// (e.g. after saving changes on a detail page). Driven by the router's
|
||||
// onRouteLoaded callback: the old `$: ... router.location ...` reactive
|
||||
// statement did not reliably re-fire on navigation under Svelte 5,
|
||||
// so homecoming refreshes silently never ran.
|
||||
async function refreshHomeWatchlists(location: string): Promise<void> {
|
||||
if (location !== "/" || !get(watchlistNeedsRefresh)) return;
|
||||
if (get(aniListLoggedIn) && get(aniListPrimary)) {
|
||||
await CheckIfAniListLoggedInAndLoadWatchList();
|
||||
}
|
||||
if (get(malLoggedIn) && get(malPrimary)) {
|
||||
await App.GetMyAnimeList(1000).then((w) => malWatchList.set(w));
|
||||
}
|
||||
if (get(simklLoggedIn) && get(simklPrimary)) {
|
||||
await App.SimklGetUserWatchlist().then((w) => simklWatchList.set(w));
|
||||
}
|
||||
|
||||
watchlistNeedsRefresh.set(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Header />
|
||||
<ErrorModal />
|
||||
<Router
|
||||
onRouteLoaded={(detail) => {
|
||||
void refreshHomeWatchlists(detail.location);
|
||||
}}
|
||||
routes={{
|
||||
"/": Home,
|
||||
"/anime/:id": wrap({
|
||||
|
||||
@@ -11,22 +11,22 @@
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import WebsiteLink from "./WebsiteLink.svelte";
|
||||
import type { AniListGetSingleAnime } from "../anilist/types/AniListCurrentUserWatchListType";
|
||||
import type {
|
||||
AniListGetSingleAnime,
|
||||
AniListUpdateVariables,
|
||||
MALAnime,
|
||||
MalListStatus,
|
||||
MALUploadStatus,
|
||||
SimklAnime,
|
||||
} from "../../bindings/AniTrack/models";
|
||||
import Rating from "./Rating.svelte";
|
||||
import {
|
||||
convertAniListDateToString,
|
||||
convertAniListDateToDate,
|
||||
} from "../helperFunctions/convertAniListDateIn";
|
||||
import AnimeTable from "./AnimeTable.svelte";
|
||||
import type {
|
||||
MALAnime,
|
||||
MalListStatus,
|
||||
MALUploadStatus,
|
||||
} from "../mal/types/MALTypes";
|
||||
import type { SimklAnime } from "../simkl/types/simklTypes";
|
||||
import { writable } from "svelte/store";
|
||||
import type { StatusOption, StatusOptions } from "../helperTypes/StatusTypes";
|
||||
import type { AniListUpdateVariables } from "../anilist/types/AniListTypes";
|
||||
import { convertDateToAniList } from "../helperFunctions/convertDateToAniList";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import { AddAnimeServiceToTable } from "../helperModules/AddAnimeServiceToTable.svelte";
|
||||
@@ -35,12 +35,12 @@
|
||||
import { Badge, Tooltip } from "flowbite-svelte";
|
||||
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/;
|
||||
|
||||
let isAniListLoggedIn: boolean;
|
||||
let isMalLoggedIn: boolean;
|
||||
let isSimklLoggedIn: boolean;
|
||||
let currentAniListAnime: AniListGetSingleAnime;
|
||||
let currentMalAnime: MALAnime;
|
||||
let currentSimklAnime: SimklAnime;
|
||||
let isAniListLoggedIn!: boolean;
|
||||
let isMalLoggedIn!: boolean;
|
||||
let isSimklLoggedIn!: boolean;
|
||||
let currentAniListAnime!: AniListGetSingleAnime;
|
||||
let currentMalAnime!: MALAnime;
|
||||
let currentSimklAnime!: SimklAnime;
|
||||
let submitting = writable(false);
|
||||
let isSubmitting: boolean;
|
||||
let submitSuccess = writable(false);
|
||||
@@ -103,11 +103,15 @@
|
||||
let finishDate = "";
|
||||
if (currentMalAnime.my_list_status.start_date !== "") {
|
||||
const startArray = re.exec(currentMalAnime.my_list_status.start_date);
|
||||
startDate = `${startArray[2]}-${startArray[3]}-${startArray[1]}`;
|
||||
if (startArray) {
|
||||
startDate = `${startArray[2]}-${startArray[3]}-${startArray[1]}`;
|
||||
}
|
||||
}
|
||||
if (currentMalAnime.my_list_status.finish_date !== "") {
|
||||
const finishArray = re.exec(currentMalAnime.my_list_status.finish_date);
|
||||
finishDate = `${finishArray[2]}-${finishArray[3]}-${finishArray[1]}`;
|
||||
if (finishArray) {
|
||||
finishDate = `${finishArray[2]}-${finishArray[3]}-${finishArray[1]}`;
|
||||
}
|
||||
}
|
||||
AddAnimeServiceToTable({
|
||||
id: `m-${currentMalAnime.id}`,
|
||||
@@ -165,7 +169,9 @@
|
||||
for (let field of formData) {
|
||||
const [key, value] = field;
|
||||
if (key === "rating") {
|
||||
submitData.rating = Number(value) * 2;
|
||||
// Form value is already 0-10 (StarInput works in backend units).
|
||||
// The old * 2 belonged to the retired 0-5 slider scale.
|
||||
submitData.rating = Number(value);
|
||||
continue;
|
||||
}
|
||||
if (key === "episodes") {
|
||||
@@ -180,7 +186,11 @@
|
||||
submitData.status = startingAnilistStatusOption;
|
||||
continue;
|
||||
}
|
||||
submitData[key] = value;
|
||||
// The only remaining form field is "notes" (string); anything else
|
||||
// is ignored rather than assigned into a mistyped slot.
|
||||
if (key === "notes" && typeof value === "string") {
|
||||
submitData.notes = value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -271,13 +281,17 @@
|
||||
const startArray = re.exec(
|
||||
currentMalAnime.my_list_status.start_date,
|
||||
);
|
||||
startDate = `${startArray[2]}-${startArray[3]}-${startArray[1]}`;
|
||||
if (startArray) {
|
||||
startDate = `${startArray[2]}-${startArray[3]}-${startArray[1]}`;
|
||||
}
|
||||
}
|
||||
if (currentMalAnime.my_list_status.finish_date !== "") {
|
||||
const finishArray = re.exec(
|
||||
currentMalAnime.my_list_status.finish_date,
|
||||
);
|
||||
finishDate = `${finishArray[2]}-${finishArray[3]}-${finishArray[1]}`;
|
||||
if (finishArray) {
|
||||
finishDate = `${finishArray[2]}-${finishArray[3]}-${finishArray[1]}`;
|
||||
}
|
||||
}
|
||||
AddAnimeServiceToTable({
|
||||
id: `m-${currentMalAnime.id}`,
|
||||
@@ -503,7 +517,7 @@
|
||||
{title}
|
||||
</h1>
|
||||
<div class="grid grid-cols-1 md:grid-cols-10 grid-flow-col gap-4">
|
||||
<div class="md:col-span-2 space-y-3">
|
||||
<div class="md:col-span-2 space-y-3 flex flex-col items-center">
|
||||
<img
|
||||
class="rounded-lg"
|
||||
src={currentAniListAnime.data.MediaList.media.coverImage.large}
|
||||
@@ -525,6 +539,7 @@
|
||||
<div class="relative flex items-center max-w-[8rem]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Decrease episode progress"
|
||||
id="decrement-button"
|
||||
data-input-counter-decrement="quantity-input"
|
||||
on:click={() => {
|
||||
@@ -583,6 +598,7 @@
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Increase episode progress"
|
||||
id="increment-button"
|
||||
data-input-counter-increment="quantity-input"
|
||||
on:click={() => {
|
||||
@@ -682,7 +698,7 @@
|
||||
>
|
||||
<Datepicker
|
||||
bind:value={startedAtDate}
|
||||
color="slate"
|
||||
color="gray"
|
||||
dateFormat={{
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
@@ -699,7 +715,7 @@
|
||||
>
|
||||
<Datepicker
|
||||
bind:value={completedAtDate}
|
||||
color="slate"
|
||||
color="gray"
|
||||
dateFormat={{
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
|
||||
@@ -1,118 +1,95 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
createRender,
|
||||
createTable,
|
||||
Render,
|
||||
Subscribe,
|
||||
} from "svelte-headless-table";
|
||||
// @ts-ignore
|
||||
import { addSortBy } from "svelte-headless-table/plugins";
|
||||
import { tableItems } from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import type { TableItem } from "../helperTypes/TableTypes";
|
||||
import WebsiteLink from "./WebsiteLink.svelte";
|
||||
|
||||
//when adding sort here is code { sort: addSortBy() }
|
||||
const table = createTable(tableItems, { sort: addSortBy() });
|
||||
type SortKey = keyof TableItem;
|
||||
type SortOrder = "asc" | "desc" | undefined;
|
||||
|
||||
const columns = table.createColumns([
|
||||
table.column({
|
||||
header: "Service Id",
|
||||
cell: ({ value }) => createRender(WebsiteLink, { id: value }),
|
||||
accessor: "id",
|
||||
}),
|
||||
table.column({
|
||||
header: "Anime Title",
|
||||
accessor: "title",
|
||||
}),
|
||||
table.column({
|
||||
header: "Service",
|
||||
accessor: "service",
|
||||
}),
|
||||
table.column({
|
||||
header: "Episode Progress",
|
||||
accessor: "progress",
|
||||
}),
|
||||
table.column({
|
||||
header: "Status",
|
||||
accessor: "status",
|
||||
}),
|
||||
table.column({
|
||||
header: "Started At",
|
||||
accessor: "startedAt",
|
||||
}),
|
||||
table.column({
|
||||
header: "Completed At",
|
||||
accessor: "completedAt",
|
||||
}),
|
||||
table.column({
|
||||
header: "Rating",
|
||||
accessor: "score",
|
||||
}),
|
||||
table.column({
|
||||
header: "Repeat",
|
||||
accessor: "repeat",
|
||||
}),
|
||||
table.column({
|
||||
header: "Notes",
|
||||
accessor: "notes",
|
||||
}),
|
||||
]);
|
||||
const columns: { key: SortKey; header: string }[] = [
|
||||
{ key: "id", header: "Service Id" },
|
||||
{ key: "title", header: "Anime Title" },
|
||||
{ key: "service", header: "Service" },
|
||||
{ key: "progress", header: "Episode Progress" },
|
||||
{ key: "status", header: "Status" },
|
||||
{ key: "startedAt", header: "Started At" },
|
||||
{ key: "completedAt", header: "Completed At" },
|
||||
{ key: "score", header: "Rating" },
|
||||
{ key: "repeat", header: "Repeat" },
|
||||
{ key: "notes", header: "Notes" },
|
||||
];
|
||||
|
||||
//add pluginStates when add sort back
|
||||
const { headerRows, rows, tableAttrs, tableBodyAttrs } =
|
||||
table.createViewModel(columns);
|
||||
let sortKey = $state<SortKey | undefined>(undefined);
|
||||
let sortOrder = $state<SortOrder>(undefined);
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey !== key) {
|
||||
sortKey = key;
|
||||
sortOrder = "asc";
|
||||
} else if (sortOrder === "asc") {
|
||||
sortOrder = "desc";
|
||||
} else if (sortOrder === "desc") {
|
||||
sortKey = undefined;
|
||||
sortOrder = undefined;
|
||||
} else {
|
||||
sortOrder = "asc";
|
||||
}
|
||||
}
|
||||
|
||||
function compareItems(a: TableItem, b: TableItem): number {
|
||||
if (sortKey === undefined || sortOrder === undefined) return 0;
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
let result: number;
|
||||
if (typeof av === "number" && typeof bv === "number") {
|
||||
result = av - bv;
|
||||
} else {
|
||||
result = String(av ?? "").localeCompare(String(bv ?? ""));
|
||||
}
|
||||
return sortOrder === "asc" ? result : -result;
|
||||
}
|
||||
|
||||
let sortedRows = $derived(
|
||||
sortKey === undefined ? $tableItems : [...$tableItems].sort(compareItems),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="relative overflow-x-auto rounded-lg mb-5">
|
||||
<table
|
||||
class="w-full text-sm text-left rtl:text-right text-gray-400"
|
||||
{...$tableAttrs}
|
||||
>
|
||||
<table class="w-full text-sm text-left rtl:text-right text-gray-400">
|
||||
<thead class="text-xs uppercase bg-gray-700 text-gray-400">
|
||||
{#each $headerRows as headerRow (headerRow.id)}
|
||||
<Subscribe attrs={headerRow.attrs()} let:attrs>
|
||||
<tr {...attrs}>
|
||||
{#each headerRow.cells as cell (cell.id)}
|
||||
<Subscribe
|
||||
attrs={cell.attrs()}
|
||||
let:attrs
|
||||
props={cell.props()}
|
||||
let:props
|
||||
>
|
||||
<th
|
||||
{...attrs}
|
||||
on:click={props.sort.toggle}
|
||||
class:sorted={props.sort.order !==
|
||||
undefined}
|
||||
class="px-6 py-3"
|
||||
>
|
||||
<div>
|
||||
<Render of={cell.render()} />
|
||||
{#if props.sort.order === "asc"}
|
||||
⬇️
|
||||
{:else if props.sort.order === "desc"}
|
||||
⬆️
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
</Subscribe>
|
||||
{/each}
|
||||
</tr>
|
||||
</Subscribe>
|
||||
{/each}
|
||||
<tr>
|
||||
{#each columns as column (column.key)}
|
||||
<th
|
||||
onclick={() => toggleSort(column.key)}
|
||||
class:sorted={sortKey === column.key &&
|
||||
sortOrder !== undefined}
|
||||
class="px-6 py-3 cursor-pointer"
|
||||
>
|
||||
<div>
|
||||
{column.header}
|
||||
{#if sortKey === column.key && sortOrder === "asc"}
|
||||
⬇️
|
||||
{:else if sortKey === column.key && sortOrder === "desc"}
|
||||
⬆️
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody {...$tableBodyAttrs}>
|
||||
{#each $rows as row (row.id)}
|
||||
<Subscribe attrs={row.attrs()} let:attrs>
|
||||
<tr {...attrs} class="bg-gray-800 border-gray-700">
|
||||
{#each row.cells as cell (cell.id)}
|
||||
<Subscribe attrs={cell.attrs()} let:attrs>
|
||||
<td {...attrs} class="px-6 py-4">
|
||||
<Render of={cell.render()} />
|
||||
</td>
|
||||
</Subscribe>
|
||||
{/each}
|
||||
</tr>
|
||||
</Subscribe>
|
||||
<tbody>
|
||||
{#each sortedRows as row (row.id + row.service)}
|
||||
<tr class="bg-gray-800 border-gray-700">
|
||||
{#each columns as column (column.key)}
|
||||
<td class="px-6 py-4">
|
||||
{#if column.key === "id"}
|
||||
<WebsiteLink id={row.id} />
|
||||
{:else}
|
||||
{row[column.key]}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "flowbite-svelte";
|
||||
import type { AniListUser } from "../anilist/types/AniListTypes";
|
||||
import type { AniListUser, MyAnimeListUser, SimklUser } from "../../bindings/AniTrack/models";
|
||||
import {
|
||||
aniListLoggedIn,
|
||||
aniListUser,
|
||||
@@ -17,16 +17,14 @@
|
||||
serviceLoggingIn,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import {Application} from "@wailsio/runtime";
|
||||
import type { MyAnimeListUser } from "../mal/types/MALTypes";
|
||||
import type { SimklUser } from "../simkl/types/simklTypes";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
|
||||
let currentAniListUser: AniListUser;
|
||||
let currentMALUser: MyAnimeListUser;
|
||||
let currentSimklUser: SimklUser;
|
||||
let isAniListLoggedIn: boolean;
|
||||
let isSimklLoggedIn: boolean;
|
||||
let isMALLoggedIn: boolean;
|
||||
let isAniListLoggedIn!: boolean;
|
||||
let isSimklLoggedIn!: boolean;
|
||||
let isMALLoggedIn!: boolean;
|
||||
let loggingIn: string[] = [];
|
||||
|
||||
aniListUser.subscribe((value) => (currentAniListUser = value));
|
||||
@@ -38,7 +36,8 @@
|
||||
serviceLoggingIn.subscribe((value) => (loggingIn = value));
|
||||
|
||||
function dropdownUser(): void {
|
||||
let dropdown = document.querySelector("#userDropdown");
|
||||
const dropdown = document.querySelector("#userDropdown");
|
||||
if (!dropdown) return;
|
||||
dropdown.classList.toggle("hidden");
|
||||
|
||||
if (!dropdown.classList.contains("hidden")) {
|
||||
@@ -47,8 +46,9 @@
|
||||
}
|
||||
|
||||
function clickOutside(event: Event): void {
|
||||
let dropdown = document.querySelector("#userDropdown");
|
||||
let toggleBtn = document.querySelector("#userDropdownButton");
|
||||
const dropdown = document.querySelector("#userDropdown");
|
||||
const toggleBtn = document.querySelector("#userDropdownButton");
|
||||
if (!dropdown || !toggleBtn) return;
|
||||
|
||||
if (
|
||||
!dropdown.contains(event.target as Node) &&
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { Button } from "flowbite-svelte";
|
||||
import type { ButtonProps } from "flowbite-svelte";
|
||||
|
||||
export let value: Date | null = null;
|
||||
export let defaultDate: Date | null = null;
|
||||
@@ -19,7 +20,7 @@
|
||||
export let disabled: boolean = false;
|
||||
export let required: boolean = false;
|
||||
export let inputClass: string = "";
|
||||
export let color: Button["color"] = "primary";
|
||||
export let color: ButtonProps["color"] = "primary";
|
||||
export let inline: boolean = false;
|
||||
export let autohide: boolean = true;
|
||||
export let showActionButtons: boolean = false;
|
||||
@@ -47,7 +48,7 @@
|
||||
});
|
||||
|
||||
// Color handling functions
|
||||
function getFocusRingClass(color: Button["color"]): string {
|
||||
function getFocusRingClass(color: ButtonProps["color"]): string {
|
||||
switch (color) {
|
||||
case "primary":
|
||||
return "focus:ring-2 focus:ring-primary-400";
|
||||
@@ -61,14 +62,14 @@
|
||||
return "focus:ring-2 focus:ring-yellow-400";
|
||||
case "purple":
|
||||
return "focus:ring-2 focus:ring-purple-400";
|
||||
case "slate":
|
||||
return "focus:ring-2 focus:ring-slate-400";
|
||||
case "gray":
|
||||
return "focus:ring-2 focus:ring-gray-400";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getRangeBackgroundClass(color: Button["color"]): string {
|
||||
function getRangeBackgroundClass(color: ButtonProps["color"]): string {
|
||||
switch (color) {
|
||||
case "primary":
|
||||
return "bg-primary-900";
|
||||
@@ -82,8 +83,8 @@
|
||||
return "bg-yellow-900";
|
||||
case "purple":
|
||||
return "bg-purple-900";
|
||||
case "slate":
|
||||
return "bg-slate-900";
|
||||
case "gray":
|
||||
return "bg-gray-900";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -352,7 +353,7 @@
|
||||
{/if}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Button
|
||||
on:click={() => changeMonth(-1)}
|
||||
onclick={() => changeMonth(-1)}
|
||||
{color}
|
||||
size="sm"
|
||||
aria-label="Previous month"
|
||||
@@ -382,7 +383,7 @@
|
||||
})}
|
||||
</h3>
|
||||
<Button
|
||||
on:click={() => changeMonth(1)}
|
||||
onclick={() => changeMonth(1)}
|
||||
{color}
|
||||
size="sm"
|
||||
aria-label="Next month"
|
||||
@@ -424,8 +425,8 @@
|
||||
: ''} {isInRange(day)
|
||||
? getRangeBackgroundClass(color)
|
||||
: ''}"
|
||||
on:click={() => handleDaySelect(day)}
|
||||
on:keydown={handleCalendarKeydown}
|
||||
onclick={() => handleDaySelect(day)}
|
||||
onkeydown={handleCalendarKeydown}
|
||||
aria-label={day.toLocaleDateString(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
@@ -441,13 +442,13 @@
|
||||
</div>
|
||||
{#if showActionButtons}
|
||||
<div class="mt-4 flex justify-between">
|
||||
<Button on:click={handleToday} {color} size="sm"
|
||||
<Button onclick={handleToday} {color} size="sm"
|
||||
>Today</Button
|
||||
>
|
||||
<Button on:click={handleClear} color="red" size="sm"
|
||||
<Button onclick={handleClear} color="red" size="sm"
|
||||
>Clear</Button
|
||||
>
|
||||
<Button on:click={handleApply} {color} size="sm"
|
||||
<Button onclick={handleApply} {color} size="sm"
|
||||
>Apply</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -61,13 +61,15 @@
|
||||
dismiss this message to continue with limited functionality.
|
||||
</p>
|
||||
</div>
|
||||
<div slot="footer" class="flex gap-3 justify-end">
|
||||
{#if $apiError.canRetry}
|
||||
<Button on:click={handleRetry} class="bg-blue-600 hover:bg-blue-700">
|
||||
Retry Connection
|
||||
</Button>
|
||||
{/if}
|
||||
<Button on:click={handleDismiss} color="alternative">Dismiss</Button>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<div class="flex gap-3 justify-end">
|
||||
{#if $apiError.canRetry}
|
||||
<Button onclick={handleRetry} class="bg-blue-600 hover:bg-blue-700">
|
||||
Retry Connection
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={handleDismiss} color="alternative">Dismiss</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
import logo from "../assets/images/AniTrackLogo.svg";
|
||||
import { link } from "svelte-spa-router";
|
||||
|
||||
let isAniListLoggedIn: boolean;
|
||||
let isSimklLoggedIn: boolean;
|
||||
let isMALLoggedIn: boolean;
|
||||
let isAniListLoggedIn!: boolean;
|
||||
let isSimklLoggedIn!: boolean;
|
||||
let isMALLoggedIn!: boolean;
|
||||
let loggingIn: string[] = [];
|
||||
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
@@ -34,19 +34,20 @@
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center min-[950px]:order-2 space-x-3 min-[950px]:space-x-0 rtl:space-x-reverse"
|
||||
class="flex items-center sm:order-2 space-x-3 sm:space-x-0 rtl:space-x-reverse"
|
||||
>
|
||||
<div class="min-[950px]:block min-[950px]:mr-4">
|
||||
<div class="hidden sm:block sm:mr-4">
|
||||
<Search />
|
||||
</div>
|
||||
<AvatarMenu />
|
||||
<button
|
||||
on:click={() => {
|
||||
let menu = document.querySelector("#navbar-user");
|
||||
const menu = document.querySelector("#navbar-user");
|
||||
if (!menu) return;
|
||||
menu.classList.toggle("hidden");
|
||||
}}
|
||||
type="button"
|
||||
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm rounded-lg min-[950px]:hidden focus:outline-none focus:ring-2 text-gray-400 hover:bg-gray-700 focus:ring-gray-600"
|
||||
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm rounded-lg sm:hidden focus:outline-none focus:ring-2 text-gray-400 hover:bg-gray-700 focus:ring-gray-600"
|
||||
aria-controls="navbar-user"
|
||||
aria-expanded="false"
|
||||
>
|
||||
@@ -69,11 +70,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="hidden items-center justify-between w-full pb-4 min-[950px]:pb-0 min-[950px]:flex min-[950px]:w-auto min-[950px]:order-1 border border-gray-700 min-[950px]:border-0 bg-gray-800 min-[950px]:bg-transparent rounded-lg"
|
||||
class="hidden items-center justify-between w-full pb-4 sm:pb-0 sm:flex sm:w-auto sm:order-1 border border-gray-700 sm:border-0 bg-gray-800 sm:bg-transparent rounded-lg"
|
||||
id="navbar-user"
|
||||
>
|
||||
<ul
|
||||
class="flex flex-col font-medium pb-6 min-[950px]:p-0 mt-4 min-[950px]:space-x-8 rtl:space-x-reverse min-[950px]:flex-row min-[950px]:mt-0"
|
||||
class="flex flex-col font-medium pb-6 sm:p-0 mt-4 sm:space-x-8 rtl:space-x-reverse sm:flex-row sm:mt-0"
|
||||
>
|
||||
<li>
|
||||
{#if !isAniListLoggedIn}
|
||||
@@ -125,7 +126,7 @@
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex justify-center min-[950px]:hidden">
|
||||
<div class="flex justify-center sm:hidden">
|
||||
<Search />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
watchListPage,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
|
||||
import type { AniListCurrentUserWatchList } from "../anilist/types/AniListCurrentUserWatchListType";
|
||||
import type { AniListCurrentUserWatchList } from "../../bindings/AniTrack/models";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
|
||||
let aniListWatchListLoaded: AniListCurrentUserWatchList;
|
||||
let page: number;
|
||||
let perPage: number;
|
||||
let sort: string;
|
||||
let sort!: string;
|
||||
|
||||
watchListPage.subscribe((value) => (page = value));
|
||||
animePerPage.subscribe((value) => (perPage = value));
|
||||
@@ -43,14 +43,14 @@
|
||||
function changeCountPerPage(
|
||||
e: Event & { currentTarget: HTMLSelectElement },
|
||||
): void {
|
||||
App.GetAniListUserWatchingList(1, Number(e.currentTarget.value), sort).then(
|
||||
(result) => {
|
||||
animePerPage.set(Number(e.currentTarget.value));
|
||||
watchListPage.set(1);
|
||||
aniListWatchlist.set(result);
|
||||
aniListLoggedIn.set(true);
|
||||
},
|
||||
);
|
||||
// Read synchronously: Svelte 5 nulls currentTarget after dispatch.
|
||||
const count = Number(e.currentTarget.value);
|
||||
App.GetAniListUserWatchingList(1, count, sort).then((result) => {
|
||||
animePerPage.set(count);
|
||||
watchListPage.set(1);
|
||||
aniListWatchlist.set(result);
|
||||
aniListLoggedIn.set(true);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
<div class="relative flex items-center max-w-[11rem]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous page"
|
||||
id="decrement-button"
|
||||
on:click={() => ChangeWatchListPage(page - 1)}
|
||||
class={page <= 1
|
||||
@@ -191,6 +192,7 @@
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next page"
|
||||
id="increment-button"
|
||||
on:click={() => ChangeWatchListPage(page + 1)}
|
||||
class={page >= aniListWatchListLoaded.data.Page.pageInfo.lastPage
|
||||
|
||||
@@ -1,30 +1,9 @@
|
||||
<script lang="ts">
|
||||
import StarRatting from "../star-rating/Stars.svelte";
|
||||
import StarInput from "../star-rating/StarInput.svelte";
|
||||
|
||||
export let score
|
||||
let { score = $bindable(0) }: { score: number } = $props();
|
||||
|
||||
let config = {
|
||||
readOnly: false,
|
||||
countStars: 5,
|
||||
range: {
|
||||
min: 0,
|
||||
max: 5,
|
||||
step: 0.5
|
||||
},
|
||||
score: score / 2,
|
||||
showScore: false,
|
||||
name: "rating",
|
||||
scoreFormat: function(){ return `(${this.score.toFixed(0)}/${this.countStars})` },
|
||||
starConfig: {
|
||||
size: 32,
|
||||
fillColor: '#F9ED4F',
|
||||
strokeColor: "#e2c714",
|
||||
unfilledColor: '#FFF',
|
||||
strokeUnfilledColor: '#000'
|
||||
}
|
||||
}
|
||||
|
||||
const ratingInWords = {
|
||||
const ratingInWords: Record<number, string> = {
|
||||
0: "Not Reviewed",
|
||||
1: "Appalling",
|
||||
2: "Horrible",
|
||||
@@ -37,14 +16,10 @@
|
||||
9: "Great",
|
||||
10: "Masterpiece",
|
||||
}
|
||||
|
||||
const changeRating = (e: any) => {
|
||||
score = e.target.valueAsNumber * 2
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<StarRatting bind:config on:change={changeRating}/>
|
||||
<p>Rating: {config.score * 2}</p>
|
||||
<p>{ratingInWords[config.score * 2]}</p>
|
||||
<StarInput bind:value={score} min={0} max={10} step={1} name="rating" />
|
||||
<p>Rating: {score}</p>
|
||||
<p>{ratingInWords[score]}</p>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
{ value: MediaListSort.MediaPopularityDesc, name: "Media Popularity Desc" },
|
||||
];
|
||||
|
||||
let sort: string;
|
||||
let sort!: string;
|
||||
aniListSort.subscribe((value) => (sort = value));
|
||||
console.log(sort);
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
loading,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import type { AniListCurrentUserWatchList } from "../anilist/types/AniListCurrentUserWatchListType";
|
||||
import type { AniListCurrentUserWatchList } from "../../bindings/AniTrack/models";
|
||||
import { Rating } from "flowbite-svelte";
|
||||
import loader from "../helperFunctions/loader";
|
||||
import { CheckIfAniListLoggedInAndLoadWatchList } from "../helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
|
||||
import Sort from "../helperComponents/Sort.svelte";
|
||||
import RefreshWatchListButton from "./RefreshWatchListButton.svelte";
|
||||
|
||||
let isAniListLoggedIn: boolean;
|
||||
let isAniListLoggedIn!: boolean;
|
||||
let aniListWatchListLoaded: AniListCurrentUserWatchList;
|
||||
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
isAniList = id.includes("a-");
|
||||
isMAL = id.includes("m-");
|
||||
isSimkl = id.includes("s-");
|
||||
if (isAniList || isMAL || isSimkl) newId = id.match(re)[1];
|
||||
if (isAniList || isMAL || isSimkl) newId = id.match(re)![1];
|
||||
else newId = id;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {AniListGetSingleAnime} from "../anilist/types/AniListCurrentUserWatchListType";
|
||||
import type {AniListGetSingleAnime} from "../../bindings/AniTrack/models";
|
||||
|
||||
export const AniListGetSingleAnimeDefaultData: AniListGetSingleAnime = {
|
||||
data: {
|
||||
@@ -10,23 +10,57 @@ export const AniListGetSingleAnimeDefaultData: AniListGetSingleAnime = {
|
||||
id: 0,
|
||||
idMal: 0,
|
||||
title: {
|
||||
userPreferred: "",
|
||||
romaji: "",
|
||||
english: "",
|
||||
native: "",
|
||||
},
|
||||
description: "",
|
||||
coverImage: {
|
||||
ExtraLarge: "",
|
||||
large: "",
|
||||
Medium: "",
|
||||
Color: "",
|
||||
},
|
||||
BannerImage: "",
|
||||
Format: "",
|
||||
season: "",
|
||||
seasonYear: 0,
|
||||
status: "",
|
||||
episodes: 0,
|
||||
Duration: 0,
|
||||
CountryOfOrigin: "",
|
||||
Source: "",
|
||||
Synonyms: null,
|
||||
AverageScore: 0,
|
||||
MeanScore: 0,
|
||||
Popularity: 0,
|
||||
Trending: 0,
|
||||
Favourites: 0,
|
||||
relations: {
|
||||
nodes: null,
|
||||
},
|
||||
startDate: {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
},
|
||||
endDate: {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
},
|
||||
nextAiringEpisode: {
|
||||
airingAt: 0,
|
||||
timeUntilAiring: 0,
|
||||
episode: 0,
|
||||
}
|
||||
},
|
||||
airingSchedule: {
|
||||
nodes: null,
|
||||
},
|
||||
genres: [],
|
||||
tags: [],
|
||||
isAdult: false,
|
||||
},
|
||||
status: "",
|
||||
startedAt: {
|
||||
|
||||
@@ -13,7 +13,7 @@ const convertAniListDateToString = (date: {
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
const newISODate = new Date(date.year, date.month - 1, date.day);
|
||||
const newISODate = new Date(date.year, date.month! - 1, date.day);
|
||||
const newMoment = moment(newISODate);
|
||||
return newMoment.format("MM-DD-YYYY");
|
||||
};
|
||||
@@ -31,7 +31,7 @@ const convertAniListDateToDate = (date: {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return new Date(date.year, date.month - 1, date.day);
|
||||
return new Date(date.year, date.month! - 1, date.day);
|
||||
};
|
||||
|
||||
export { convertAniListDateToString, convertAniListDateToDate };
|
||||
|
||||
@@ -14,6 +14,13 @@ const convertDateStringToAniList = (date: string): AnilistDate => {
|
||||
}
|
||||
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/;
|
||||
const newDate = re.exec(date);
|
||||
if (newDate === null) {
|
||||
return {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
year: Number(newDate[1]),
|
||||
month: Number(newDate[2]),
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { mount, unmount } from 'svelte';
|
||||
import Spinner from '../helperComponents/Spinner.svelte';
|
||||
|
||||
export default (node: any, loading: any) => {
|
||||
let Spin: any
|
||||
loading.subscribe((loading: any) => {
|
||||
if(loading){
|
||||
Spin = new Spinner({
|
||||
let app: any;
|
||||
const unsubscribe = loading.subscribe((isLoading: any) => {
|
||||
if (isLoading && !app) {
|
||||
app = mount(Spinner, {
|
||||
target: node,
|
||||
intro: true
|
||||
})
|
||||
} else {
|
||||
if(Spin){
|
||||
Spin?.$destroy?.()
|
||||
Spin = undefined;
|
||||
});
|
||||
} else if (!isLoading && app) {
|
||||
const current = app;
|
||||
app = undefined;
|
||||
// Teardown must never break the caller.
|
||||
void unmount(current).catch(() => {});
|
||||
}
|
||||
});
|
||||
return {
|
||||
destroy() {
|
||||
unsubscribe();
|
||||
if (app) {
|
||||
const current = app;
|
||||
app = undefined;
|
||||
void unmount(current).catch(() => {});
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
import { tableItems } from "./GlobalVariablesAndHelperFunctions.svelte"
|
||||
|
||||
export function AddAnimeServiceToTable(animeItem: TableItem) {
|
||||
// Always return a NEW array: in-place mutation with the same
|
||||
// reference does not reliably invalidate $derived consumers
|
||||
// under Svelte 5, which left the table stale after submits.
|
||||
tableItems.update((table) => {
|
||||
if (table.length === 0) {
|
||||
table.push(animeItem)
|
||||
} else {
|
||||
for (const [index, tableItem] of table.entries()) {
|
||||
if(tableItem.service === animeItem.service) {
|
||||
table[index] = animeItem
|
||||
return table
|
||||
}
|
||||
}
|
||||
table.push(animeItem)
|
||||
const index = table.findIndex(
|
||||
(tableItem) => tableItem.service === animeItem.service,
|
||||
);
|
||||
if (index === -1) {
|
||||
return [...table, animeItem];
|
||||
}
|
||||
return table
|
||||
return table.map((tableItem, i) =>
|
||||
i === index ? animeItem : tableItem,
|
||||
);
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -16,7 +16,7 @@
|
||||
let isAniListPrimary: boolean;
|
||||
let page: number;
|
||||
let perPage: number;
|
||||
let sort: string;
|
||||
let sort!: string;
|
||||
|
||||
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
||||
watchListPage.subscribe((value) => (page = value));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" context="module">
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import {malUser, malPrimary, malWatchList, malLoggedIn, serviceLoggingIn} from "./GlobalVariablesAndHelperFunctions.svelte"
|
||||
import type { MyAnimeListUser } from "../mal/types/MALTypes";
|
||||
import type { MyAnimeListUser } from "../../bindings/AniTrack/models";
|
||||
|
||||
let isMalPrimary: boolean
|
||||
malPrimary.subscribe(value => isMalPrimary = value)
|
||||
|
||||
@@ -3,22 +3,16 @@
|
||||
import type {
|
||||
AniListCurrentUserWatchList,
|
||||
AniListGetSingleAnime,
|
||||
} from "../anilist/types/AniListCurrentUserWatchListType.js";
|
||||
import { writable } from "svelte/store";
|
||||
import type {
|
||||
SimklAnime,
|
||||
SimklUser,
|
||||
SimklWatchList,
|
||||
} from "../simkl/types/simklTypes";
|
||||
import {
|
||||
type AniListUser,
|
||||
MediaListSort,
|
||||
} from "../anilist/types/AniListTypes";
|
||||
import type {
|
||||
AniListUser,
|
||||
MALAnime,
|
||||
MALWatchlist,
|
||||
MyAnimeListUser,
|
||||
} from "../mal/types/MALTypes";
|
||||
SimklAnime,
|
||||
SimklUser,
|
||||
SimklWatchListType,
|
||||
} from "../../bindings/AniTrack/models";
|
||||
import { writable } from "svelte/store";
|
||||
import { MediaListSort } from "../anilist/types/AniListTypes";
|
||||
import type { TableItems } from "../helperTypes/TableTypes";
|
||||
import { AniListGetSingleAnimeDefaultData } from "../helperDefaults/AniListGetSingleAnime";
|
||||
|
||||
@@ -28,7 +22,7 @@
|
||||
export let simklLoggedIn = writable(false);
|
||||
export let malLoggedIn = writable(false);
|
||||
export const serviceLoggingIn = writable([] as string[]);
|
||||
export let simklWatchList = writable({} as SimklWatchList);
|
||||
export let simklWatchList = writable({} as SimklWatchListType);
|
||||
export let aniListPrimary = writable(true);
|
||||
export let simklPrimary = writable(false);
|
||||
export let malPrimary = writable(false);
|
||||
@@ -50,12 +44,12 @@
|
||||
let isAniListPrimary: boolean;
|
||||
let page: number;
|
||||
let perPage: number;
|
||||
let sort: string;
|
||||
let sort!: string;
|
||||
let aniWatchlist: AniListCurrentUserWatchList;
|
||||
let currentAniListAnime: AniListGetSingleAnime;
|
||||
let currentAniListAnime!: AniListGetSingleAnime;
|
||||
|
||||
let isMalLoggedIn: boolean;
|
||||
let isSimklLoggedIn: boolean;
|
||||
let isMalLoggedIn!: boolean;
|
||||
let isSimklLoggedIn!: boolean;
|
||||
|
||||
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
||||
watchListPage.subscribe((value) => (page = value));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import './style.css'
|
||||
import { mount } from 'svelte'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app')
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import loader from "../helperFunctions/loader";
|
||||
|
||||
let isAniListPrimary: boolean;
|
||||
let isAniListLoggedIn: boolean;
|
||||
let isAniListLoggedIn!: boolean;
|
||||
|
||||
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<!-- Dumb reusable star-rating input. No stores, no backend knowledge:
|
||||
`value` is written live on hover (reverted on leave unless committed)
|
||||
and set on click/keyboard; the parent owns what the value means
|
||||
(words, persistence). Reuses the Star leaf, so output is pixel-identical
|
||||
to the old overlay-slider version without the overlay. -->
|
||||
<script lang="ts">
|
||||
import Star from './components/Star.svelte';
|
||||
|
||||
type StarColors = {
|
||||
size: number;
|
||||
fillColor: string;
|
||||
strokeColor: string;
|
||||
unfilledColor: string;
|
||||
strokeUnfilledColor: string;
|
||||
};
|
||||
|
||||
let {
|
||||
value = $bindable(0),
|
||||
min = 0,
|
||||
max = 5,
|
||||
step = 0.5,
|
||||
count = 5,
|
||||
disabled = false,
|
||||
name,
|
||||
starConfig = {
|
||||
size: 32,
|
||||
fillColor: '#F9ED4F',
|
||||
strokeColor: '#BB8511',
|
||||
unfilledColor: '#FFF',
|
||||
strokeUnfilledColor: '#000'
|
||||
},
|
||||
onchange,
|
||||
}: {
|
||||
value?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
// When set, renders a hidden input so the value submits with a
|
||||
// surrounding <form> via FormData (the old slider exposed
|
||||
// name="rating" the same way).
|
||||
name?: string;
|
||||
starConfig?: StarColors;
|
||||
onchange?: (value: number) => void;
|
||||
} = $props();
|
||||
|
||||
// Value at hover entry; restored on leave unless committed. Null = idle.
|
||||
let baseline: number | null = $state(null);
|
||||
|
||||
function clamp(v: number): number {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
function snap(v: number): number {
|
||||
const snapped = min + Math.round((v - min) / step) * step;
|
||||
return clamp(Math.round(snapped * 1e6) / 1e6);
|
||||
}
|
||||
|
||||
// Continuous value for cursor position within star `index`.
|
||||
function valueAt(index: number, fraction: number): number {
|
||||
if (index === 0 && fraction < 0.2) return min;
|
||||
const f = fraction >= 0.5 ? 1 : 0.5;
|
||||
return snap(min + ((index + f) / count) * (max - min));
|
||||
}
|
||||
|
||||
function fractionOf(e: MouseEvent, el: HTMLElement): number {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0) return 1;
|
||||
return Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
}
|
||||
|
||||
function fillOf(index: number): number {
|
||||
const per = (max - min) / count;
|
||||
return Math.min(1, Math.max(0, (value - min - index * per) / per));
|
||||
}
|
||||
|
||||
function onHoverMove(e: MouseEvent, el: HTMLElement, index: number) {
|
||||
if (disabled) return;
|
||||
if (baseline === null) baseline = value;
|
||||
value = valueAt(index, fractionOf(e, el));
|
||||
}
|
||||
|
||||
function onHoverLeave() {
|
||||
if (disabled) return;
|
||||
if (baseline !== null) {
|
||||
value = baseline;
|
||||
baseline = null;
|
||||
}
|
||||
}
|
||||
|
||||
function commit(v: number) {
|
||||
if (disabled) return;
|
||||
value = clamp(v);
|
||||
baseline = value;
|
||||
onchange?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="stars-container"
|
||||
role="group"
|
||||
aria-label="Star rating"
|
||||
onmouseleave={onHoverLeave}
|
||||
>
|
||||
<div class="stars">
|
||||
{#if name !== undefined}
|
||||
<input type="hidden" name={name} value={value} />
|
||||
{/if}
|
||||
{#each Array(count) as _, i}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center p-0 border-0 bg-transparent"
|
||||
disabled={disabled}
|
||||
aria-label={`Rate ${i + 1} out of ${count}`}
|
||||
onmousemove={(e) => onHoverMove(e, e.currentTarget, i)}
|
||||
onclick={() => commit(value)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
commit(value + step);
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
commit(value - step);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
id={`star-${i}`}
|
||||
readOnly={disabled}
|
||||
starConfig={starConfig}
|
||||
fillPercentage={fillOf(i)}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stars-container{ position: relative; display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
||||
.stars{ display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
||||
</style>
|
||||
@@ -1,63 +0,0 @@
|
||||
<!-- Originally from @ernane/svelte-star-rating. Wanted to give credit but could not use from the library without causing program crash. -->
|
||||
|
||||
<script>
|
||||
import Star from './components/Star.svelte';
|
||||
export let config = {
|
||||
readOnly: false,
|
||||
countStars: 5,
|
||||
range: { min: 0, max: 5, step: 0.001 },
|
||||
score: 0.0,
|
||||
showScore: true,
|
||||
name: "stars",
|
||||
scoreFormat: function(){ return `(${this.score.toFixed(0)}/${this.countStars})` },
|
||||
starConfig: {
|
||||
size: 30,
|
||||
fillColor: '#F9ED4F',
|
||||
strokeColor: "#BB8511",
|
||||
unfilledColor: '#FFF',
|
||||
strokeUnfilledColor: '#000'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="stars-container">
|
||||
<div class="range-stars">
|
||||
<div class="stars">
|
||||
{#each Array(config.countStars) as star, id}
|
||||
{#if Math.floor(config.score) === id}
|
||||
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={config.score - Math.floor(config.score)}/>
|
||||
{:else if Math.floor(config.score) > id}
|
||||
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={1}/>
|
||||
{:else}
|
||||
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={0}/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<input name={config.name}
|
||||
class="slider"
|
||||
type="range"
|
||||
min={config.readOnly ? config.score : config.range.min}
|
||||
max={config.readOnly ? config.score : config.range.max}
|
||||
step="{config.range.step}" bind:value={config.score}
|
||||
on:change
|
||||
on:click
|
||||
>
|
||||
</div>
|
||||
{#if config.showScore}
|
||||
<span class="show-score" style="font-size: {config.starConfig.size/2}px;">
|
||||
{#if config.scoreFormat}
|
||||
{config.scoreFormat()}
|
||||
{:else}
|
||||
({((config.score/config.countStars)*100).toFixed(2)}%)
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.stars-container{ position: relative; display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
||||
.range-stars{ position: relative; }
|
||||
.stars{ display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
||||
.slider{ opacity: 0; cursor: pointer; position: absolute; top: 0; left: 0; right: 0; height: 100%; }
|
||||
.show-score{ user-select: none; color: #888 }
|
||||
</style>
|
||||
+57
-3
@@ -1,6 +1,60 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin 'flowbite/plugin';
|
||||
|
||||
@source "../node_modules/flowbite-svelte/dist";
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #FFF5F2;
|
||||
--color-primary-100: #FFF1EE;
|
||||
--color-primary-200: #FFE4DE;
|
||||
--color-primary-300: #FFD5CC;
|
||||
--color-primary-400: #FFBCAD;
|
||||
--color-primary-500: #FE795D;
|
||||
--color-primary-600: #EF562F;
|
||||
--color-primary-700: #EB4F27;
|
||||
--color-primary-800: #CC4522;
|
||||
--color-primary-900: #A5371B;
|
||||
}
|
||||
|
||||
@utility container {
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
|
||||
@media (width >= theme(--breakpoint-sm)) {
|
||||
max-width: theme(--breakpoint-sm);
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
|
||||
@media (width >= theme(--breakpoint-md)) {
|
||||
max-width: theme(--breakpoint-md);
|
||||
}
|
||||
|
||||
@media (width >= theme(--breakpoint-lg)) {
|
||||
max-width: theme(--breakpoint-lg);
|
||||
padding-inline: 4rem;
|
||||
}
|
||||
|
||||
@media (width >= theme(--breakpoint-xl)) {
|
||||
max-width: theme(--breakpoint-xl);
|
||||
padding-inline: 5rem;
|
||||
}
|
||||
|
||||
@media (width >= theme(--breakpoint-2xl)) {
|
||||
max-width: theme(--breakpoint-2xl);
|
||||
padding-inline: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/* Tailwind v4 removed the v3 preflight rule that gave buttons a
|
||||
pointer cursor. Restore it (disabled buttons keep the default). */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: rgba(27, 38, 54, 1);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import sveltePreprocess from 'svelte-preprocess'
|
||||
|
||||
export default {
|
||||
// Consult https://github.com/sveltejs/svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: sveltePreprocess()
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import flowbitePlugin from 'flowbite/plugin'
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{svelte,js,ts,jsx,tsx}",
|
||||
"./node_modules/flowbite/**/*.{html,js,svelte,ts}",
|
||||
"./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}",
|
||||
],
|
||||
plugins: [ flowbitePlugin ],
|
||||
|
||||
darkMode: 'media',
|
||||
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: {
|
||||
DEFAULT: '1rem',
|
||||
sm: '2rem',
|
||||
lg: '4rem',
|
||||
xl: '5rem',
|
||||
'2xl': '6rem',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
// flowbite-svelte
|
||||
primary: {
|
||||
50: '#FFF5F2',
|
||||
100: '#FFF1EE',
|
||||
200: '#FFE4DE',
|
||||
300: '#FFD5CC',
|
||||
400: '#FFBCAD',
|
||||
500: '#FE795D',
|
||||
600: '#EF562F',
|
||||
700: '#EB4F27',
|
||||
800: '#CC4522',
|
||||
900: '#A5371B'
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import {svelte} from '@sveltejs/vite-plugin-svelte'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import wails from '@wailsio/runtime/plugins/vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
@@ -9,5 +10,5 @@ export default defineConfig({
|
||||
port: Number(process.env.WAILS_VITE_PORT) || 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [svelte(), wails('./bindings')]
|
||||
plugins: [tailwindcss(), svelte(), wails('./bindings')]
|
||||
})
|
||||
|
||||
@@ -3,8 +3,7 @@ module AniTrack
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/tidwall/gjson v1.19.0
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.20
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.22
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
)
|
||||
|
||||
@@ -16,7 +15,5 @@ require (
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
)
|
||||
|
||||
@@ -26,14 +26,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.20 h1:AKKrRGMSqzJET1W0Jt9v+f0oOqM9Amsi7LKMM6XnSOE=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.20/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.22 h1:GsdqwRB7nESQyeZKVv16DLIS30xCLMlZP/8UhgKnHYc=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.22/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -38,6 +38,9 @@ func main() {
|
||||
svc = NewApp(app)
|
||||
app.RegisterService(application.NewService(svc))
|
||||
|
||||
// Self-updates: desktop production builds only (no-op elsewhere).
|
||||
maybeEnableUpdater(app)
|
||||
|
||||
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: appTitle(),
|
||||
Width: 1024,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# instead of:
|
||||
# make release VERSION=1.6.7
|
||||
# Lives in the repo (no machine-specific alias needed). Tags are plain
|
||||
# versions (1.6.7) to match wails.json productVersion.
|
||||
# versions (1.6.7) to match build/config.yml info.version.
|
||||
set -eu
|
||||
|
||||
[ "$#" -ge 1 ] || { echo "Usage: ./release 1.6.7" >&2; exit 1; }
|
||||
|
||||
+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
|
||||
@@ -0,0 +1,5 @@
|
||||
package main
|
||||
|
||||
// Code generated by `make release` from build/config.yml info.version.
|
||||
// DO NOT EDIT.
|
||||
const appVersionString = "2.0.0"
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "AniTrack",
|
||||
"frontend": {
|
||||
"dir": "./frontend",
|
||||
"install": "npm install",
|
||||
"build": "npm run build",
|
||||
"dev": "npm run dev",
|
||||
"devServerUrl": "http://localhost:5173"
|
||||
},
|
||||
"author": {
|
||||
"name": "John O'Keefe",
|
||||
"email": "admin@linuxhg.com"
|
||||
},
|
||||
"info": {
|
||||
"productName": "AniTrack",
|
||||
"productVersion": "1.99.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user