Compare commits

..
20 Commits
Author SHA1 Message Date
John O'Keefe 2ab5a080a9 chore(release): bump version to 2.0.0
Release / release (push) Successful in 3m20s
2026-09-16 16:56:31 -04:00
John O'Keefe 60ff980809 fix(frontend): header swap breakpoint down to mobile width
- all 16 min-[950px] instances (the only file using the custom
  breakpoint) become standard sm:, so inline search survives the
  whole tablet range and the hamburger appears only once search
  leaves at true mobile width; avatar present throughout either way
- the top-bar search wrapper was also missing its base hidden, which
  rendered search twice below the breakpoint; fixed in the same pass
2026-09-16 16:30:35 -04:00
John O'Keefe e2c8ba10ce fix(frontend): submit-time data flow (rating scale, table reactivity)
- rating submitted doubled: the * 2 belonged to the retired 0-5
  slider scale while StarInput already works in backend 0-10 units,
  so every rating arrived doubled (9 became 18, both backends
  rejected it); form value now passes through unscaled, display and
  words map already native 0-10
- StarInput gains an optional name prop rendering a hidden input,
  restoring the rating form field the old slider exposed; Rating
  passes name="rating"
- table updates go immutable (new array every path): in-place
  mutation with the same reference never reliably invalidated the
  Svelte 5 $derived, which left the grid stale after successful
  submits; replace-or-append semantics unchanged
- per-page dropdown reads the event value synchronously (Svelte 5
  nulls currentTarget after dispatch, crashing the old
  read-inside-.then)
- detail poster column shares a flex centering context so poster and
  stars align by construction at every width
2026-09-16 16:30:23 -04:00
John O'Keefe 515678f035 fix(frontend): Svelte 5 mount API and route-driven refresh
- loader action used new Component() plus $destroy, both removed in
  Svelte 5 (component_api_invalid_new crashed every refresh click
  before the reload ran); rewritten on mount/unmount with a real
  action destroy for the leaked subscription
- homecoming refresh moves off the legacy $: block, which never
  tracked the runes-backed router.location, onto the sanctioned
  onRouteLoaded callback with identical guard semantics; unused
  router import dropped so the check stays warning-free
2026-09-16 16:30:08 -04:00
John O'Keefe e8a5768a2f docs: record toolchain completion and version-source move
- toolchain item flipped to done-state with the deliberate holds
  named (TypeScript 5.9 peer constraint, runtime lockstep rule)
- Makefile/config records updated for the single-source version flow
  and the wails.json retirement proof
- branch map already corrected in an earlier pass; history above the
  edited lines untouched
2026-09-16 16:29:48 -04:00
John O'Keefe 2a6397b964 refactor!: retire wails.json, config.yml is the version source
- version.go (generated, never hand-edited) carries the compiled-in
  version; app.go drops the wails.json embed plus the gjson dependency
- make release bumps config.yml and regenerates version.go in one
  commit; the re-run-safe guards from the 1.99.1 cycle carry over
- release guard reads config.yml; CI CLI pin and cache key move to
  beta.22 to match the lockstep above
- release wrapper and config NOTE comments updated; deletion proof:
  wails3 build green plus a dev boot reaching the connected state
  with the file absent (the one cold-boot failure reproduced as a
  Vite 8 cold-start vs app retry race, cleared by rerunning warm)
2026-09-16 16:29:35 -04:00
John O'Keefe de808dbb67 chore(deps): Wails v3 beta.20 to beta.22 lockstep
- go.mod, local CLI install, and @wailsio/runtime move together;
  the JS/Go wire protocol must match exactly, so the runtime never
  moves alone
- bindings regen is byte-identical (same 29 methods/IDs), confirming
  the bump is behavior-transparent at the binding layer
- beta.23 exists but is hours old and touches Linux single-instance;
  deliberately deferred to a routine bump, not stacked onto 2.0
- deletes wails.json (staged earlier during retirement proof; the
  version-source move lands in the next commit)
