Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ab5a080a9 | ||
|
|
60ff980809 | ||
|
|
e2c8ba10ce | ||
|
|
515678f035 | ||
|
|
e8a5768a2f | ||
|
|
2a6397b964 | ||
|
|
de808dbb67 | ||
|
|
f63563ce16 | ||
|
|
8d5d8ac2d7 | ||
|
|
59b81396f9 | ||
|
|
4087599629 | ||
|
|
577c7d0d5b | ||
|
|
5ce1094b90 | ||
|
|
0b9f19cb08 | ||
|
|
948c2d3960 | ||
|
|
0149b24a33 | ||
|
|
52fc656669 | ||
|
|
e9b04c0a84 | ||
|
|
bc1f4b1482 | ||
|
|
5036e34d3c |
@@ -12,9 +12,10 @@ run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
|
|||||||
# work-in-progress commits never ship.
|
# work-in-progress commits never ship.
|
||||||
#
|
#
|
||||||
# Local flow: `./release 1.6.7` (or `make release VERSION=1.6.7`) bumps
|
# 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
|
# build/config.yml (and the generated version.go), commits the bump,
|
||||||
# git-cliff notes, and pushes commit + tag. This workflow verifies
|
# creates an annotated tag carrying the git-cliff notes, and pushes
|
||||||
# wails.json matches the tag before building.
|
# commit + tag. This workflow verifies build/config.yml matches the tag
|
||||||
|
# before building.
|
||||||
#
|
#
|
||||||
# Secrets test (verifies environment.go wiring without shipping): tag the
|
# Secrets test (verifies environment.go wiring without shipping): tag the
|
||||||
# current bumped commit with a suffix and push, e.g.
|
# current bumped commit with a suffix and push, e.g.
|
||||||
@@ -49,7 +50,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
|
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
|
||||||
|
|
||||||
- name: Guard wails.json matches tag
|
- name: Guard build/config.yml matches tag
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
: "${TAG:?TAG is required}"
|
: "${TAG:?TAG is required}"
|
||||||
@@ -58,21 +59,21 @@ jobs:
|
|||||||
NORM="${TAG#v}"
|
NORM="${TAG#v}"
|
||||||
NORM="${NORM#AniTrack-}"
|
NORM="${NORM#AniTrack-}"
|
||||||
BASE="${NORM%%-*}"
|
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 [ "${NORM}" = "${BASE}" ]; then
|
||||||
if [ "${WAILS_VER}" != "${NORM}" ]; then
|
if [ "${CFG_VER}" != "${NORM}" ]; then
|
||||||
echo "::error::wails.json productVersion (${WAILS_VER}) != tag (${NORM}). Bump via ./release ${NORM} first." >&2
|
echo "::error::build/config.yml info.version (${CFG_VER}) != tag (${NORM}). Bump via ./release ${NORM} first." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Pre-release (e.g. 1.6.7-rc1): wails.json must match the base version.
|
# Pre-release (e.g. 1.6.7-rc1): build/config.yml must match the base version.
|
||||||
if [ "${WAILS_VER}" != "${BASE}" ]; then
|
if [ "${CFG_VER}" != "${BASE}" ]; then
|
||||||
echo "::error::wails.json productVersion (${WAILS_VER}) != tag base (${BASE}). Bump via ./release ${BASE} first." >&2
|
echo "::error::build/config.yml info.version (${CFG_VER}) != tag base (${BASE}). Bump via ./release ${BASE} first." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
echo "VERSION=${NORM}" >> "${GITHUB_ENV}"
|
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
@@ -119,13 +120,13 @@ jobs:
|
|||||||
# The compiled CLI binary. Bump the key whenever the @version pin
|
# The compiled CLI binary. Bump the key whenever the @version pin
|
||||||
# below changes, or the old CLI will be silently reused.
|
# below changes, or the old CLI will be silently reused.
|
||||||
path: ~/go/bin/wails3
|
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
|
- name: Install Wails CLI
|
||||||
if: steps.wails-cli.outputs.cache-hit != 'true'
|
if: steps.wails-cli.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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
|
- name: Add Go bin to PATH
|
||||||
run: echo "${HOME}/go/bin" >> "${GITHUB_PATH}"
|
run: echo "${HOME}/go/bin" >> "${GITHUB_PATH}"
|
||||||
@@ -245,7 +246,38 @@ jobs:
|
|||||||
echo "Created new release for ${TAG}"
|
echo "Created new release for ${TAG}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload release archive
|
- name: Build updater asset and sign
|
||||||
|
env:
|
||||||
|
UPDATER_SIGNING_KEY: ${{ secrets.UPDATER_SIGNING_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: "${VERSION:?VERSION missing from version-guard step}"
|
||||||
|
export PATH="${HOME}/go/bin:${PATH}"
|
||||||
|
: "${UPDATER_SIGNING_KEY:?Missing secret UPDATER_SIGNING_KEY. Add the updater private key under Settings → Secrets → Actions.}"
|
||||||
|
# Bare updater binary: the updater swaps os.Executable() in place,
|
||||||
|
# so it takes the single binary, not the user tarball (whose
|
||||||
|
# top-level directory would fail the single-entry rule).
|
||||||
|
mkdir -p updater-dist
|
||||||
|
cp build/bin/AniTrack updater-dist/AniTrack-linux-amd64
|
||||||
|
printf '%s' "${UPDATER_SIGNING_KEY}" > updater-dist/updater.key
|
||||||
|
chmod 600 updater-dist/updater.key
|
||||||
|
wails3 updater sign -key updater-dist/updater.key updater-dist/AniTrack-linux-amd64 > updater-dist/sign.json
|
||||||
|
# Split the sign output into the sidecar assets the Gitea provider
|
||||||
|
# fetches: <name>.sha512 (sha512sum format) + <name>.sig (base64).
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import base64, binascii, json
|
||||||
|
entries = json.load(open('updater-dist/sign.json'))
|
||||||
|
entry = next(e for e in entries if e['filename'].endswith('AniTrack-linux-amd64'))
|
||||||
|
assert entry['digestAlgo'] == 'sha512', entry
|
||||||
|
assert entry['signatureAlgo'] == 'ed25519ph', entry
|
||||||
|
digest_hex = binascii.hexlify(base64.b64decode(entry['digest'])).decode()
|
||||||
|
open('updater-dist/AniTrack-linux-amd64.sha512', 'w').write(f"{digest_hex} AniTrack-linux-amd64\n")
|
||||||
|
open('updater-dist/AniTrack-linux-amd64.sig', 'w').write(entry['signature'].strip() + '\n')
|
||||||
|
EOF
|
||||||
|
shred -u updater-dist/updater.key
|
||||||
|
ls -la updater-dist/
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
REPO: ${{ gitea.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
@@ -255,27 +287,30 @@ jobs:
|
|||||||
: "${ARCHIVE:?ARCHIVE missing from packaging step}"
|
: "${ARCHIVE:?ARCHIVE missing from packaging step}"
|
||||||
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||||
AUTH="Authorization: token ${TOKEN}"
|
AUTH="Authorization: token ${TOKEN}"
|
||||||
test -f "${ARCHIVE}" || { echo "::error::${ARCHIVE} not found" >&2; exit 1; }
|
for ASSET in "${ARCHIVE}" updater-dist/AniTrack-linux-amd64 updater-dist/AniTrack-linux-amd64.sha512 updater-dist/AniTrack-linux-amd64.sig; do
|
||||||
|
test -f "${ASSET}" || { echo "::error::${ASSET} not found" >&2; exit 1; }
|
||||||
|
|
||||||
RID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty')"
|
RID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty')"
|
||||||
if [ -z "${RID}" ]; then
|
if [ -z "${RID}" ]; then
|
||||||
echo "::error::No release found for tag ${TAG} after create step" >&2
|
echo "::error::No release found for tag ${TAG} after create step" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Replace a same-named asset so re-runs stay idempotent.
|
# Replace a same-named asset so re-runs stay idempotent.
|
||||||
AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ARCHIVE}" '.[] | select(.name==$n) | .id // empty')"
|
ANAME="$(basename "${ASSET}")"
|
||||||
if [ -n "${AID}" ]; then
|
AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ANAME}" '.[] | select(.name==$n) | .id // empty')"
|
||||||
curl -sS -X DELETE -H "${AUTH}" "${API}/${RID}/assets/${AID}" >/dev/null
|
if [ -n "${AID}" ]; then
|
||||||
echo "Deleted existing asset id=${AID} (${ARCHIVE})"
|
curl -sS -X DELETE -H "${AUTH}" "${API}/${RID}/assets/${AID}" >/dev/null
|
||||||
fi
|
echo "Deleted existing asset id=${AID} (${ANAME})"
|
||||||
|
fi
|
||||||
|
|
||||||
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
|
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
|
||||||
-F "attachment=@${ARCHIVE}" "${API}/${RID}/assets?name=${ARCHIVE}")"
|
-F "attachment=@${ASSET}" "${API}/${RID}/assets?name=${ANAME}")"
|
||||||
code="$(printf '%s' "${resp}" | tail -n1)"
|
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||||
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||||
if [ "${code}" -ge 400 ]; then
|
if [ "${code}" -ge 400 ]; then
|
||||||
echo "::error::Asset upload ${code}: ${rbody}" >&2
|
echo "::error::Asset upload ${code}: ${rbody}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Uploaded ${ARCHIVE} to release id=${RID}"
|
echo "Uploaded ${ANAME} to release id=${RID}"
|
||||||
|
done
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ frontend/package.json.md5
|
|||||||
.idea
|
.idea
|
||||||
.env
|
.env
|
||||||
environment.go
|
environment.go
|
||||||
|
# Updater signing private key. The public half (updater.pub) is committed and
|
||||||
|
# embedded; the private key lives in the password manager + CI secrets only.
|
||||||
|
updater.key
|
||||||
|
|
||||||
# REST (http files)
|
# REST (http files)
|
||||||
http-client.private.env.json
|
http-client.private.env.json
|
||||||
@@ -41,3 +44,5 @@ http-client.private.env.json
|
|||||||
*.tar
|
*.tar
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
/AniTrack
|
/AniTrack
|
||||||
|
# Updater CI staging (bare binary + sidecars, runner-ephemeral)
|
||||||
|
updater-dist/
|
||||||
|
|||||||
@@ -11,22 +11,26 @@ build:
|
|||||||
clean:
|
clean:
|
||||||
rm -rf build/bin/*
|
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),
|
# annotated version tag carrying auto-generated release notes (git-cliff),
|
||||||
# and push both. The tag push triggers .gitea/workflows/release.yml, which
|
# 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
|
# AniTrack-<VERSION>.tar.gz from build/, and publishes a Gitea Release
|
||||||
# named AniTrack-<VERSION> with the archive attached. Notes come entirely
|
# named AniTrack-<VERSION> with the archive attached. Notes come entirely
|
||||||
# from Conventional Commits — no hand-written message required.
|
# 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
|
# 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
|
# create a throwaway lightweight tag, generate the notes, replace it with
|
||||||
# an annotated tag, then push. --cleanup=verbatim keeps the markdown "###"
|
# an annotated tag, then push. --cleanup=verbatim keeps the markdown "###"
|
||||||
# group headers (git's default cleanup would strip lines starting with "#").
|
# 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
|
# Tags are plain versions (1.6.7) to match build/config.yml info.version and
|
||||||
# existing tag history. Pre-releases are created by tagging manually with a
|
# the existing tag history. Pre-releases are created by tagging manually with
|
||||||
# suffix (e.g. 1.6.7-rc1 on top of the bumped commit) — the workflow marks
|
# 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.
|
# those as prerelease. Use ./release as a shortcut.
|
||||||
#
|
#
|
||||||
# Requires git-cliff: https://git-cliff.org/install
|
# 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; }
|
@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; }
|
@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 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; }
|
@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
|
@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)..."
|
@echo "Bumping build/config.yml to $(VERSION)..."
|
||||||
@python3 -c "import re; p='build/config.yml'; s=open(p).read(); s2=re.sub(r'^ version: \"[^\"]*\"', ' version: \"$(VERSION)\"', s, count=1, flags=re.M); assert s2 != s, 'info.version not found'; open(p,'w').write(s2)"
|
@python3 -c "import re; p='build/config.yml'; s=open(p).read(); m=re.search(r'^ version: \"([^\"]*)\"', s, flags=re.M); assert m, 'info.version not found'; print('build/config.yml', m.group(1), '-> $(VERSION)' if m.group(1) != '$(VERSION)' else '(already at version)'); open(p,'w').write(re.sub(r'^ version: \"[^\"]*\"', ' version: \"$(VERSION)\"', s, count=1, flags=re.M))"
|
||||||
@git add wails.json build/config.yml
|
@echo "Regenerating version.go from build/config.yml..."
|
||||||
@git commit -m "chore(release): bump version to $(VERSION)"
|
@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)..."
|
@echo "Generating release notes for $(VERSION)..."
|
||||||
@git tag "$(VERSION)" HEAD && \
|
@git tag "$(VERSION)" HEAD && \
|
||||||
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ To run as is, please feel free to download a binary from the releases page.
|
|||||||
> **To run:** install the WebKitGTK 6 runtime first — it is not preinstalled
|
> **To run:** install the WebKitGTK 6 runtime first — it is not preinstalled
|
||||||
> on most distros, and the app will not start without it
|
> on most distros, and the app will not start without it
|
||||||
> (`error while loading shared libraries: libwebkitgtk-6.0.so.4`).
|
> (`error while loading shared libraries: libwebkitgtk-6.0.so.4`).
|
||||||
|
> The release tarball's `install_linux.sh` installs it automatically
|
||||||
|
> (Arch, Debian/Ubuntu, Fedora, Void, OpenMandriva). Manual fallback —
|
||||||
> Arch: `sudo pacman -S webkitgtk-6.0` ·
|
> Arch: `sudo pacman -S webkitgtk-6.0` ·
|
||||||
> Debian/Ubuntu: `sudo apt install libwebkitgtk-6.0-4`.
|
> Debian/Ubuntu: `sudo apt install libwebkitgtk-6.0-4`.
|
||||||
|
|
||||||
@@ -58,3 +60,10 @@ The `wailsv3` branch moves AniTrack from Wails `v2` → `v3` and from `99designs
|
|||||||
## Building
|
## Building
|
||||||
|
|
||||||
To build a redistributable, production mode package, use `make build`. The binary lands at `build/bin/AniTrack`.
|
To build a redistributable, production mode package, use `make build`. The binary lands at `build/bin/AniTrack`.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
Desktop Linux releases self-update: the app checks for a newer release at
|
||||||
|
startup and offers it in-app (signed, verified before install). Only the
|
||||||
|
binary updates itself — icons and the `.desktop` file still come from the
|
||||||
|
release tarball. Details in `docs/V3_MIGRATION.md: Self-updates`.
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed wails.json
|
|
||||||
var wailsJSON string
|
|
||||||
|
|
||||||
// App struct
|
// App struct
|
||||||
type App struct {
|
type App struct {
|
||||||
app *application.App
|
app *application.App
|
||||||
@@ -21,9 +16,10 @@ func NewApp(app *application.App) *App {
|
|||||||
return &App{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 {
|
func appVersion() string {
|
||||||
return gjson.Get(wailsJSON, "info.productVersion").String()
|
return appVersionString
|
||||||
}
|
}
|
||||||
|
|
||||||
// appTitle is the window title: "AniTrack <version>".
|
// appTitle is the window title: "AniTrack <version>".
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
# Wails v3 project configuration.
|
# 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,
|
# Never touch the top-level `version: '3'` — that is the config file schema,
|
||||||
# not the app version.
|
# not the app version.
|
||||||
version: '3'
|
version: '3'
|
||||||
@@ -10,7 +11,7 @@ info:
|
|||||||
productIdentifier: "com.linuxhg.anitrack"
|
productIdentifier: "com.linuxhg.anitrack"
|
||||||
description: "Track anime watchlists across AniList, MyAnimeList, and Simkl"
|
description: "Track anime watchlists across AniList, MyAnimeList, and Simkl"
|
||||||
copyright: "John O'Keefe"
|
copyright: "John O'Keefe"
|
||||||
version: "1.99.0"
|
version: "2.0.0"
|
||||||
|
|
||||||
# Dev mode configuration
|
# Dev mode configuration
|
||||||
dev_mode:
|
dev_mode:
|
||||||
|
|||||||
@@ -1,4 +1,98 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -u
|
||||||
|
|
||||||
|
# Step 0: WebKitGTK 6 runtime ...................................................
|
||||||
|
# The release binary dynamically links libwebkitgtk-6.0 and libgtk-4, which are
|
||||||
|
# not preinstalled on most distros and are not shipped in the tarball. Install
|
||||||
|
# them first so the app can start; abort before copying anything otherwise.
|
||||||
|
#
|
||||||
|
# OS_RELEASE_FILE override exists for testing the distro mapping only.
|
||||||
|
|
||||||
|
print_manual_instructions() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Install the WebKitGTK 6 runtime for your distribution, then re-run this script:
|
||||||
|
Arch / Manjaro / EndeavourOS: sudo pacman -S webkitgtk-6.0
|
||||||
|
Debian / Ubuntu / Mint / Pop: sudo apt install libwebkitgtk-6.0-4
|
||||||
|
Fedora / Nobara: sudo dnf install webkitgtk6.0
|
||||||
|
OpenMandriva: sudo dnf install lib64webkit2gtk6.0
|
||||||
|
Void (glibc): sudo xbps-install -S libwebkitgtk60
|
||||||
|
Without it the app fails at launch: error while loading shared libraries: libwebkitgtk-6.0.so.4
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
install_webkit_runtime() {
|
||||||
|
# 0a. musl guard: the prebuilt binary is glibc-linked and cannot run on
|
||||||
|
# musl systems (Alpine, Void-musl). No package fixes that — refuse loudly.
|
||||||
|
if ldd --version 2>&1 | grep -qi musl; then
|
||||||
|
echo "ERROR: AniTrack ships a glibc-linked binary, but this system uses musl libc." >&2
|
||||||
|
echo "The prebuilt release cannot run here. Build from source instead (see README)." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 0b. Every package manager below needs privilege.
|
||||||
|
local sudo_cmd=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
if ! command -v sudo >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: root privileges are required to install system packages," >&2
|
||||||
|
echo "but sudo is not available. Install the WebKitGTK 6 runtime manually:" >&2
|
||||||
|
print_manual_instructions
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sudo_cmd="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 0c. Distro mapping. ID_LIKE catches derivatives (Mint/Pop -> ubuntu,
|
||||||
|
# Manjaro/EndeavourOS -> arch, Nobara -> fedora) without enumerating them.
|
||||||
|
local os_release="${OS_RELEASE_FILE:-/etc/os-release}"
|
||||||
|
local dist_id="" dist_like=""
|
||||||
|
if [ -r "$os_release" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$os_release"
|
||||||
|
dist_id="${ID:-}"
|
||||||
|
dist_like="${ID_LIKE:-}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
need_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
local ids=" ${dist_id} ${dist_like} "
|
||||||
|
case "$ids" in
|
||||||
|
*" arch "*|*" manjaro "*|*" endeavouros "*|*" garuda "*|*" cachyos "*)
|
||||||
|
need_cmd pacman || { echo "ERROR: pacman not found on Arch-family system." >&2; return 1; }
|
||||||
|
$sudo_cmd pacman -S --needed --noconfirm webkitgtk-6.0 || return 1
|
||||||
|
;;
|
||||||
|
*" debian "*|*" ubuntu "*|*" pop "*|*" linuxmint "*)
|
||||||
|
need_cmd apt-get || { echo "ERROR: apt-get not found on Debian-family system." >&2; return 1; }
|
||||||
|
$sudo_cmd apt-get update && $sudo_cmd apt-get install -y libwebkitgtk-6.0-4 || return 1
|
||||||
|
;;
|
||||||
|
*" fedora "*|*" nobara "*)
|
||||||
|
need_cmd dnf || { echo "ERROR: dnf not found on Fedora-family system." >&2; return 1; }
|
||||||
|
$sudo_cmd dnf install -y webkitgtk6.0 || return 1
|
||||||
|
;;
|
||||||
|
*" openmandriva "*)
|
||||||
|
need_cmd dnf || { echo "ERROR: dnf not found on OpenMandriva system." >&2; return 1; }
|
||||||
|
$sudo_cmd dnf install -y lib64webkit2gtk6.0 || return 1
|
||||||
|
;;
|
||||||
|
*" void "*)
|
||||||
|
need_cmd xbps-install || { echo "ERROR: xbps-install not found on Void system." >&2; return 1; }
|
||||||
|
$sudo_cmd xbps-install -Sy libwebkitgtk60 || return 1
|
||||||
|
;;
|
||||||
|
*" alpine "*)
|
||||||
|
echo "ERROR: Alpine Linux uses musl libc, which the prebuilt glibc-linked binary cannot run on." >&2
|
||||||
|
echo "Build from source instead (see README)." >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: unsupported or unrecognised distribution (ID='${dist_id}' ID_LIKE='${dist_like}')." >&2
|
||||||
|
print_manual_instructions
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
unset -f need_cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
install_webkit_runtime || exit 1
|
||||||
|
|
||||||
# copy desktop file
|
# copy desktop file
|
||||||
if [ ! -f "$HOME/.local/share/applications/AniTrack.desktop" ]; then
|
if [ ! -f "$HOME/.local/share/applications/AniTrack.desktop" ]; then
|
||||||
|
|||||||
+22
-5
@@ -21,7 +21,7 @@ This document summarizes the `wailsv3` branch (Wails `v2.15.0` → `v3.0.0-beta.
|
|||||||
|
|
||||||
**Build + CI:**
|
**Build + CI:**
|
||||||
- `Taskfile.yml` + `build/Taskfile.yml` (stock `common`) + `build/linux/Taskfile.yml` (stock minus `common:generate:icons` — macOS/Windows icon generation targets `build/appicon.png` which this Linux-only project doesn’t have) + `build/config.yml` (AniTrack metadata, `Version 1.6.8`). Root output stays `build/bin/AniTrack` so release packaging is untouched. `wails.json` → `v3` `frontend` block, `info.productVersion` retained.
|
- `Taskfile.yml` + `build/Taskfile.yml` (stock `common`) + `build/linux/Taskfile.yml` (stock minus `common:generate:icons` — macOS/Windows icon generation targets `build/appicon.png` which this Linux-only project doesn’t have) + `build/config.yml` (AniTrack metadata, `Version 1.6.8`). Root output stays `build/bin/AniTrack` so release packaging is untouched. `wails.json` → `v3` `frontend` block, `info.productVersion` retained.
|
||||||
- `Makefile`: `wails dev/build -tags webkit2_41` → `wails3 dev -port 5173` / `wails3 build` (no tags; `v3` defaults to `GTK4/WebKitGTK 6.0`, same `2.52.x` engine generation). Added `.task/` to `.gitignore`. Removed `build/darwin/`, `build/windows/`, `frontend/package.json.md5`. `make release` bumps both `wails.json` and `build/config.yml:info.version` (TEMP until `wails.json` is retired).
|
- `Makefile`: `wails dev/build -tags webkit2_41` → `wails3 dev -port 5173` / `wails3 build` (no tags; `v3` defaults to `GTK4/WebKitGTK 6.0`, same `2.52.x` engine generation). Added `.task/` to `.gitignore`. Removed `build/darwin/`, `build/windows/`, `frontend/package.json.md5`. Version flow: single source `build/config.yml:info.version`, `make release` bumps it and regenerates `version.go` (compiled-in copy) in the same commit — `wails.json` retired entirely (proof: `wails3 build` + `wails3 dev` both green without it; a first-boot `dev` failure turned out to be Vite 8 cold-start vs the app retry budget, fixed by rerunning warm, not by the file).
|
||||||
- `.gitea/workflows/release.yml`: Wails CLI `wails@v2.15.0` → `wails3@v3.0.0-beta.20` (path + cache key), apt `libgtk-3-dev/libwebkit2gtk-4.1-dev` → `libgtk-4-dev/libwebkitgtk-6.0-dev`, `make build` unchanged. Go/npm/go-build/Wails-CLI caches remain valid (npm cache now resolves since `frontend/package-lock.json` is committed).
|
- `.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.
|
**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.
|
In practice: running both versions side-by-side works fine — each reads its own collection, neither overwrites the other. If you fully move to `v3`, just log into AniList / MAL / Simkl once there; the `v2` entries become stale but harmless (11 small items). See the notice in `README.md: Migrating from v2` — no automatic deletion, on purpose.
|
||||||
|
|
||||||
|
## Self-updates (desktop Linux, 1.99.x)
|
||||||
|
|
||||||
|
Releases self-update in place via the Wails v3 updater (`app.Updater`): a
|
||||||
|
custom Gitea provider (`updater/gitea` — Wails ships none for Gitea) checks
|
||||||
|
the Gitea releases API, and CI publishes a signed bare-binary asset
|
||||||
|
(`AniTrack-linux-amd64` + `.sha512`/`.sig` sidecars from
|
||||||
|
`wails3 updater sign`) next to the user tarball. Verification is fail-closed;
|
||||||
|
a release missing any of the three files is skipped. Gated to desktop
|
||||||
|
production builds (`application.System.IsDesktop()` + `-tags production`) —
|
||||||
|
mobile stays on Obtainium, dev builds never check. The startup check is
|
||||||
|
headless; the builtin window opens only when an update is found.
|
||||||
|
`ANITRACK_UPDATER_CHANNEL=beta` includes `-rc` pre-releases (testing only).
|
||||||
|
Only the binary self-swaps; icons/`.desktop` still come from the tarball's
|
||||||
|
`install_linux.sh`. Signing: public half `updater.pub` is embedded; the
|
||||||
|
private key lives in the password manager + the `UPDATER_SIGNING_KEY` CI
|
||||||
|
secret, never in the repo (see `.gitignore`).
|
||||||
|
|
||||||
## Future plans on v3
|
## Future plans on v3
|
||||||
|
|
||||||
**Not yet done — staged after the desktop migration, in order** (each with its home branch, so nothing lands in the wrong place):
|
**Not yet done — staged after the desktop migration, in order** (each with its home branch, so nothing lands in the wrong place):
|
||||||
|
|
||||||
1. **Stabilize desktop `v3` (on `wailsv3` — this branch's whole purpose):** real-world dogfooding (logins, watchlist sync, error modals), then merge to `main` when Wails `v3` hits stable.
|
1. **Stabilize desktop `v3`:** real-world dogfooding of `main` (logins, watchlist sync, error modals). Merged to `main` as the `1.99.x` beta series while Wails `v3` is still beta upstream.
|
||||||
2. **Frontend toolchain refresh (on `wailsv3`, before the merge):** Svelte 4 → 5, Vite 4 → 8, `vite-plugin-svelte 2 → 7`, Tailwind 3 → 4 — majors deferred intentionally; they pair naturally with `v3`’s Svelte 5 templates and touch the same desktop files, so no separate branch.
|
2. **Frontend toolchain refresh:** 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.
|
3. **Android (short-lived feature branch off `wailsv3`, e.g. `wailsv3-android`, merged back):** personal sideload, no Play/official F-Droid. Per-file `//go:build android` shims — `zalando` → `Android.Secure*` (`EncryptedSharedPreferences`), and `localhost:6734` OAuth callbacks → deep-link `anitrack://callback` + intent filter; start with one provider (AniList) to prove the pattern. Responsive / safe-area polish. Distribution via `adb install` + GitHub Releases + Obtainium. Kept off `wailsv3` proper so the mobile scaffolding (`build/android/`, manifests, gradle files) doesn't pollute the desktop-to-`main` merge.
|
||||||
4. **CI follow-ups (wherever the work they support lives):** none required for desktop; the current 4 caches + the fixed runner cache backend already bring releases from ~12 min to ~3 min. Builder image only if the 2 min apt floor becomes annoying.
|
4. **CI follow-ups (wherever the work they support lives):** none required for desktop; the current 4 caches + the fixed runner cache backend already bring releases from ~12 min to ~3 min. Builder image only if the 2 min apt floor becomes annoying.
|
||||||
|
|
||||||
## Branch map
|
## Branch map
|
||||||
|
|
||||||
- `main` — still `v2`, stable, ships `1.6.8` releases.
|
- `main` — v3 desktop, ships `1.99.x` beta-series releases.
|
||||||
- `wailsv3` — `v3` desktop, unpushed until dogfooding passes, ahead of `main` by the commits above plus `2bf8d38` (`frontend/package-lock.json`) and `efe45f3`/`a19080e`/`336a1f3` from the CI/cache work. Merge `main` forward before each `v3` push.
|
- `wailsv3` — retained as an alias tracking `main` for now.
|
||||||
|
|||||||
@@ -8,9 +8,5 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script src="./src/main.ts" type="module"></script>
|
<script src="./src/main.ts" type="module"></script>
|
||||||
<script
|
|
||||||
src="./node_modules/flowbite/dist/flowbite.js"
|
|
||||||
type="module"
|
|
||||||
></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+1465
-2007
File diff suppressed because it is too large
Load Diff
+15
-18
@@ -11,26 +11,23 @@
|
|||||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^2.4.1",
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||||
"@tsconfig/svelte": "^4.0.1",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"@tsconfig/svelte": "^5.0.8",
|
||||||
"postcss": "^8.4.45",
|
"svelte": "^5.57.0",
|
||||||
"svelte": "^4.0.0",
|
"svelte-check": "^4.7.6",
|
||||||
"svelte-check": "^3.4.3",
|
"svelte-spa-router": "^5.1.1",
|
||||||
"svelte-headless-table": "^0.18.3",
|
"tailwind-merge": "^3.7.0",
|
||||||
"svelte-preprocess": "^5.0.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"svelte-spa-router": "^4.0.1",
|
"tslib": "^2.8.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"typescript": "^5.9.3",
|
||||||
"tailwindcss": "^3.4.10",
|
"vite": "^8.3.0"
|
||||||
"tslib": "^2.7.0",
|
|
||||||
"typescript": "^5.0.0",
|
|
||||||
"vite": "^4.5.5"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@wailsio/runtime": "3.0.0-beta.20",
|
"@wailsio/runtime": "3.0.0-beta.22",
|
||||||
"flowbite": "^2.5.1",
|
"flowbite": "^4.0.2",
|
||||||
"flowbite-svelte": "^0.46.16",
|
"flowbite-svelte": "^1.33.1",
|
||||||
"moment": "^2.30.1"
|
"moment": "^2.31.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
+25
-17
@@ -21,13 +21,12 @@
|
|||||||
import { CheckIfMALLoggedInAndSetUser } from "./helperModules/CheckIfMyAnimeListLoggedIn.svelte";
|
import { CheckIfMALLoggedInAndSetUser } from "./helperModules/CheckIfMyAnimeListLoggedIn.svelte";
|
||||||
import { CheckIfSimklLoggedInAndSetUser } from "./helperModules/CheckIsSimklLoggedIn.svelte";
|
import { CheckIfSimklLoggedInAndSetUser } from "./helperModules/CheckIsSimklLoggedIn.svelte";
|
||||||
import {App} from "../bindings/AniTrack";
|
import {App} from "../bindings/AniTrack";
|
||||||
import { loc } from "svelte-spa-router";
|
|
||||||
import ErrorModal from "./helperComponents/ErrorModal.svelte";
|
import ErrorModal from "./helperComponents/ErrorModal.svelte";
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
let isAniListLoggedIn: boolean;
|
let isAniListLoggedIn!: boolean;
|
||||||
let isMALLoggedIn: boolean;
|
let isMALLoggedIn!: boolean;
|
||||||
let isSimklLoggedIn: boolean;
|
let isSimklLoggedIn!: boolean;
|
||||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||||
malLoggedIn.subscribe((value) => (isMALLoggedIn = value));
|
malLoggedIn.subscribe((value) => (isMALLoggedIn = value));
|
||||||
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
|
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
|
||||||
@@ -39,26 +38,35 @@
|
|||||||
!isSimklLoggedIn && (await CheckIfSimklLoggedInAndSetUser());
|
!isSimklLoggedIn && (await CheckIfSimklLoggedInAndSetUser());
|
||||||
});
|
});
|
||||||
|
|
||||||
$: if ($loc?.location === "/" && $watchlistNeedsRefresh) {
|
import { get } from "svelte/store";
|
||||||
(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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
<ErrorModal />
|
<ErrorModal />
|
||||||
<Router
|
<Router
|
||||||
|
onRouteLoaded={(detail) => {
|
||||||
|
void refreshHomeWatchlists(detail.location);
|
||||||
|
}}
|
||||||
routes={{
|
routes={{
|
||||||
"/": Home,
|
"/": Home,
|
||||||
"/anime/:id": wrap({
|
"/anime/:id": wrap({
|
||||||
|
|||||||
@@ -11,22 +11,22 @@
|
|||||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||||
import { push } from "svelte-spa-router";
|
import { push } from "svelte-spa-router";
|
||||||
import WebsiteLink from "./WebsiteLink.svelte";
|
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 Rating from "./Rating.svelte";
|
||||||
import {
|
import {
|
||||||
convertAniListDateToString,
|
convertAniListDateToString,
|
||||||
convertAniListDateToDate,
|
convertAniListDateToDate,
|
||||||
} from "../helperFunctions/convertAniListDateIn";
|
} from "../helperFunctions/convertAniListDateIn";
|
||||||
import AnimeTable from "./AnimeTable.svelte";
|
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 { writable } from "svelte/store";
|
||||||
import type { StatusOption, StatusOptions } from "../helperTypes/StatusTypes";
|
import type { StatusOption, StatusOptions } from "../helperTypes/StatusTypes";
|
||||||
import type { AniListUpdateVariables } from "../anilist/types/AniListTypes";
|
|
||||||
import { convertDateToAniList } from "../helperFunctions/convertDateToAniList";
|
import { convertDateToAniList } from "../helperFunctions/convertDateToAniList";
|
||||||
import {App} from "../../bindings/AniTrack";
|
import {App} from "../../bindings/AniTrack";
|
||||||
import { AddAnimeServiceToTable } from "../helperModules/AddAnimeServiceToTable.svelte";
|
import { AddAnimeServiceToTable } from "../helperModules/AddAnimeServiceToTable.svelte";
|
||||||
@@ -35,12 +35,12 @@
|
|||||||
import { Badge, Tooltip } from "flowbite-svelte";
|
import { Badge, Tooltip } from "flowbite-svelte";
|
||||||
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/;
|
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/;
|
||||||
|
|
||||||
let isAniListLoggedIn: boolean;
|
let isAniListLoggedIn!: boolean;
|
||||||
let isMalLoggedIn: boolean;
|
let isMalLoggedIn!: boolean;
|
||||||
let isSimklLoggedIn: boolean;
|
let isSimklLoggedIn!: boolean;
|
||||||
let currentAniListAnime: AniListGetSingleAnime;
|
let currentAniListAnime!: AniListGetSingleAnime;
|
||||||
let currentMalAnime: MALAnime;
|
let currentMalAnime!: MALAnime;
|
||||||
let currentSimklAnime: SimklAnime;
|
let currentSimklAnime!: SimklAnime;
|
||||||
let submitting = writable(false);
|
let submitting = writable(false);
|
||||||
let isSubmitting: boolean;
|
let isSubmitting: boolean;
|
||||||
let submitSuccess = writable(false);
|
let submitSuccess = writable(false);
|
||||||
@@ -103,11 +103,15 @@
|
|||||||
let finishDate = "";
|
let finishDate = "";
|
||||||
if (currentMalAnime.my_list_status.start_date !== "") {
|
if (currentMalAnime.my_list_status.start_date !== "") {
|
||||||
const startArray = re.exec(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 !== "") {
|
if (currentMalAnime.my_list_status.finish_date !== "") {
|
||||||
const finishArray = re.exec(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({
|
AddAnimeServiceToTable({
|
||||||
id: `m-${currentMalAnime.id}`,
|
id: `m-${currentMalAnime.id}`,
|
||||||
@@ -165,7 +169,9 @@
|
|||||||
for (let field of formData) {
|
for (let field of formData) {
|
||||||
const [key, value] = field;
|
const [key, value] = field;
|
||||||
if (key === "rating") {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (key === "episodes") {
|
if (key === "episodes") {
|
||||||
@@ -180,7 +186,11 @@
|
|||||||
submitData.status = startingAnilistStatusOption;
|
submitData.status = startingAnilistStatusOption;
|
||||||
continue;
|
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 {
|
try {
|
||||||
@@ -271,13 +281,17 @@
|
|||||||
const startArray = re.exec(
|
const startArray = re.exec(
|
||||||
currentMalAnime.my_list_status.start_date,
|
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 !== "") {
|
if (currentMalAnime.my_list_status.finish_date !== "") {
|
||||||
const finishArray = re.exec(
|
const finishArray = re.exec(
|
||||||
currentMalAnime.my_list_status.finish_date,
|
currentMalAnime.my_list_status.finish_date,
|
||||||
);
|
);
|
||||||
finishDate = `${finishArray[2]}-${finishArray[3]}-${finishArray[1]}`;
|
if (finishArray) {
|
||||||
|
finishDate = `${finishArray[2]}-${finishArray[3]}-${finishArray[1]}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AddAnimeServiceToTable({
|
AddAnimeServiceToTable({
|
||||||
id: `m-${currentMalAnime.id}`,
|
id: `m-${currentMalAnime.id}`,
|
||||||
@@ -503,7 +517,7 @@
|
|||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-10 grid-flow-col gap-4">
|
<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
|
<img
|
||||||
class="rounded-lg"
|
class="rounded-lg"
|
||||||
src={currentAniListAnime.data.MediaList.media.coverImage.large}
|
src={currentAniListAnime.data.MediaList.media.coverImage.large}
|
||||||
@@ -525,6 +539,7 @@
|
|||||||
<div class="relative flex items-center max-w-[8rem]">
|
<div class="relative flex items-center max-w-[8rem]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Decrease episode progress"
|
||||||
id="decrement-button"
|
id="decrement-button"
|
||||||
data-input-counter-decrement="quantity-input"
|
data-input-counter-decrement="quantity-input"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
@@ -583,6 +598,7 @@
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Increase episode progress"
|
||||||
id="increment-button"
|
id="increment-button"
|
||||||
data-input-counter-increment="quantity-input"
|
data-input-counter-increment="quantity-input"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
@@ -682,7 +698,7 @@
|
|||||||
>
|
>
|
||||||
<Datepicker
|
<Datepicker
|
||||||
bind:value={startedAtDate}
|
bind:value={startedAtDate}
|
||||||
color="slate"
|
color="gray"
|
||||||
dateFormat={{
|
dateFormat={{
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
@@ -699,7 +715,7 @@
|
|||||||
>
|
>
|
||||||
<Datepicker
|
<Datepicker
|
||||||
bind:value={completedAtDate}
|
bind:value={completedAtDate}
|
||||||
color="slate"
|
color="gray"
|
||||||
dateFormat={{
|
dateFormat={{
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
|
|||||||
@@ -1,118 +1,95 @@
|
|||||||
<script lang="ts">
|
<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 { tableItems } from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||||
|
import type { TableItem } from "../helperTypes/TableTypes";
|
||||||
import WebsiteLink from "./WebsiteLink.svelte";
|
import WebsiteLink from "./WebsiteLink.svelte";
|
||||||
|
|
||||||
//when adding sort here is code { sort: addSortBy() }
|
type SortKey = keyof TableItem;
|
||||||
const table = createTable(tableItems, { sort: addSortBy() });
|
type SortOrder = "asc" | "desc" | undefined;
|
||||||
|
|
||||||
const columns = table.createColumns([
|
const columns: { key: SortKey; header: string }[] = [
|
||||||
table.column({
|
{ key: "id", header: "Service Id" },
|
||||||
header: "Service Id",
|
{ key: "title", header: "Anime Title" },
|
||||||
cell: ({ value }) => createRender(WebsiteLink, { id: value }),
|
{ key: "service", header: "Service" },
|
||||||
accessor: "id",
|
{ key: "progress", header: "Episode Progress" },
|
||||||
}),
|
{ key: "status", header: "Status" },
|
||||||
table.column({
|
{ key: "startedAt", header: "Started At" },
|
||||||
header: "Anime Title",
|
{ key: "completedAt", header: "Completed At" },
|
||||||
accessor: "title",
|
{ key: "score", header: "Rating" },
|
||||||
}),
|
{ key: "repeat", header: "Repeat" },
|
||||||
table.column({
|
{ key: "notes", header: "Notes" },
|
||||||
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",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
//add pluginStates when add sort back
|
let sortKey = $state<SortKey | undefined>(undefined);
|
||||||
const { headerRows, rows, tableAttrs, tableBodyAttrs } =
|
let sortOrder = $state<SortOrder>(undefined);
|
||||||
table.createViewModel(columns);
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="relative overflow-x-auto rounded-lg mb-5">
|
<div class="relative overflow-x-auto rounded-lg mb-5">
|
||||||
<table
|
<table class="w-full text-sm text-left rtl:text-right text-gray-400">
|
||||||
class="w-full text-sm text-left rtl:text-right text-gray-400"
|
|
||||||
{...$tableAttrs}
|
|
||||||
>
|
|
||||||
<thead class="text-xs uppercase bg-gray-700 text-gray-400">
|
<thead class="text-xs uppercase bg-gray-700 text-gray-400">
|
||||||
{#each $headerRows as headerRow (headerRow.id)}
|
<tr>
|
||||||
<Subscribe attrs={headerRow.attrs()} let:attrs>
|
{#each columns as column (column.key)}
|
||||||
<tr {...attrs}>
|
<th
|
||||||
{#each headerRow.cells as cell (cell.id)}
|
onclick={() => toggleSort(column.key)}
|
||||||
<Subscribe
|
class:sorted={sortKey === column.key &&
|
||||||
attrs={cell.attrs()}
|
sortOrder !== undefined}
|
||||||
let:attrs
|
class="px-6 py-3 cursor-pointer"
|
||||||
props={cell.props()}
|
>
|
||||||
let:props
|
<div>
|
||||||
>
|
{column.header}
|
||||||
<th
|
{#if sortKey === column.key && sortOrder === "asc"}
|
||||||
{...attrs}
|
⬇️
|
||||||
on:click={props.sort.toggle}
|
{:else if sortKey === column.key && sortOrder === "desc"}
|
||||||
class:sorted={props.sort.order !==
|
⬆️
|
||||||
undefined}
|
{/if}
|
||||||
class="px-6 py-3"
|
</div>
|
||||||
>
|
</th>
|
||||||
<div>
|
{/each}
|
||||||
<Render of={cell.render()} />
|
</tr>
|
||||||
{#if props.sort.order === "asc"}
|
|
||||||
⬇️
|
|
||||||
{:else if props.sort.order === "desc"}
|
|
||||||
⬆️
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</Subscribe>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
</Subscribe>
|
|
||||||
{/each}
|
|
||||||
</thead>
|
</thead>
|
||||||
<tbody {...$tableBodyAttrs}>
|
<tbody>
|
||||||
{#each $rows as row (row.id)}
|
{#each sortedRows as row (row.id + row.service)}
|
||||||
<Subscribe attrs={row.attrs()} let:attrs>
|
<tr class="bg-gray-800 border-gray-700">
|
||||||
<tr {...attrs} class="bg-gray-800 border-gray-700">
|
{#each columns as column (column.key)}
|
||||||
{#each row.cells as cell (cell.id)}
|
<td class="px-6 py-4">
|
||||||
<Subscribe attrs={cell.attrs()} let:attrs>
|
{#if column.key === "id"}
|
||||||
<td {...attrs} class="px-6 py-4">
|
<WebsiteLink id={row.id} />
|
||||||
<Render of={cell.render()} />
|
{:else}
|
||||||
</td>
|
{row[column.key]}
|
||||||
</Subscribe>
|
{/if}
|
||||||
{/each}
|
</td>
|
||||||
</tr>
|
{/each}
|
||||||
</Subscribe>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar } from "flowbite-svelte";
|
import { Avatar } from "flowbite-svelte";
|
||||||
import type { AniListUser } from "../anilist/types/AniListTypes";
|
import type { AniListUser, MyAnimeListUser, SimklUser } from "../../bindings/AniTrack/models";
|
||||||
import {
|
import {
|
||||||
aniListLoggedIn,
|
aniListLoggedIn,
|
||||||
aniListUser,
|
aniListUser,
|
||||||
@@ -17,16 +17,14 @@
|
|||||||
serviceLoggingIn,
|
serviceLoggingIn,
|
||||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||||
import {Application} from "@wailsio/runtime";
|
import {Application} from "@wailsio/runtime";
|
||||||
import type { MyAnimeListUser } from "../mal/types/MALTypes";
|
import {App} from "../../bindings/AniTrack";
|
||||||
import type { SimklUser } from "../simkl/types/simklTypes";
|
|
||||||
import {App} from "../../bindings/AniTrack";
|
|
||||||
|
|
||||||
let currentAniListUser: AniListUser;
|
let currentAniListUser: AniListUser;
|
||||||
let currentMALUser: MyAnimeListUser;
|
let currentMALUser: MyAnimeListUser;
|
||||||
let currentSimklUser: SimklUser;
|
let currentSimklUser: SimklUser;
|
||||||
let isAniListLoggedIn: boolean;
|
let isAniListLoggedIn!: boolean;
|
||||||
let isSimklLoggedIn: boolean;
|
let isSimklLoggedIn!: boolean;
|
||||||
let isMALLoggedIn: boolean;
|
let isMALLoggedIn!: boolean;
|
||||||
let loggingIn: string[] = [];
|
let loggingIn: string[] = [];
|
||||||
|
|
||||||
aniListUser.subscribe((value) => (currentAniListUser = value));
|
aniListUser.subscribe((value) => (currentAniListUser = value));
|
||||||
@@ -38,7 +36,8 @@
|
|||||||
serviceLoggingIn.subscribe((value) => (loggingIn = value));
|
serviceLoggingIn.subscribe((value) => (loggingIn = value));
|
||||||
|
|
||||||
function dropdownUser(): void {
|
function dropdownUser(): void {
|
||||||
let dropdown = document.querySelector("#userDropdown");
|
const dropdown = document.querySelector("#userDropdown");
|
||||||
|
if (!dropdown) return;
|
||||||
dropdown.classList.toggle("hidden");
|
dropdown.classList.toggle("hidden");
|
||||||
|
|
||||||
if (!dropdown.classList.contains("hidden")) {
|
if (!dropdown.classList.contains("hidden")) {
|
||||||
@@ -47,8 +46,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clickOutside(event: Event): void {
|
function clickOutside(event: Event): void {
|
||||||
let dropdown = document.querySelector("#userDropdown");
|
const dropdown = document.querySelector("#userDropdown");
|
||||||
let toggleBtn = document.querySelector("#userDropdownButton");
|
const toggleBtn = document.querySelector("#userDropdownButton");
|
||||||
|
if (!dropdown || !toggleBtn) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!dropdown.contains(event.target as Node) &&
|
!dropdown.contains(event.target as Node) &&
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { createEventDispatcher, onMount } from "svelte";
|
import { createEventDispatcher, onMount } from "svelte";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { Button } from "flowbite-svelte";
|
import { Button } from "flowbite-svelte";
|
||||||
|
import type { ButtonProps } from "flowbite-svelte";
|
||||||
|
|
||||||
export let value: Date | null = null;
|
export let value: Date | null = null;
|
||||||
export let defaultDate: Date | null = null;
|
export let defaultDate: Date | null = null;
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
export let disabled: boolean = false;
|
export let disabled: boolean = false;
|
||||||
export let required: boolean = false;
|
export let required: boolean = false;
|
||||||
export let inputClass: string = "";
|
export let inputClass: string = "";
|
||||||
export let color: Button["color"] = "primary";
|
export let color: ButtonProps["color"] = "primary";
|
||||||
export let inline: boolean = false;
|
export let inline: boolean = false;
|
||||||
export let autohide: boolean = true;
|
export let autohide: boolean = true;
|
||||||
export let showActionButtons: boolean = false;
|
export let showActionButtons: boolean = false;
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Color handling functions
|
// Color handling functions
|
||||||
function getFocusRingClass(color: Button["color"]): string {
|
function getFocusRingClass(color: ButtonProps["color"]): string {
|
||||||
switch (color) {
|
switch (color) {
|
||||||
case "primary":
|
case "primary":
|
||||||
return "focus:ring-2 focus:ring-primary-400";
|
return "focus:ring-2 focus:ring-primary-400";
|
||||||
@@ -61,14 +62,14 @@
|
|||||||
return "focus:ring-2 focus:ring-yellow-400";
|
return "focus:ring-2 focus:ring-yellow-400";
|
||||||
case "purple":
|
case "purple":
|
||||||
return "focus:ring-2 focus:ring-purple-400";
|
return "focus:ring-2 focus:ring-purple-400";
|
||||||
case "slate":
|
case "gray":
|
||||||
return "focus:ring-2 focus:ring-slate-400";
|
return "focus:ring-2 focus:ring-gray-400";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRangeBackgroundClass(color: Button["color"]): string {
|
function getRangeBackgroundClass(color: ButtonProps["color"]): string {
|
||||||
switch (color) {
|
switch (color) {
|
||||||
case "primary":
|
case "primary":
|
||||||
return "bg-primary-900";
|
return "bg-primary-900";
|
||||||
@@ -82,8 +83,8 @@
|
|||||||
return "bg-yellow-900";
|
return "bg-yellow-900";
|
||||||
case "purple":
|
case "purple":
|
||||||
return "bg-purple-900";
|
return "bg-purple-900";
|
||||||
case "slate":
|
case "gray":
|
||||||
return "bg-slate-900";
|
return "bg-gray-900";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -352,7 +353,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<Button
|
<Button
|
||||||
on:click={() => changeMonth(-1)}
|
onclick={() => changeMonth(-1)}
|
||||||
{color}
|
{color}
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Previous month"
|
aria-label="Previous month"
|
||||||
@@ -382,7 +383,7 @@
|
|||||||
})}
|
})}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
on:click={() => changeMonth(1)}
|
onclick={() => changeMonth(1)}
|
||||||
{color}
|
{color}
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Next month"
|
aria-label="Next month"
|
||||||
@@ -424,8 +425,8 @@
|
|||||||
: ''} {isInRange(day)
|
: ''} {isInRange(day)
|
||||||
? getRangeBackgroundClass(color)
|
? getRangeBackgroundClass(color)
|
||||||
: ''}"
|
: ''}"
|
||||||
on:click={() => handleDaySelect(day)}
|
onclick={() => handleDaySelect(day)}
|
||||||
on:keydown={handleCalendarKeydown}
|
onkeydown={handleCalendarKeydown}
|
||||||
aria-label={day.toLocaleDateString(locale, {
|
aria-label={day.toLocaleDateString(locale, {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -441,13 +442,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if showActionButtons}
|
{#if showActionButtons}
|
||||||
<div class="mt-4 flex justify-between">
|
<div class="mt-4 flex justify-between">
|
||||||
<Button on:click={handleToday} {color} size="sm"
|
<Button onclick={handleToday} {color} size="sm"
|
||||||
>Today</Button
|
>Today</Button
|
||||||
>
|
>
|
||||||
<Button on:click={handleClear} color="red" size="sm"
|
<Button onclick={handleClear} color="red" size="sm"
|
||||||
>Clear</Button
|
>Clear</Button
|
||||||
>
|
>
|
||||||
<Button on:click={handleApply} {color} size="sm"
|
<Button onclick={handleApply} {color} size="sm"
|
||||||
>Apply</Button
|
>Apply</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,13 +61,15 @@
|
|||||||
dismiss this message to continue with limited functionality.
|
dismiss this message to continue with limited functionality.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div slot="footer" class="flex gap-3 justify-end">
|
{#snippet footer()}
|
||||||
{#if $apiError.canRetry}
|
<div class="flex gap-3 justify-end">
|
||||||
<Button on:click={handleRetry} class="bg-blue-600 hover:bg-blue-700">
|
{#if $apiError.canRetry}
|
||||||
Retry Connection
|
<Button onclick={handleRetry} class="bg-blue-600 hover:bg-blue-700">
|
||||||
</Button>
|
Retry Connection
|
||||||
{/if}
|
</Button>
|
||||||
<Button on:click={handleDismiss} color="alternative">Dismiss</Button>
|
{/if}
|
||||||
</div>
|
<Button onclick={handleDismiss} color="alternative">Dismiss</Button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
import logo from "../assets/images/AniTrackLogo.svg";
|
import logo from "../assets/images/AniTrackLogo.svg";
|
||||||
import { link } from "svelte-spa-router";
|
import { link } from "svelte-spa-router";
|
||||||
|
|
||||||
let isAniListLoggedIn: boolean;
|
let isAniListLoggedIn!: boolean;
|
||||||
let isSimklLoggedIn: boolean;
|
let isSimklLoggedIn!: boolean;
|
||||||
let isMALLoggedIn: boolean;
|
let isMALLoggedIn!: boolean;
|
||||||
let loggingIn: string[] = [];
|
let loggingIn: string[] = [];
|
||||||
|
|
||||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||||
@@ -34,19 +34,20 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<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 />
|
<Search />
|
||||||
</div>
|
</div>
|
||||||
<AvatarMenu />
|
<AvatarMenu />
|
||||||
<button
|
<button
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
let menu = document.querySelector("#navbar-user");
|
const menu = document.querySelector("#navbar-user");
|
||||||
|
if (!menu) return;
|
||||||
menu.classList.toggle("hidden");
|
menu.classList.toggle("hidden");
|
||||||
}}
|
}}
|
||||||
type="button"
|
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-controls="navbar-user"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
>
|
>
|
||||||
@@ -69,11 +70,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<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"
|
id="navbar-user"
|
||||||
>
|
>
|
||||||
<ul
|
<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>
|
<li>
|
||||||
{#if !isAniListLoggedIn}
|
{#if !isAniListLoggedIn}
|
||||||
@@ -125,7 +126,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="flex justify-center min-[950px]:hidden">
|
<div class="flex justify-center sm:hidden">
|
||||||
<Search />
|
<Search />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
watchListPage,
|
watchListPage,
|
||||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||||
|
|
||||||
import type { AniListCurrentUserWatchList } from "../anilist/types/AniListCurrentUserWatchListType";
|
import type { AniListCurrentUserWatchList } from "../../bindings/AniTrack/models";
|
||||||
import {App} from "../../bindings/AniTrack";
|
import {App} from "../../bindings/AniTrack";
|
||||||
|
|
||||||
let aniListWatchListLoaded: AniListCurrentUserWatchList;
|
let aniListWatchListLoaded: AniListCurrentUserWatchList;
|
||||||
let page: number;
|
let page: number;
|
||||||
let perPage: number;
|
let perPage: number;
|
||||||
let sort: string;
|
let sort!: string;
|
||||||
|
|
||||||
watchListPage.subscribe((value) => (page = value));
|
watchListPage.subscribe((value) => (page = value));
|
||||||
animePerPage.subscribe((value) => (perPage = value));
|
animePerPage.subscribe((value) => (perPage = value));
|
||||||
@@ -43,14 +43,14 @@
|
|||||||
function changeCountPerPage(
|
function changeCountPerPage(
|
||||||
e: Event & { currentTarget: HTMLSelectElement },
|
e: Event & { currentTarget: HTMLSelectElement },
|
||||||
): void {
|
): void {
|
||||||
App.GetAniListUserWatchingList(1, Number(e.currentTarget.value), sort).then(
|
// Read synchronously: Svelte 5 nulls currentTarget after dispatch.
|
||||||
(result) => {
|
const count = Number(e.currentTarget.value);
|
||||||
animePerPage.set(Number(e.currentTarget.value));
|
App.GetAniListUserWatchingList(1, count, sort).then((result) => {
|
||||||
watchListPage.set(1);
|
animePerPage.set(count);
|
||||||
aniListWatchlist.set(result);
|
watchListPage.set(1);
|
||||||
aniListLoggedIn.set(true);
|
aniListWatchlist.set(result);
|
||||||
},
|
aniListLoggedIn.set(true);
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -151,6 +151,7 @@
|
|||||||
<div class="relative flex items-center max-w-[11rem]">
|
<div class="relative flex items-center max-w-[11rem]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Previous page"
|
||||||
id="decrement-button"
|
id="decrement-button"
|
||||||
on:click={() => ChangeWatchListPage(page - 1)}
|
on:click={() => ChangeWatchListPage(page - 1)}
|
||||||
class={page <= 1
|
class={page <= 1
|
||||||
@@ -191,6 +192,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Next page"
|
||||||
id="increment-button"
|
id="increment-button"
|
||||||
on:click={() => ChangeWatchListPage(page + 1)}
|
on:click={() => ChangeWatchListPage(page + 1)}
|
||||||
class={page >= aniListWatchListLoaded.data.Page.pageInfo.lastPage
|
class={page >= aniListWatchListLoaded.data.Page.pageInfo.lastPage
|
||||||
|
|||||||
@@ -1,30 +1,9 @@
|
|||||||
<script lang="ts">
|
<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 = {
|
const ratingInWords: Record<number, string> = {
|
||||||
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 = {
|
|
||||||
0: "Not Reviewed",
|
0: "Not Reviewed",
|
||||||
1: "Appalling",
|
1: "Appalling",
|
||||||
2: "Horrible",
|
2: "Horrible",
|
||||||
@@ -37,14 +16,10 @@
|
|||||||
9: "Great",
|
9: "Great",
|
||||||
10: "Masterpiece",
|
10: "Masterpiece",
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeRating = (e: any) => {
|
|
||||||
score = e.target.valueAsNumber * 2
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<StarRatting bind:config on:change={changeRating}/>
|
<StarInput bind:value={score} min={0} max={10} step={1} name="rating" />
|
||||||
<p>Rating: {config.score * 2}</p>
|
<p>Rating: {score}</p>
|
||||||
<p>{ratingInWords[config.score * 2]}</p>
|
<p>{ratingInWords[score]}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
{ value: MediaListSort.MediaPopularityDesc, name: "Media Popularity Desc" },
|
{ value: MediaListSort.MediaPopularityDesc, name: "Media Popularity Desc" },
|
||||||
];
|
];
|
||||||
|
|
||||||
let sort: string;
|
let sort!: string;
|
||||||
aniListSort.subscribe((value) => (sort = value));
|
aniListSort.subscribe((value) => (sort = value));
|
||||||
console.log(sort);
|
console.log(sort);
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
loading,
|
loading,
|
||||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||||
import { push } from "svelte-spa-router";
|
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 { Rating } from "flowbite-svelte";
|
||||||
import loader from "../helperFunctions/loader";
|
import loader from "../helperFunctions/loader";
|
||||||
import { CheckIfAniListLoggedInAndLoadWatchList } from "../helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
|
import { CheckIfAniListLoggedInAndLoadWatchList } from "../helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
|
||||||
import Sort from "../helperComponents/Sort.svelte";
|
import Sort from "../helperComponents/Sort.svelte";
|
||||||
import RefreshWatchListButton from "./RefreshWatchListButton.svelte";
|
import RefreshWatchListButton from "./RefreshWatchListButton.svelte";
|
||||||
|
|
||||||
let isAniListLoggedIn: boolean;
|
let isAniListLoggedIn!: boolean;
|
||||||
let aniListWatchListLoaded: AniListCurrentUserWatchList;
|
let aniListWatchListLoaded: AniListCurrentUserWatchList;
|
||||||
|
|
||||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
isAniList = id.includes("a-");
|
isAniList = id.includes("a-");
|
||||||
isMAL = id.includes("m-");
|
isMAL = id.includes("m-");
|
||||||
isSimkl = id.includes("s-");
|
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;
|
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 = {
|
export const AniListGetSingleAnimeDefaultData: AniListGetSingleAnime = {
|
||||||
data: {
|
data: {
|
||||||
@@ -10,23 +10,57 @@ export const AniListGetSingleAnimeDefaultData: AniListGetSingleAnime = {
|
|||||||
id: 0,
|
id: 0,
|
||||||
idMal: 0,
|
idMal: 0,
|
||||||
title: {
|
title: {
|
||||||
|
userPreferred: "",
|
||||||
romaji: "",
|
romaji: "",
|
||||||
english: "",
|
english: "",
|
||||||
native: "",
|
native: "",
|
||||||
},
|
},
|
||||||
description: "",
|
description: "",
|
||||||
coverImage: {
|
coverImage: {
|
||||||
|
ExtraLarge: "",
|
||||||
large: "",
|
large: "",
|
||||||
|
Medium: "",
|
||||||
|
Color: "",
|
||||||
},
|
},
|
||||||
|
BannerImage: "",
|
||||||
|
Format: "",
|
||||||
season: "",
|
season: "",
|
||||||
seasonYear: 0,
|
seasonYear: 0,
|
||||||
status: "",
|
status: "",
|
||||||
episodes: 0,
|
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: {
|
nextAiringEpisode: {
|
||||||
airingAt: 0,
|
airingAt: 0,
|
||||||
timeUntilAiring: 0,
|
timeUntilAiring: 0,
|
||||||
episode: 0,
|
episode: 0,
|
||||||
}
|
},
|
||||||
|
airingSchedule: {
|
||||||
|
nodes: null,
|
||||||
|
},
|
||||||
|
genres: [],
|
||||||
|
tags: [],
|
||||||
|
isAdult: false,
|
||||||
},
|
},
|
||||||
status: "",
|
status: "",
|
||||||
startedAt: {
|
startedAt: {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const convertAniListDateToString = (date: {
|
|||||||
) {
|
) {
|
||||||
return "";
|
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);
|
const newMoment = moment(newISODate);
|
||||||
return newMoment.format("MM-DD-YYYY");
|
return newMoment.format("MM-DD-YYYY");
|
||||||
};
|
};
|
||||||
@@ -31,7 +31,7 @@ const convertAniListDateToDate = (date: {
|
|||||||
) {
|
) {
|
||||||
return null;
|
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 };
|
export { convertAniListDateToString, convertAniListDateToDate };
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ const convertDateStringToAniList = (date: string): AnilistDate => {
|
|||||||
}
|
}
|
||||||
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/;
|
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/;
|
||||||
const newDate = re.exec(date);
|
const newDate = re.exec(date);
|
||||||
|
if (newDate === null) {
|
||||||
|
return {
|
||||||
|
year: 0,
|
||||||
|
month: 0,
|
||||||
|
day: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
year: Number(newDate[1]),
|
year: Number(newDate[1]),
|
||||||
month: Number(newDate[2]),
|
month: Number(newDate[2]),
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
|
import { mount, unmount } from 'svelte';
|
||||||
import Spinner from '../helperComponents/Spinner.svelte';
|
import Spinner from '../helperComponents/Spinner.svelte';
|
||||||
|
|
||||||
export default (node: any, loading: any) => {
|
export default (node: any, loading: any) => {
|
||||||
let Spin: any
|
let app: any;
|
||||||
loading.subscribe((loading: any) => {
|
const unsubscribe = loading.subscribe((isLoading: any) => {
|
||||||
if(loading){
|
if (isLoading && !app) {
|
||||||
Spin = new Spinner({
|
app = mount(Spinner, {
|
||||||
target: node,
|
target: node,
|
||||||
intro: true
|
intro: true
|
||||||
})
|
});
|
||||||
} else {
|
} else if (!isLoading && app) {
|
||||||
if(Spin){
|
const current = app;
|
||||||
Spin?.$destroy?.()
|
app = undefined;
|
||||||
Spin = 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"
|
import { tableItems } from "./GlobalVariablesAndHelperFunctions.svelte"
|
||||||
|
|
||||||
export function AddAnimeServiceToTable(animeItem: TableItem) {
|
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) => {
|
tableItems.update((table) => {
|
||||||
if (table.length === 0) {
|
const index = table.findIndex(
|
||||||
table.push(animeItem)
|
(tableItem) => tableItem.service === animeItem.service,
|
||||||
} else {
|
);
|
||||||
for (const [index, tableItem] of table.entries()) {
|
if (index === -1) {
|
||||||
if(tableItem.service === animeItem.service) {
|
return [...table, animeItem];
|
||||||
table[index] = animeItem
|
|
||||||
return table
|
|
||||||
}
|
|
||||||
}
|
|
||||||
table.push(animeItem)
|
|
||||||
}
|
}
|
||||||
return table
|
return table.map((tableItem, i) =>
|
||||||
|
i === index ? animeItem : tableItem,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
let isAniListPrimary: boolean;
|
let isAniListPrimary: boolean;
|
||||||
let page: number;
|
let page: number;
|
||||||
let perPage: number;
|
let perPage: number;
|
||||||
let sort: string;
|
let sort!: string;
|
||||||
|
|
||||||
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
||||||
watchListPage.subscribe((value) => (page = value));
|
watchListPage.subscribe((value) => (page = value));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" context="module">
|
<script lang="ts" context="module">
|
||||||
import {App} from "../../bindings/AniTrack";
|
import {App} from "../../bindings/AniTrack";
|
||||||
import {malUser, malPrimary, malWatchList, malLoggedIn, serviceLoggingIn} from "./GlobalVariablesAndHelperFunctions.svelte"
|
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
|
let isMalPrimary: boolean
|
||||||
malPrimary.subscribe(value => isMalPrimary = value)
|
malPrimary.subscribe(value => isMalPrimary = value)
|
||||||
|
|||||||
@@ -3,22 +3,16 @@
|
|||||||
import type {
|
import type {
|
||||||
AniListCurrentUserWatchList,
|
AniListCurrentUserWatchList,
|
||||||
AniListGetSingleAnime,
|
AniListGetSingleAnime,
|
||||||
} from "../anilist/types/AniListCurrentUserWatchListType.js";
|
AniListUser,
|
||||||
import { writable } from "svelte/store";
|
|
||||||
import type {
|
|
||||||
SimklAnime,
|
|
||||||
SimklUser,
|
|
||||||
SimklWatchList,
|
|
||||||
} from "../simkl/types/simklTypes";
|
|
||||||
import {
|
|
||||||
type AniListUser,
|
|
||||||
MediaListSort,
|
|
||||||
} from "../anilist/types/AniListTypes";
|
|
||||||
import type {
|
|
||||||
MALAnime,
|
MALAnime,
|
||||||
MALWatchlist,
|
MALWatchlist,
|
||||||
MyAnimeListUser,
|
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 type { TableItems } from "../helperTypes/TableTypes";
|
||||||
import { AniListGetSingleAnimeDefaultData } from "../helperDefaults/AniListGetSingleAnime";
|
import { AniListGetSingleAnimeDefaultData } from "../helperDefaults/AniListGetSingleAnime";
|
||||||
|
|
||||||
@@ -28,7 +22,7 @@
|
|||||||
export let simklLoggedIn = writable(false);
|
export let simklLoggedIn = writable(false);
|
||||||
export let malLoggedIn = writable(false);
|
export let malLoggedIn = writable(false);
|
||||||
export const serviceLoggingIn = writable([] as string[]);
|
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 aniListPrimary = writable(true);
|
||||||
export let simklPrimary = writable(false);
|
export let simklPrimary = writable(false);
|
||||||
export let malPrimary = writable(false);
|
export let malPrimary = writable(false);
|
||||||
@@ -50,12 +44,12 @@
|
|||||||
let isAniListPrimary: boolean;
|
let isAniListPrimary: boolean;
|
||||||
let page: number;
|
let page: number;
|
||||||
let perPage: number;
|
let perPage: number;
|
||||||
let sort: string;
|
let sort!: string;
|
||||||
let aniWatchlist: AniListCurrentUserWatchList;
|
let aniWatchlist: AniListCurrentUserWatchList;
|
||||||
let currentAniListAnime: AniListGetSingleAnime;
|
let currentAniListAnime!: AniListGetSingleAnime;
|
||||||
|
|
||||||
let isMalLoggedIn: boolean;
|
let isMalLoggedIn!: boolean;
|
||||||
let isSimklLoggedIn: boolean;
|
let isSimklLoggedIn!: boolean;
|
||||||
|
|
||||||
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
||||||
watchListPage.subscribe((value) => (page = value));
|
watchListPage.subscribe((value) => (page = value));
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
|
import { mount } from 'svelte'
|
||||||
import App from './App.svelte'
|
import App from './App.svelte'
|
||||||
|
|
||||||
const app = new App({
|
const app = mount(App, {
|
||||||
target: document.getElementById('app')
|
target: document.getElementById('app')!
|
||||||
})
|
})
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import loader from "../helperFunctions/loader";
|
import loader from "../helperFunctions/loader";
|
||||||
|
|
||||||
let isAniListPrimary: boolean;
|
let isAniListPrimary: boolean;
|
||||||
let isAniListLoggedIn: boolean;
|
let isAniListLoggedIn!: boolean;
|
||||||
|
|
||||||
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
||||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<!-- Dumb reusable star-rating input. No stores, no backend knowledge:
|
||||||
|
`value` is written live on hover (reverted on leave unless committed)
|
||||||
|
and set on click/keyboard; the parent owns what the value means
|
||||||
|
(words, persistence). Reuses the Star leaf, so output is pixel-identical
|
||||||
|
to the old overlay-slider version without the overlay. -->
|
||||||
|
<script lang="ts">
|
||||||
|
import Star from './components/Star.svelte';
|
||||||
|
|
||||||
|
type StarColors = {
|
||||||
|
size: number;
|
||||||
|
fillColor: string;
|
||||||
|
strokeColor: string;
|
||||||
|
unfilledColor: string;
|
||||||
|
strokeUnfilledColor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(0),
|
||||||
|
min = 0,
|
||||||
|
max = 5,
|
||||||
|
step = 0.5,
|
||||||
|
count = 5,
|
||||||
|
disabled = false,
|
||||||
|
name,
|
||||||
|
starConfig = {
|
||||||
|
size: 32,
|
||||||
|
fillColor: '#F9ED4F',
|
||||||
|
strokeColor: '#BB8511',
|
||||||
|
unfilledColor: '#FFF',
|
||||||
|
strokeUnfilledColor: '#000'
|
||||||
|
},
|
||||||
|
onchange,
|
||||||
|
}: {
|
||||||
|
value?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
count?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
// When set, renders a hidden input so the value submits with a
|
||||||
|
// surrounding <form> via FormData (the old slider exposed
|
||||||
|
// name="rating" the same way).
|
||||||
|
name?: string;
|
||||||
|
starConfig?: StarColors;
|
||||||
|
onchange?: (value: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Value at hover entry; restored on leave unless committed. Null = idle.
|
||||||
|
let baseline: number | null = $state(null);
|
||||||
|
|
||||||
|
function clamp(v: number): number {
|
||||||
|
return Math.min(max, Math.max(min, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snap(v: number): number {
|
||||||
|
const snapped = min + Math.round((v - min) / step) * step;
|
||||||
|
return clamp(Math.round(snapped * 1e6) / 1e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continuous value for cursor position within star `index`.
|
||||||
|
function valueAt(index: number, fraction: number): number {
|
||||||
|
if (index === 0 && fraction < 0.2) return min;
|
||||||
|
const f = fraction >= 0.5 ? 1 : 0.5;
|
||||||
|
return snap(min + ((index + f) / count) * (max - min));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fractionOf(e: MouseEvent, el: HTMLElement): number {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0) return 1;
|
||||||
|
return Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillOf(index: number): number {
|
||||||
|
const per = (max - min) / count;
|
||||||
|
return Math.min(1, Math.max(0, (value - min - index * per) / per));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHoverMove(e: MouseEvent, el: HTMLElement, index: number) {
|
||||||
|
if (disabled) return;
|
||||||
|
if (baseline === null) baseline = value;
|
||||||
|
value = valueAt(index, fractionOf(e, el));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHoverLeave() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (baseline !== null) {
|
||||||
|
value = baseline;
|
||||||
|
baseline = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(v: number) {
|
||||||
|
if (disabled) return;
|
||||||
|
value = clamp(v);
|
||||||
|
baseline = value;
|
||||||
|
onchange?.(value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="stars-container"
|
||||||
|
role="group"
|
||||||
|
aria-label="Star rating"
|
||||||
|
onmouseleave={onHoverLeave}
|
||||||
|
>
|
||||||
|
<div class="stars">
|
||||||
|
{#if name !== undefined}
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
{/if}
|
||||||
|
{#each Array(count) as _, i}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center p-0 border-0 bg-transparent"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Rate ${i + 1} out of ${count}`}
|
||||||
|
onmousemove={(e) => onHoverMove(e, e.currentTarget, i)}
|
||||||
|
onclick={() => commit(value)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
commit(value + step);
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
commit(value - step);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
id={`star-${i}`}
|
||||||
|
readOnly={disabled}
|
||||||
|
starConfig={starConfig}
|
||||||
|
fillPercentage={fillOf(i)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stars-container{ position: relative; display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
||||||
|
.stars{ display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
||||||
|
</style>
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<!-- Originally from @ernane/svelte-star-rating. Wanted to give credit but could not use from the library without causing program crash. -->
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import Star from './components/Star.svelte';
|
|
||||||
export let config = {
|
|
||||||
readOnly: false,
|
|
||||||
countStars: 5,
|
|
||||||
range: { min: 0, max: 5, step: 0.001 },
|
|
||||||
score: 0.0,
|
|
||||||
showScore: true,
|
|
||||||
name: "stars",
|
|
||||||
scoreFormat: function(){ return `(${this.score.toFixed(0)}/${this.countStars})` },
|
|
||||||
starConfig: {
|
|
||||||
size: 30,
|
|
||||||
fillColor: '#F9ED4F',
|
|
||||||
strokeColor: "#BB8511",
|
|
||||||
unfilledColor: '#FFF',
|
|
||||||
strokeUnfilledColor: '#000'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="stars-container">
|
|
||||||
<div class="range-stars">
|
|
||||||
<div class="stars">
|
|
||||||
{#each Array(config.countStars) as star, id}
|
|
||||||
{#if Math.floor(config.score) === id}
|
|
||||||
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={config.score - Math.floor(config.score)}/>
|
|
||||||
{:else if Math.floor(config.score) > id}
|
|
||||||
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={1}/>
|
|
||||||
{:else}
|
|
||||||
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={0}/>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<input name={config.name}
|
|
||||||
class="slider"
|
|
||||||
type="range"
|
|
||||||
min={config.readOnly ? config.score : config.range.min}
|
|
||||||
max={config.readOnly ? config.score : config.range.max}
|
|
||||||
step="{config.range.step}" bind:value={config.score}
|
|
||||||
on:change
|
|
||||||
on:click
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{#if config.showScore}
|
|
||||||
<span class="show-score" style="font-size: {config.starConfig.size/2}px;">
|
|
||||||
{#if config.scoreFormat}
|
|
||||||
{config.scoreFormat()}
|
|
||||||
{:else}
|
|
||||||
({((config.score/config.countStars)*100).toFixed(2)}%)
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.stars-container{ position: relative; display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
|
||||||
.range-stars{ position: relative; }
|
|
||||||
.stars{ display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
|
||||||
.slider{ opacity: 0; cursor: pointer; position: absolute; top: 0; left: 0; right: 0; height: 100%; }
|
|
||||||
.show-score{ user-select: none; color: #888 }
|
|
||||||
</style>
|
|
||||||
+57
-3
@@ -1,6 +1,60 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@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 {
|
html {
|
||||||
background-color: rgba(27, 38, 54, 1);
|
background-color: rgba(27, 38, 54, 1);
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import sveltePreprocess from 'svelte-preprocess'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Consult https://github.com/sveltejs/svelte-preprocess
|
|
||||||
// for more information about preprocessors
|
|
||||||
preprocess: sveltePreprocess()
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import flowbitePlugin from 'flowbite/plugin'
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{svelte,js,ts,jsx,tsx}",
|
|
||||||
"./node_modules/flowbite/**/*.{html,js,svelte,ts}",
|
|
||||||
"./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}",
|
|
||||||
],
|
|
||||||
plugins: [ flowbitePlugin ],
|
|
||||||
|
|
||||||
darkMode: 'media',
|
|
||||||
|
|
||||||
theme: {
|
|
||||||
container: {
|
|
||||||
center: true,
|
|
||||||
padding: {
|
|
||||||
DEFAULT: '1rem',
|
|
||||||
sm: '2rem',
|
|
||||||
lg: '4rem',
|
|
||||||
xl: '5rem',
|
|
||||||
'2xl': '6rem',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
// flowbite-svelte
|
|
||||||
primary: {
|
|
||||||
50: '#FFF5F2',
|
|
||||||
100: '#FFF1EE',
|
|
||||||
200: '#FFE4DE',
|
|
||||||
300: '#FFD5CC',
|
|
||||||
400: '#FFBCAD',
|
|
||||||
500: '#FE795D',
|
|
||||||
600: '#EF562F',
|
|
||||||
700: '#EB4F27',
|
|
||||||
800: '#CC4522',
|
|
||||||
900: '#A5371B'
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {defineConfig} from 'vite'
|
import {defineConfig} from 'vite'
|
||||||
import {svelte} from '@sveltejs/vite-plugin-svelte'
|
import {svelte} from '@sveltejs/vite-plugin-svelte'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import wails from '@wailsio/runtime/plugins/vite'
|
import wails from '@wailsio/runtime/plugins/vite'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
@@ -9,5 +10,5 @@ export default defineConfig({
|
|||||||
port: Number(process.env.WAILS_VITE_PORT) || 5173,
|
port: Number(process.env.WAILS_VITE_PORT) || 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
plugins: [svelte(), wails('./bindings')]
|
plugins: [tailwindcss(), svelte(), wails('./bindings')]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ module AniTrack
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/tidwall/gjson v1.19.0
|
github.com/wailsapp/wails/v3 v3.0.0-beta.22
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-beta.20
|
|
||||||
github.com/zalando/go-keyring v0.2.8
|
github.com/zalando/go-keyring v0.2.8
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +15,5 @@ require (
|
|||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // 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
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,14 +26,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
|||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
github.com/wailsapp/wails/v3 v3.0.0-beta.22 h1:GsdqwRB7nESQyeZKVv16DLIS30xCLMlZP/8UhgKnHYc=
|
||||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
github.com/wailsapp/wails/v3 v3.0.0-beta.22/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
|
||||||
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/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
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=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func main() {
|
|||||||
svc = NewApp(app)
|
svc = NewApp(app)
|
||||||
app.RegisterService(application.NewService(svc))
|
app.RegisterService(application.NewService(svc))
|
||||||
|
|
||||||
|
// Self-updates: desktop production builds only (no-op elsewhere).
|
||||||
|
maybeEnableUpdater(app)
|
||||||
|
|
||||||
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
Title: appTitle(),
|
Title: appTitle(),
|
||||||
Width: 1024,
|
Width: 1024,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# instead of:
|
# instead of:
|
||||||
# make release VERSION=1.6.7
|
# make release VERSION=1.6.7
|
||||||
# Lives in the repo (no machine-specific alias needed). Tags are plain
|
# 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
|
set -eu
|
||||||
|
|
||||||
[ "$#" -ge 1 ] || { echo "Usage: ./release 1.6.7" >&2; exit 1; }
|
[ "$#" -ge 1 ] || { echo "Usage: ./release 1.6.7" >&2; exit 1; }
|
||||||
|
|||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/updater"
|
||||||
|
|
||||||
|
giteaprovider "AniTrack/updater/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed updater.pub
|
||||||
|
var updaterPublicKey []byte
|
||||||
|
|
||||||
|
// maybeEnableUpdater wires self-updates: desktop production builds only.
|
||||||
|
// Mobile stays on Obtainium; dev builds never phone home. The startup check
|
||||||
|
// is headless — the builtin window opens only when an update is found, via a
|
||||||
|
// second CheckAndInstall (one redundant index round-trip, negligible).
|
||||||
|
func maybeEnableUpdater(app *application.App) {
|
||||||
|
if !updaterAutoCheck {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !application.System.IsDesktop() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gh, err := giteaprovider.New(giteaprovider.Config{
|
||||||
|
BaseURL: "https://git.linuxhg.com",
|
||||||
|
Owner: "john-okeefe",
|
||||||
|
Repo: "Anitrack",
|
||||||
|
AssetName: "AniTrack-linux-amd64",
|
||||||
|
// Beta channel (rc tags) for testing: ANITRACK_UPDATER_CHANNEL=beta.
|
||||||
|
AllowPrerelease: os.Getenv("ANITRACK_UPDATER_CHANNEL") == "beta",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("updater: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.Updater.Init(updater.Config{
|
||||||
|
CurrentVersion: appVersion(),
|
||||||
|
Providers: []updater.Provider{gh},
|
||||||
|
PublicKey: updaterPublicKey,
|
||||||
|
Window: &updater.BuiltinWindow{
|
||||||
|
CSS: ":root { --accent: #4d9fff; }",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("updater: init: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
rel, err := app.Updater.Check(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("updater: check: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("updater: %s available, opening installer", rel.Version)
|
||||||
|
if err := app.Updater.CheckAndInstall(context.Background()); err != nil {
|
||||||
|
log.Printf("updater: install: %s", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAWWF3cDxJKM668FqWsFsA9e7r7aXU2uDTUF+ZKKcxLP0=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// Package gitea implements updater.Provider for Gitea releases.
|
||||||
|
//
|
||||||
|
// Releases live on a self-hosted Gitea instance, for which Wails ships no
|
||||||
|
// in-tree provider, so this mirrors the in-tree github provider against
|
||||||
|
// Gitea's releases API. Everything else (verification, staging, swap,
|
||||||
|
// restart, window) stays the framework's job.
|
||||||
|
//
|
||||||
|
// Asset layout per release (see .gitea/workflows/release.yml):
|
||||||
|
//
|
||||||
|
// AniTrack-linux-amd64 bare updater binary, exact name match
|
||||||
|
// AniTrack-linux-amd64.sha512 "<hex> <filename>" (sha512sum format)
|
||||||
|
// AniTrack-linux-amd64.sig base64 ed25519ph signature from
|
||||||
|
// `wails3 updater sign`
|
||||||
|
//
|
||||||
|
// A release missing any of the three is skipped: verification fails closed.
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures the Gitea provider.
|
||||||
|
type Config struct {
|
||||||
|
// BaseURL is the instance root, e.g. https://git.linuxhg.com.
|
||||||
|
BaseURL string
|
||||||
|
// Owner and Repo identify the repository, e.g. john-okeefe/Anitrack.
|
||||||
|
Owner string
|
||||||
|
Repo string
|
||||||
|
// AssetName is the exact updater artifact name, e.g. AniTrack-linux-amd64.
|
||||||
|
AssetName string
|
||||||
|
// HTTPClient overrides the default client (30s timeout). Optional.
|
||||||
|
HTTPClient *http.Client
|
||||||
|
// AllowPrerelease includes pre-release-tagged releases. Default false:
|
||||||
|
// -rc tags stay invisible (stable channel). Beta/test flows opt in.
|
||||||
|
AllowPrerelease bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is an updater.Provider backed by Gitea releases.
|
||||||
|
type Provider struct {
|
||||||
|
cfg Config
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New validates config and returns a Provider.
|
||||||
|
func New(cfg Config) (*Provider, error) {
|
||||||
|
cfg.BaseURL = strings.TrimSuffix(cfg.BaseURL, "/")
|
||||||
|
if cfg.BaseURL == "" || cfg.Owner == "" || cfg.Repo == "" || cfg.AssetName == "" {
|
||||||
|
return nil, fmt.Errorf("gitea: BaseURL, Owner, Repo and AssetName are all required")
|
||||||
|
}
|
||||||
|
client := cfg.HTTPClient
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
return &Provider{cfg: cfg, client: client}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name identifies the provider in logs and event payloads.
|
||||||
|
func (p *Provider) Name() string { return "gitea" }
|
||||||
|
|
||||||
|
// giteaRelease mirrors the fields we read from the Gitea releases API.
|
||||||
|
type giteaRelease struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
PublishedAt string `json:"published_at"`
|
||||||
|
Assets []giteaAsset `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// giteaAsset mirrors one release attachment.
|
||||||
|
type giteaAsset struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check finds the newest usable release newer than req.CurrentVersion.
|
||||||
|
// (nil, nil) means up-to-date.
|
||||||
|
func (p *Provider) Check(ctx context.Context, req updater.CheckRequest) (*updater.Release, error) {
|
||||||
|
releases, err := p.listReleases(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var best *giteaRelease
|
||||||
|
var bestAsset *giteaAsset
|
||||||
|
for i := range releases {
|
||||||
|
r := &releases[i]
|
||||||
|
if r.Draft {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Prerelease && !p.cfg.AllowPrerelease {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if compareSemver(r.TagName, req.CurrentVersion) <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
asset := findAsset(r.Assets, p.cfg.AssetName)
|
||||||
|
if asset == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if best == nil || compareSemver(r.TagName, best.TagName) > 0 {
|
||||||
|
best = r
|
||||||
|
bestAsset = asset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
verification, err := p.fetchVerification(ctx, *bestAsset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rel := &updater.Release{
|
||||||
|
Version: strings.TrimPrefix(strings.TrimPrefix(best.TagName, "v"), "V"),
|
||||||
|
Channel: "stable",
|
||||||
|
Name: best.Name,
|
||||||
|
Notes: best.Body,
|
||||||
|
Artifact: updater.Artifact{
|
||||||
|
Filename: bestAsset.Name,
|
||||||
|
Size: bestAsset.Size,
|
||||||
|
Platform: req.Platform,
|
||||||
|
Arch: req.Arch,
|
||||||
|
},
|
||||||
|
Verification: verification,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"downloadURL": bestAsset.BrowserDownloadURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if rel.Channel == "stable" && (best.Prerelease) {
|
||||||
|
rel.Channel = "beta"
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, best.PublishedAt); err == nil {
|
||||||
|
rel.PublishedAt = t
|
||||||
|
}
|
||||||
|
return rel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams the release artifact to dst, reporting progress.
|
||||||
|
func (p *Provider) Download(ctx context.Context, r *updater.Release, dst io.Writer, onProgress func(written, total int64)) error {
|
||||||
|
url, _ := r.Metadata["downloadURL"].(string)
|
||||||
|
if url == "" {
|
||||||
|
return fmt.Errorf("gitea: release %s has no download URL", r.Version)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "AniTrack-updater")
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("gitea: download %s: %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
total := r.Artifact.Size
|
||||||
|
if total <= 0 {
|
||||||
|
total = resp.ContentLength
|
||||||
|
}
|
||||||
|
var written int64
|
||||||
|
buf := make([]byte, 128*1024)
|
||||||
|
for {
|
||||||
|
n, rerr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
written += int64(n)
|
||||||
|
onProgress(written, total)
|
||||||
|
}
|
||||||
|
if rerr == io.EOF {
|
||||||
|
onProgress(written, total)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
return rerr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listReleases fetches the releases index (newest first page is enough).
|
||||||
|
func (p *Provider) listReleases(ctx context.Context) ([]giteaRelease, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=50", p.cfg.BaseURL, p.cfg.Owner, p.cfg.Repo)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "AniTrack-updater")
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitea: list releases: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("gitea: list releases: %s", resp.Status)
|
||||||
|
}
|
||||||
|
var releases []giteaRelease
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||||
|
return nil, fmt.Errorf("gitea: decode releases: %w", err)
|
||||||
|
}
|
||||||
|
return releases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAsset returns the attachment with exactly assetName, or nil.
|
||||||
|
func findAsset(assets []giteaAsset, assetName string) *giteaAsset {
|
||||||
|
for i := range assets {
|
||||||
|
if assets[i].Name == assetName && assets[i].BrowserDownloadURL != "" {
|
||||||
|
return &assets[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchVerification downloads the .sha512 + .sig sidecars next to the asset.
|
||||||
|
// This mirrors `wails3 updater sign` (SHA-512 digest, ed25519ph signature).
|
||||||
|
// Anything missing or malformed is an error: verification fails closed.
|
||||||
|
func (p *Provider) fetchVerification(ctx context.Context, asset giteaAsset) (*updater.Verification, error) {
|
||||||
|
base := strings.TrimSuffix(asset.BrowserDownloadURL, asset.Name)
|
||||||
|
digestHex, err := p.fetchText(ctx, base+asset.Name+".sha512")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitea: digest sidecar: %w", err)
|
||||||
|
}
|
||||||
|
sigB64, err := p.fetchText(ctx, base+asset.Name+".sig")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitea: signature sidecar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<hex> <filename>" (sha512sum format); tolerate bare hex too.
|
||||||
|
digestHex = strings.Fields(digestHex)[0]
|
||||||
|
digest, err := hex.DecodeString(digestHex)
|
||||||
|
if err != nil || len(digest) != 64 {
|
||||||
|
return nil, fmt.Errorf("gitea: malformed digest sidecar for %s", asset.Name)
|
||||||
|
}
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sigB64))
|
||||||
|
if err != nil || len(sig) == 0 {
|
||||||
|
return nil, fmt.Errorf("gitea: malformed signature sidecar for %s", asset.Name)
|
||||||
|
}
|
||||||
|
return &updater.Verification{
|
||||||
|
DigestAlgo: "sha512",
|
||||||
|
Digest: digest,
|
||||||
|
SignatureAlgo: "ed25519ph",
|
||||||
|
Signature: sig,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchText GETs url and returns the trimmed body.
|
||||||
|
func (p *Provider) fetchText(ctx context.Context, url string) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "AniTrack-updater")
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("%s: %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(body)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareSemver compares plain versions ("1.99.0", optional v prefix).
|
||||||
|
// Pre-release suffixes (-rc1) sort below the plain release. Returns
|
||||||
|
// -1, 0 or +1. Unparseable input compares as 0.0.0 (never newest).
|
||||||
|
func compareSemver(a, b string) int {
|
||||||
|
an, ap := splitSemver(a)
|
||||||
|
bn, bp := splitSemver(b)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if an[i] != bn[i] {
|
||||||
|
if an[i] < bn[i] {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case ap == bp:
|
||||||
|
return 0
|
||||||
|
case ap == "":
|
||||||
|
return 1
|
||||||
|
case bp == "":
|
||||||
|
return -1
|
||||||
|
case ap < bp:
|
||||||
|
return -1
|
||||||
|
case ap > bp:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitSemver splits "v1.99.0-rc1" into ([1 99 0], "rc1").
|
||||||
|
func splitSemver(v string) ([3]int, string) {
|
||||||
|
var nums [3]int
|
||||||
|
v = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(v), "v"), "V")
|
||||||
|
core, pre, _ := strings.Cut(v, "-")
|
||||||
|
for i, part := range strings.Split(core, ".") {
|
||||||
|
if i >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(part)
|
||||||
|
if err != nil {
|
||||||
|
return [3]int{}, pre
|
||||||
|
}
|
||||||
|
nums[i] = n
|
||||||
|
}
|
||||||
|
return nums, pre
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompareSemver(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"1.99.0", "1.6.8", 1},
|
||||||
|
{"1.6.8", "1.99.0", -1},
|
||||||
|
{"1.99.0", "1.99.0", 0},
|
||||||
|
{"v1.99.0", "1.99.0", 0},
|
||||||
|
{"2.0.0", "1.99.9", 1},
|
||||||
|
{"1.99.0-rc1", "1.99.0", -1},
|
||||||
|
{"1.99.0", "1.99.0-rc1", 1},
|
||||||
|
{"1.99.0-rc1", "1.99.0-rc2", -1},
|
||||||
|
{"1.10.0", "1.9.0", 1}, // numeric, not lexicographic
|
||||||
|
{"garbage", "1.0.0", -1},
|
||||||
|
{"1.0.0", "garbage", 1},
|
||||||
|
{"", "", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := compareSemver(c.a, c.b); got != c.want {
|
||||||
|
t.Errorf("compareSemver(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testAsset = "AniTrack-linux-amd64"
|
||||||
|
const testData = "artifact-bytes"
|
||||||
|
|
||||||
|
type releaseSpec struct {
|
||||||
|
tag string
|
||||||
|
draft bool
|
||||||
|
prerelease bool
|
||||||
|
withAsset bool
|
||||||
|
withSidecar bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServer serves a canned releases index. Every spec with withAsset gets
|
||||||
|
// the test artifact; sidecars (.sha512/.sig, correctly formed) are served
|
||||||
|
// only if at least one spec sets withSidecar.
|
||||||
|
func buildServer(t *testing.T, specs []releaseSpec) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data := []byte(testData)
|
||||||
|
sum := sha512.Sum512(data)
|
||||||
|
sha := hex.EncodeToString(sum[:]) + " " + testAsset + "\n"
|
||||||
|
sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, sum[:])) + "\n"
|
||||||
|
serveSidecars := false
|
||||||
|
for _, s := range specs {
|
||||||
|
if s.withSidecar {
|
||||||
|
serveSidecars = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var srv *httptest.Server
|
||||||
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/releases"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("[")
|
||||||
|
for i, s := range specs {
|
||||||
|
assets := ""
|
||||||
|
if s.withAsset {
|
||||||
|
assets = fmt.Sprintf(`{"name":%q,"size":%d,"browser_download_url":%q}`,
|
||||||
|
testAsset, len(data), srv.URL+"/dl/"+testAsset)
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(",")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `{"tag_name":%q,"name":%q,"body":"notes","draft":%v,"prerelease":%v,"published_at":"2026-09-16T00:00:00Z","assets":[%s]}`,
|
||||||
|
s.tag, s.tag, s.draft, s.prerelease, assets)
|
||||||
|
}
|
||||||
|
b.WriteString("]")
|
||||||
|
fmt.Fprint(w, b.String())
|
||||||
|
case serveSidecars && r.URL.Path == "/dl/"+testAsset+".sha512":
|
||||||
|
fmt.Fprint(w, sha)
|
||||||
|
case serveSidecars && r.URL.Path == "/dl/"+testAsset+".sig":
|
||||||
|
fmt.Fprint(w, sig)
|
||||||
|
case serveSidecars && r.URL.Path == "/dl/"+testAsset:
|
||||||
|
w.Write(data)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProvider(srv *httptest.Server, allowPre bool) *Provider {
|
||||||
|
p, err := New(Config{
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
Owner: "o",
|
||||||
|
Repo: "r",
|
||||||
|
AssetName: testAsset,
|
||||||
|
AllowPrerelease: allowPre,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckFindsNewest(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{
|
||||||
|
{tag: "1.6.8", withAsset: true, withSidecar: true},
|
||||||
|
{tag: "1.99.0", withAsset: true, withSidecar: true},
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
rel, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel == nil {
|
||||||
|
t.Fatal("expected a release, got up-to-date")
|
||||||
|
}
|
||||||
|
if rel.Version != "1.99.0" {
|
||||||
|
t.Errorf("version = %q, want 1.99.0", rel.Version)
|
||||||
|
}
|
||||||
|
if rel.Verification == nil || rel.Verification.DigestAlgo != "sha512" || rel.Verification.SignatureAlgo != "ed25519ph" {
|
||||||
|
t.Errorf("verification not populated: %+v", rel.Verification)
|
||||||
|
}
|
||||||
|
if len(rel.Verification.Digest) != 64 || len(rel.Verification.Signature) == 0 {
|
||||||
|
t.Errorf("verification malformed: %+v", rel.Verification)
|
||||||
|
}
|
||||||
|
if rel.Notes != "notes" {
|
||||||
|
t.Errorf("notes = %q", rel.Notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckUpToDate(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{{tag: "1.99.0", withAsset: true, withSidecar: true}})
|
||||||
|
defer srv.Close()
|
||||||
|
rel, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.99.0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel != nil {
|
||||||
|
t.Errorf("expected up-to-date, got %s", rel.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSkipsDraftPrereleaseAndMissingAsset(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{
|
||||||
|
{tag: "1.99.2", draft: true, withAsset: true, withSidecar: true},
|
||||||
|
{tag: "1.99.1", prerelease: true, withAsset: true, withSidecar: true},
|
||||||
|
{tag: "1.99.0", withAsset: false},
|
||||||
|
{tag: "1.6.9", withAsset: true, withSidecar: true},
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
rel, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel == nil || rel.Version != "1.6.9" {
|
||||||
|
t.Fatalf("version = %v, want 1.6.9 (draft/prerelease/asset-less skipped)", rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAllowPrerelease(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{
|
||||||
|
{tag: "1.99.1-rc1", prerelease: true, withAsset: true, withSidecar: true},
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
rel, err := testProvider(srv, true).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.99.0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel == nil || rel.Version != "1.99.1-rc1" {
|
||||||
|
t.Fatalf("version = %v, want 1.99.1-rc1", rel)
|
||||||
|
}
|
||||||
|
if rel.Channel != "beta" {
|
||||||
|
t.Errorf("channel = %q, want beta", rel.Channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckFailsClosedWithoutSidecars(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{{tag: "1.99.0", withAsset: true, withSidecar: false}})
|
||||||
|
defer srv.Close()
|
||||||
|
_, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing sidecars, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
_, err := testProvider(srv, false).Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 500, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadStreamsWithProgress(t *testing.T) {
|
||||||
|
srv := buildServer(t, []releaseSpec{{tag: "1.99.0", withAsset: true, withSidecar: true}})
|
||||||
|
defer srv.Close()
|
||||||
|
p := testProvider(srv, false)
|
||||||
|
rel, err := p.Check(context.Background(), updater.CheckRequest{CurrentVersion: "1.6.8"})
|
||||||
|
if err != nil || rel == nil {
|
||||||
|
t.Fatalf("check: %v %v", rel, err)
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp("", "dl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
var calls int
|
||||||
|
var last int64
|
||||||
|
err = p.Download(context.Background(), rel, f, func(written, total int64) {
|
||||||
|
calls++
|
||||||
|
last = written
|
||||||
|
if total != int64(len(testData)) {
|
||||||
|
t.Errorf("total = %d", total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls == 0 || last != int64(len(testData)) {
|
||||||
|
t.Errorf("progress calls=%d last=%d", calls, last)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(f.Name())
|
||||||
|
if string(got) != testData {
|
||||||
|
t.Errorf("body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValidatesConfig(t *testing.T) {
|
||||||
|
if _, err := New(Config{}); err == nil {
|
||||||
|
t.Error("expected error for empty config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLiveGiteaCheck hits the real instance. Opt-in only:
|
||||||
|
// ANITRACK_LIVE_TEST=1 go test ./updater/gitea/ -run TestLiveGiteaCheck -v
|
||||||
|
func TestLiveGiteaCheck(t *testing.T) {
|
||||||
|
if os.Getenv("ANITRACK_LIVE_TEST") == "" {
|
||||||
|
t.Skip("set ANITRACK_LIVE_TEST=1 to run")
|
||||||
|
}
|
||||||
|
p, err := New(Config{
|
||||||
|
BaseURL: "https://git.linuxhg.com",
|
||||||
|
Owner: "john-okeefe",
|
||||||
|
Repo: "Anitrack",
|
||||||
|
AssetName: "AniTrack-linux-amd64",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rel, err := p.Check(context.Background(), updater.CheckRequest{CurrentVersion: "0.0.0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel == nil {
|
||||||
|
// Correct until CI publishes the bare-binary asset: no release
|
||||||
|
// carries AniTrack-linux-amd64 yet, so there is nothing to offer.
|
||||||
|
// This still proves API reachability + response parsing live.
|
||||||
|
t.Log("no matching asset published yet (expected until CI ships AniTrack-linux-amd64)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("latest=%s notes=%d bytes", rel.Version, len(rel.Notes))
|
||||||
|
var n int64
|
||||||
|
err = p.Download(context.Background(), rel, io.Discard, func(w, _ int64) { n = w })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("downloaded %d bytes", n)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !production
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// updaterAutoCheck is false outside production builds (dev, vet, plain
|
||||||
|
// go build): the updater is compiled in but never runs.
|
||||||
|
const updaterAutoCheck = false
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// End-to-end updater flow test (headless): Gitea fixture provider ->
|
||||||
|
// framework Check -> DownloadAndInstall -> REAL signature verification
|
||||||
|
// against a test key. Proves our sidecar shape is exactly what the
|
||||||
|
// framework verifier accepts. No network, no display, no Run().
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/updater"
|
||||||
|
|
||||||
|
giteaprovider "AniTrack/updater/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
const flowAsset = "AniTrack-linux-amd64"
|
||||||
|
const flowData = "flow-test-binary-bytes"
|
||||||
|
|
||||||
|
// flowServer serves one signed release for version 1.99.0. When *tampered
|
||||||
|
// is true the .sig sidecar is corrupted so verification must fail. The flag
|
||||||
|
// is read per-request so one server covers both phases (the framework keeps
|
||||||
|
// a process-wide app singleton, so only one application.New per test binary).
|
||||||
|
func flowServer(t *testing.T, pub ed25519.PublicKey, priv ed25519.PrivateKey, tampered *bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
data := []byte(flowData)
|
||||||
|
sum := sha512.Sum512(data)
|
||||||
|
sigBytes, err := priv.Sign(rand.Reader, sum[:], &ed25519.Options{Hash: crypto.SHA512})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sha := hex.EncodeToString(sum[:]) + " " + flowAsset + "\n"
|
||||||
|
mkSig := func() string {
|
||||||
|
s := sigBytes
|
||||||
|
if *tampered {
|
||||||
|
s = append([]byte(nil), sigBytes...)
|
||||||
|
s[0] ^= 0xff
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(s) + "\n"
|
||||||
|
}
|
||||||
|
var srv *httptest.Server
|
||||||
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/releases"):
|
||||||
|
fmt.Fprintf(w, `[{"tag_name":"1.99.0","name":"1.99.0","body":"n","draft":false,"prerelease":false,"published_at":"2026-09-16T00:00:00Z","assets":[{"name":%q,"size":%d,"browser_download_url":%q}]}]`,
|
||||||
|
flowAsset, len(data), srv.URL+"/dl/"+flowAsset)
|
||||||
|
case r.URL.Path == "/dl/"+flowAsset+".sha512":
|
||||||
|
fmt.Fprint(w, sha)
|
||||||
|
case r.URL.Path == "/dl/"+flowAsset+".sig":
|
||||||
|
fmt.Fprint(w, mkSig())
|
||||||
|
case r.URL.Path == "/dl/"+flowAsset:
|
||||||
|
w.Write(data)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
_ = pub
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdaterFlowDownloadsAndVerifies(t *testing.T) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = priv
|
||||||
|
tampered := false
|
||||||
|
srv := flowServer(t, pub, priv, &tampered)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
gh, err := giteaprovider.New(giteaprovider.Config{
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
Owner: "o",
|
||||||
|
Repo: "r",
|
||||||
|
AssetName: flowAsset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := application.New(application.Options{Name: "AniTrackFlowTest"})
|
||||||
|
if err := app.Updater.Init(updater.Config{
|
||||||
|
CurrentVersion: "1.6.8",
|
||||||
|
Providers: []updater.Provider{gh},
|
||||||
|
PublicKey: []byte(pub),
|
||||||
|
Window: updater.WindowNone,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Phase 1: good signature -> staged bytes match the fixture.
|
||||||
|
rel, err := app.Updater.Check(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rel == nil {
|
||||||
|
t.Fatal("expected an update from 1.6.8 to 1.99.0")
|
||||||
|
}
|
||||||
|
if err := app.Updater.DownloadAndInstall(ctx); err != nil {
|
||||||
|
t.Fatalf("DownloadAndInstall (incl. real signature verify): %s", err)
|
||||||
|
}
|
||||||
|
staged := app.Updater.DownloadedPath()
|
||||||
|
if staged == "" {
|
||||||
|
t.Fatal("no staged path after install")
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(staged)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != flowData {
|
||||||
|
t.Errorf("staged bytes = %q, want fixture", got)
|
||||||
|
}
|
||||||
|
os.Remove(staged)
|
||||||
|
|
||||||
|
// Phase 2: tampered signature -> install must fail closed.
|
||||||
|
tampered = true
|
||||||
|
if _, err := app.Updater.Check(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Updater.DownloadAndInstall(ctx); err == nil {
|
||||||
|
t.Fatal("expected verification failure for tampered signature, got nil")
|
||||||
|
} else {
|
||||||
|
t.Logf("rejected as expected: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build production
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// updaterAutoCheck gates the startup update check to production builds:
|
||||||
|
// `wails3 build` sets -tags production, `wails3 dev` does not, so dev
|
||||||
|
// binaries in build/bin never phone home.
|
||||||
|
const updaterAutoCheck = true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Code generated by `make release` from build/config.yml info.version.
|
||||||
|
// DO NOT EDIT.
|
||||||
|
const appVersionString = "2.0.0"
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "AniTrack",
|
|
||||||
"frontend": {
|
|
||||||
"dir": "./frontend",
|
|
||||||
"install": "npm install",
|
|
||||||
"build": "npm run build",
|
|
||||||
"dev": "npm run dev",
|
|
||||||
"devServerUrl": "http://localhost:5173"
|
|
||||||
},
|
|
||||||
"author": {
|
|
||||||
"name": "John O'Keefe",
|
|
||||||
"email": "admin@linuxhg.com"
|
|
||||||
},
|
|
||||||
"info": {
|
|
||||||
"productName": "AniTrack",
|
|
||||||
"productVersion": "1.99.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user