- svelte-check 0/0, vet/tests/builds green, smoke launch clean
2026-09-16 16:29:12 -04:00
John O'Keefe f63563ce16 fix(frontend): star rating hover via StarInput, retire overlay slider
The vendored overlay-slider Stars.svelte never responded under
Svelte 5 - a runes port plus an explicit value/oninput rewrite both
left it inert, which convicted the mechanism rather than its
syntax. Replace it with StarInput.svelte: per-star hover zones with
fractional math (preserves the full 0-10 granularity),
snapshot-and-revert on mouseleave, commit on click, arrow-key
nudging, disabled mode. It reuses the Star SVG leaf so output is
pixel-identical, stays dumb (value in/out, no stores) for the
planned inline-grid editing, and Rating.svelte keeps owning the
words/score math locally. Legacy on:change forwarding and the
slider CSS go with the old file.
2026-09-16 14:00:44 -04:00
John O'Keefe 8d5d8ac2d7 refactor(frontend): flowbite-svelte v1 APIs, bindings-aligned store types
flowbite-svelte v1 turns components into runes: on:click on
components becomes onclick props (native elements keep on:), Modal
footers become {#snippet footer()}, Button["color"] indexing becomes
the exported ButtonProps type, and slate is gone from the color
union so the datepicker greys map to gray.

Stores now use the generated bindings models (what the Go backend
actually returns) instead of hand-written shapes that drifted from
them - nullability of genres/tags/mediaList included. That single
move clears most of the newly surfaced type errors, including the
optional episodes/nextAiringEpisode cluster. The rest is strictness
debt the old red check masked: definite-assignment on
subscribe-fed lets, null guards on querySelector/match results and
date parsing (two of which fixed latent throw-on-garbage paths),
aria-labels on icon-only buttons, the completed default-data
literal, and a shared flex centering context for the detail poster
column so poster and stars align by construction at every width.
2026-09-16 14:00:17 -04:00
John O'Keefe 59b81396f9 refactor(frontend): Svelte 5 mount API, router v5, dependency-free table
main.ts uses mount() instead of the legacy constructor. App.svelte
follows svelte-spa-router v5: the removed loc store becomes
router.location, while wrap/push/link keep their v4 call shapes.

AnimeTable.svelte drops svelte-headless-table and reimplements the
same 11 sortable columns (WebsiteLink id cells, asc/desc
indicators, click toggles through asc/desc/off) with local
$state/$derived - about 120 lines, no dependency, fully covered by
svelte-check instead of the libs view model.
2026-09-16 13:59:42 -04:00
John O'Keefe 4087599629 chore(frontend): Tailwind v4 CSS-first config
Move every tailwind.config.js option into src/style.css: @import,
@plugin flowbite/plugin, @source for the flowbite-svelte dist
(node_modules is gitignored so auto-detection skips the lib),
@theme for the custom primary scale, and @utility container
porting the old center plus responsive padding and max-width
ladders. darkMode media is v4s default so it needs no equivalent.
vite.config.ts gains the tailwindcss() plugin; index.html loses the
dead flowbite.js tag.

style.css also carries two v4-behavior restorations found while
verifying the built CSS: pointer cursor on buttons (v4 preflight
dropped v3s rule) and the container max-width ladder the first
pass at this file omitted.
2026-09-16 13:59:19 -04:00
John O'Keefe 577c7d0d5b chore(frontend): Svelte 5 / Vite 8 / Tailwind CSS 4 dependency set
Move the toolchain to Svelte 5.57, Vite 8.3, vite-plugin-svelte 7.3,
svelte-check 4.7.6, svelte-spa-router 5.1.1, Tailwind 4.3 (plus the
new @tailwindcss/vite plugin), flowbite-svelte 1.33 and flowbite
4.0.2.

Dropped outright: svelte-preprocess (only ever handled lang="ts",
which the vite plugin does natively), svelte-headless-table (peers
svelte ^4, dead end - the one table gets rewritten dependency-free),
postcss/autoprefixer (v4 handles transforms internally) and the
vanilla flowbite.js script tag (no data-* usage anywhere; the Svelte
components are self-contained).

Deliberately pinned, not upgraded: typescript stays 5.9
(svelte-check peers ^5||^6, and 7.x is the native-port rewrite) and
@wailsio/runtime stays 3.0.0-beta.20 (must match the Go framework and
CLI exactly - it moves in lockstep with them, never alone).

Fresh lockfile, zero npm vulnerabilities (was ten).
2026-09-16 13:58:47 -04:00
John O'Keefe 5ce1094b90 fix(release): make version bump re-runnable when already current
Release / release (push) Successful in 2m33s
Re-running make release for an already-bumped version died twice: the config.yml python asserted on change (no-op rewrite tripped it), and git commit would fail on an empty stage next. Now the python asserts only the pattern presence (real file-shape errors still abort) and prints already-at-version, and the commit step skips via git diff --cached when the stage is empty. Re-runs proceed to cliff notes and tagging instead of aborting. Proven both branches: already-current is a clean no-op, stale value bumps exactly line 13 with the schema version untouched.
2026-09-16 09:23:26 -04:00
John O'Keefe 0b9f19cb08 chore(release): bump version to 1.99.1
Release / release (push) Successful in 2m14s
Version bump for the updater live-proof cycle (NOT a release: no tag). Carries the signed-asset CI path so the 1.99.1-rc1 pre-release publishes AniTrack-linux-amd64 plus sidecars for the beta-channel end-to-end test.
2026-09-16 08:55:31 -04:00
John O'Keefe 948c2d3960 docs: record self-updates design and correct branch map
Add the Self-updates section (provider, signing, gates, beta channel, binary-only scope, key custody) and fix the two stale branch-map lines that still described main as the v2 line. History above untouched.
2026-09-16 08:52:14 -04:00
John O'Keefe 0149b24a33 ci(release): publish signed updater assets alongside tarball
After packaging, stage the bare AniTrack-linux-amd64 binary (the updater swaps os.Executable in place; the user tarball single top-level directory would fail the single-entry rule), sign it with wails3 updater sign using UPDATER_SIGNING_KEY (missing secret is a hard error, key shredded after use), split the output into .sha512/.sig sidecars, and extend the idempotent replace-upload loop from 1 asset to all 4. Tarball flow untouched; rc tags get signed assets too, which the beta-channel live proof needs.
2026-09-16 08:52:09 -04:00
John O'Keefe 52fc656669 feat(updater): wire startup check for desktop production builds
maybeEnableUpdater inits app.Updater (CurrentVersion from wails.json, embedded public key, accent-themed builtin window) behind two gates: -tags production (wails3 build sets it, dev does not) and application.System.IsDesktop (mobile stays on Obtainium). The startup check is headless; the window opens via a second CheckAndInstall only when an update is found. ANITRACK_UPDATER_CHANNEL=beta opts into -rc pre-releases for testing. updater_flow_test.go drives the REAL framework verifier headless: good signature stages byte-identical, tampered signature rejected (both phases green). Works around the framework process-wide app singleton with a single two-phase test.
2026-09-16 08:52:04 -04:00
John O'Keefe e9b04c0a84 feat(updater): custom Gitea releases provider with fail-closed verification
Wails ships no Gitea provider, so add updater/gitea (~250 lines): latest non-draft release newer than current wins, pre-releases skipped unless AllowPrerelease, exact AniTrack-linux-amd64 asset match, .sha512/.sig sidecars parsed into updater.Verification (sha512+ed25519ph, mirroring wails3 updater sign). Anything missing or malformed is an error, never an unsigned install. Includes semver compare, streaming download with progress, and unit tests (picking rules, fail-closed sidecars, server errors, progress) plus an opt-in live probe (ANITRACK_LIVE_TEST=1) proven against the real instance. Also: embed updater.pub (public half; private key lives in password manager + UPDATER_SIGNING_KEY CI secret, updater.key gitignored) and ignore CI-side updater-dist/ staging.
2026-09-16 08:51:59 -04:00
John O'Keefe bc1f4b1482 docs: point runners at install script, document self-updates
Runtime box now leads with the tarball install_linux.sh (which installs the webkit runtime for supported distros) and keeps manual per-distro commands as fallback. Add an Updates section: desktop Linux releases self-update in-app (signed, verified), binary-only; icons and .desktop still come from the tarball.
2026-09-16 08:51:52 -04:00
John O'Keefe 5036e34d3c fix(install): install webkitgtk-6.0 runtime per distro, refuse on musl
The release binary dynamically links libwebkitgtk-6.0, which is not preinstalled on most distros and is not shipped in the tarball, so installs failed at first launch with a missing-.so error. Add a step-0 dependency stage to install_linux.sh that maps Arch/Debian-Ubuntu/Fedora/OpenMandriva/Void to their verified runtime package names (ID plus ID_LIKE, so derivatives resolve), requires root/sudo, and stays idempotent. musl systems (Alpine, Void-musl) get a warning plus hard-abort: the glibc-linked binary cannot run there and no package fixes that. Unknown distros get the package table and exit 1. Existing desktop/icons/binary steps untouched. Verified with an 11-case stub harness (all families route correctly; sudo/no-sudo/missing-manager/musl/unknown all behave).
2026-09-16 08:51:47 -04:00
53 changed files with 3067 additions and 2527 deletions
+70 -35
View File
@@ -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
+5
View File
@@ -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/
+15 -14
View File
@@ -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) || \
+9
View File
@@ -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`.
+3 -7
View File
@@ -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
View File
@@ -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:
+94
View File
@@ -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
View File
@@ -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 doesnt 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.
-4
View File
@@ -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>
+1465 -2007
View File
File diff suppressed because it is too large Load Diff
+15 -18
View File
@@ -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"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+25 -17
View File
@@ -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({
+39 -23
View File
@@ -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",
+81 -104
View File
@@ -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>
+10 -10
View File
@@ -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) &&
+15 -14
View File
@@ -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}
+11 -10
View File
@@ -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>
+12 -10
View File
@@ -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
+6 -31
View File
@@ -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>
+1 -1
View File
@@ -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]),
+22 -11
View File
@@ -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));
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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));
+142
View File
@@ -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>
-63
View File
@@ -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
View File
@@ -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);
-7
View File
@@ -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()
}
-44
View File
@@ -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'
},
}
}
}
}
+2 -1
View File
@@ -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')]
})
+1 -4
View File
@@ -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
)
+2 -8
View File
@@ -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=
+3
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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)
}
}()
}
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAWWF3cDxJKM668FqWsFsA9e7r7aXU2uDTUF+ZKKcxLP0=
-----END PUBLIC KEY-----
+329
View File
@@ -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
}
+291
View File
@@ -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)
}
+7
View File
@@ -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
+140
View File
@@ -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)
}
}
+8
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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"
}
}