Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
186f6a5839 | ||
|
|
786d9c6110 | ||
|
|
0019310ca4 | ||
|
|
1940cb548f | ||
|
|
37e3373db9 | ||
|
|
8d5d8af297 | ||
|
|
9ce79ee416 | ||
|
|
b3a0814f24 | ||
|
|
2c576fa1e8 | ||
|
|
544dbdb4db | ||
|
|
087eb77875 | ||
|
|
6fb7b5ea11 | ||
|
|
369810e06b | ||
|
|
e51cb13662 | ||
|
|
3d65da0529 | ||
|
|
52cbef27c4 | ||
|
|
c03c0b8ec4 | ||
|
|
8c48105ca3 | ||
|
|
1c4d6ca9a1 | ||
|
|
6bf832a4ba | ||
|
|
4f8257ef72 | ||
|
|
b505959934 | ||
|
|
d0250a2133 | ||
|
|
de2b324e20 | ||
|
|
2c78b58207 | ||
|
|
a9852c18a0 | ||
|
|
409bfd5e89 | ||
|
|
0eeef6048e | ||
|
|
4fc0cdeab7 | ||
|
|
eb059a3d71 | ||
|
|
dcabec8b1d | ||
|
|
b3737d30af | ||
|
|
0bfa2136be | ||
|
|
b1cdef8769 | ||
|
|
d661836347 | ||
|
|
28eecf0a97 | ||
|
|
e8690476bd | ||
|
|
7e0be8fa30 | ||
|
|
1b05dde382 | ||
|
|
29299d17da | ||
|
|
57fbbdf0d2 | ||
|
|
df2e9ea777 | ||
|
|
c99c8efa11 | ||
|
|
904786b941 | ||
|
|
b6651310ea | ||
|
|
da38b865fc | ||
|
|
ced537ecf2 | ||
|
|
e14a34fccf | ||
|
|
78576b8da9 | ||
|
|
01706c6053 | ||
|
|
f7dc76c955 | ||
|
|
ed975019dc | ||
|
|
0c7f34ab90 | ||
|
|
a7a33527c4 | ||
|
|
0c6ca72cf1 | ||
|
|
68468e5378 | ||
|
|
6fbb62730d | ||
|
|
48b37f6301 | ||
|
|
66182f82cd | ||
|
|
18aba34c08 | ||
|
|
b98840ee37 |
@@ -0,0 +1,474 @@
|
|||||||
|
name: Build & publish the Android APK
|
||||||
|
|
||||||
|
# The fifth workflow, and the second that publishes. It builds a signed
|
||||||
|
# arm64-v8a APK on every version tag and puts it in
|
||||||
|
# Gitea's *generic* package registry, which — unlike the repository — is
|
||||||
|
# readable without credentials. That is what lets an Obtainium client
|
||||||
|
# poll a plain URL with no token and no public mirror of the source.
|
||||||
|
#
|
||||||
|
# **Why its own file rather than a job in ci.yml.** `ci.yml` runs on
|
||||||
|
# every branch push and is the workflow that gates; this one runs on
|
||||||
|
# tags only, takes tens of minutes on a cold cache, and the runner has
|
||||||
|
# capacity 1. Hanging it off the gate would put every push behind an
|
||||||
|
# SDK download.
|
||||||
|
#
|
||||||
|
# **Why it is keyed on the tag.** The ljos pipeline this is modelled on
|
||||||
|
# computes a version in CI and cuts the release itself, then gates the
|
||||||
|
# Android job on `needs.release.outputs.version != ''` with an
|
||||||
|
# `always()` whose absence silently kills the manual path. This repo
|
||||||
|
# has no release automation — tags are pushed by hand and
|
||||||
|
# homebrew-formula.yml already keys on `v*` — so the tag *is* the
|
||||||
|
# version and none of that machinery, or its failure modes, is needed.
|
||||||
|
#
|
||||||
|
# It deliberately does **not** carry `continue-on-error`. In ljos the
|
||||||
|
# Android job shared a pipeline with a server deploy that must never go
|
||||||
|
# red over a phone build; here it is standalone and can neither delay
|
||||||
|
# nor redden anything, so a release step that fails silently would be
|
||||||
|
# strictly worse than one that fails visibly.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (default: the latest v* tag)"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: android-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
apk:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
container:
|
||||||
|
image: ubuntu:24.04
|
||||||
|
# /cache/tool holds the Go toolchain ci.yml already downloads.
|
||||||
|
# The other three are this workflow's own and are ~4 GB between
|
||||||
|
# them, which is most of its wall clock on a cold run:
|
||||||
|
# android-sdk the SDK, the NDK and the platform (~2 GB)
|
||||||
|
# gradle GRADLE_USER_HOME — the wrapper distribution and
|
||||||
|
# the AGP dependency graph (~700 MB)
|
||||||
|
# pnpm-store shared with ci.yml
|
||||||
|
# Every path must be inside the runner's `valid_volumes` allowlist:
|
||||||
|
# a directory outside it makes the job **fail to start**, rather
|
||||||
|
# than silently skipping the mount.
|
||||||
|
volumes:
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/gradle:/cache/gradle
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
GO_VERSION: '1.25.0'
|
||||||
|
npm_config_store_dir: /cache/pnpm-store
|
||||||
|
# The Go half wants the NDK; the Gradle half wants a platform.
|
||||||
|
ANDROID_HOME: /cache/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /cache/android-sdk
|
||||||
|
GRADLE_USER_HOME: /cache/gradle
|
||||||
|
# Pinned, not "whatever sdkmanager installs": newer NDKs have
|
||||||
|
# broken the Wails Android build before, and r26d is what plan
|
||||||
|
# 015 phase 0 was verified against.
|
||||||
|
NDK_VERSION: 26.3.11579264
|
||||||
|
# The registry package name. Obtainium watches
|
||||||
|
# <server>/api/packages/<owner>/generic/yellowjacket-android/latest/yellowjacket.apk
|
||||||
|
PACKAGE_NAME: yellowjacket-android
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# libgtk-4-dev and libwebkitgtk-6.0-dev are here even though
|
||||||
|
# nothing in this job builds a desktop app: `wails3` is the task
|
||||||
|
# runner the whole Android build goes through, and the CLI links
|
||||||
|
# the GTK/WebKit bindings, so `go tool wails3` cannot compile
|
||||||
|
# without them. libasound2-dev is oto's `pkg-config -- alsa`
|
||||||
|
# probe, for the same reason (the *Android* build uses oboe, not
|
||||||
|
# ALSA — this is the host toolchain only).
|
||||||
|
- name: System packages
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq --no-install-recommends \
|
||||||
|
ca-certificates curl git jq unzip zip \
|
||||||
|
build-essential pkg-config \
|
||||||
|
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
|
||||||
|
openjdk-21-jdk-headless
|
||||||
|
|
||||||
|
# By hand rather than actions/checkout: that is a JS action and
|
||||||
|
# needs node inside the container before any step has installed
|
||||||
|
# it. Same approach as the other four workflows.
|
||||||
|
- name: Clone repo at this commit
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git clone --quiet \
|
||||||
|
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||||
|
git -C /src checkout --quiet --detach "$SHA"
|
||||||
|
git config --global --add safe.directory /src
|
||||||
|
git -C /src log --oneline -1
|
||||||
|
|
||||||
|
# A tag push carries the version in its own name. A manual run has
|
||||||
|
# no tag, so it takes the input or falls back to the latest v* tag,
|
||||||
|
# which is what a hand-triggered rebuild wants anyway.
|
||||||
|
- name: Resolve the version
|
||||||
|
id: version
|
||||||
|
working-directory: /src
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
v="${{ inputs.version }}"
|
||||||
|
if [ -z "$v" ]; then
|
||||||
|
case "$REF_NAME" in
|
||||||
|
v*) v="$REF_NAME" ;;
|
||||||
|
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo "v0.0.0") ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
v="${v#v}"
|
||||||
|
|
||||||
|
# v0.0.0 is semantic-release's version floor, not a shipment —
|
||||||
|
# see the bootstrap step in release.yml. It is skipped cleanly
|
||||||
|
# rather than failing the guard below, because a 45-minute red
|
||||||
|
# run against a tag that was never meant to ship is noise, and
|
||||||
|
# this is the most expensive of the four workflows a tag fires.
|
||||||
|
if [ "$v" = "0.0.0" ]; then
|
||||||
|
echo "v0.0.0 is the version floor, not a release; nothing to build"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Android orders releases by an integer and refuses anything
|
||||||
|
# not greater than what is installed. 1.3.1 -> 10301, which
|
||||||
|
# increases as long as minor and patch stay below 100.
|
||||||
|
IFS=. read -r maj min pat <<EOF
|
||||||
|
$v
|
||||||
|
EOF
|
||||||
|
code=$(( ${maj:-0} * 10000 + ${min:-0} * 100 + ${pat:-0} ))
|
||||||
|
if [ "$code" -le 0 ]; then
|
||||||
|
echo "refusing to build version '$v' (versionCode $code)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "code=$code" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "building $v (versionCode $code)"
|
||||||
|
|
||||||
|
# Releases restarted at 0.0.1 when they became automatic (plan
|
||||||
|
# 017), so versionCode restarted at 1 — *below* the 10300 an
|
||||||
|
# installed 1.3.0 build carries. Android refuses a downgrade
|
||||||
|
# outright, and the only remedy is an uninstall, which takes the
|
||||||
|
# user's library with it. Said here because this is the file
|
||||||
|
# that computes the number.
|
||||||
|
if [ "$code" -lt 10600 ]; then
|
||||||
|
echo
|
||||||
|
echo "note: versionCode $code is below the 10600 that v1.6.0 shipped."
|
||||||
|
echo " An existing install must be removed before this one will"
|
||||||
|
echo " install, and that removal takes its library with it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Go toolchain
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
|
||||||
|
mkdir -p /cache/tool && rm -rf /cache/tool/go
|
||||||
|
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
|
||||||
|
fi
|
||||||
|
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
|
||||||
|
/cache/tool/go/bin/go version
|
||||||
|
|
||||||
|
- name: Node toolchain
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt-get install -y -qq --no-install-recommends nodejs
|
||||||
|
corepack enable
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# Idempotent by directory check. sdkmanager is itself idempotent
|
||||||
|
# but still spends minutes verifying, so the guards are what make
|
||||||
|
# this cheap on every run after the first.
|
||||||
|
- name: Android SDK and NDK (cached)
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||||
|
|
||||||
|
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||||
|
echo "command line tools: installing"
|
||||||
|
cd /tmp
|
||||||
|
curl -fsSL -o tools.zip \
|
||||||
|
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
|
unzip -q tools.zip
|
||||||
|
rm -rf "$ANDROID_HOME/cmdline-tools/latest"
|
||||||
|
mv cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
|
||||||
|
else
|
||||||
|
echo "command line tools: cached"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||||
|
yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
install_if_missing() {
|
||||||
|
if [ -d "$ANDROID_HOME/$2" ]; then
|
||||||
|
echo "$1: cached"
|
||||||
|
else
|
||||||
|
echo "$1: installing"
|
||||||
|
yes | sdkmanager --install "$1" >/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
# android-35 matches compileSdk/targetSdk in
|
||||||
|
# build/android/app/build.gradle. No system image and no
|
||||||
|
# emulator: this job builds, it does not run.
|
||||||
|
install_if_missing "platform-tools" "platform-tools"
|
||||||
|
install_if_missing "platforms;android-35" "platforms/android-35"
|
||||||
|
install_if_missing "build-tools;34.0.0" "build-tools/34.0.0"
|
||||||
|
install_if_missing "ndk;${NDK_VERSION}" "ndk/${NDK_VERSION}"
|
||||||
|
|
||||||
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/${NDK_VERSION}" >> "$GITHUB_ENV"
|
||||||
|
du -sh "$ANDROID_HOME" || true
|
||||||
|
|
||||||
|
# **Signing is not optional past the first install.** Android
|
||||||
|
# refuses to update an app whose signing key changed and the only
|
||||||
|
# remedy is an uninstall, which takes the user's library with it.
|
||||||
|
# build.gradle falls back to the *debug* keystore when these are
|
||||||
|
# absent, and that key differs between every machine and every
|
||||||
|
# runner — so publishing an unsigned build is a decision to
|
||||||
|
# reinstall by hand for ever. Fail instead.
|
||||||
|
# **Signing is not optional past the first install.** Android
|
||||||
|
# refuses to update an app whose signing key changed and the only
|
||||||
|
# remedy is an uninstall, which takes the user's library with it.
|
||||||
|
# build.gradle falls back to the *debug* keystore when these are
|
||||||
|
# absent, and that key differs between every machine and every
|
||||||
|
# runner — so publishing an unsigned build is a decision to
|
||||||
|
# reinstall by hand for ever. Fail instead.
|
||||||
|
#
|
||||||
|
# Decode, check and build are one step on purpose. Splitting them
|
||||||
|
# would mean either handing the password to a later step through
|
||||||
|
# `$GITHUB_ENV` — where the `env:` dump is only masked for values
|
||||||
|
# that are *verbatim* a secret, so a trimmed one could print in
|
||||||
|
# clear — or repeating the trimming logic in both.
|
||||||
|
- name: Build the signed APK
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
YJ_VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
YJ_VERSION_CODE: ${{ steps.version.outputs.code }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ -z "${KEYSTORE_B64:-}" ]; then
|
||||||
|
echo "ANDROID_KEYSTORE_B64 is not set."
|
||||||
|
echo
|
||||||
|
echo "Building without it signs with the debug key, and every future"
|
||||||
|
echo "update then fails with a signature mismatch. See"
|
||||||
|
echo "docs/android-release.md for the keytool command and the secrets."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${KEYSTORE_PASSWORD:-}" ]; then
|
||||||
|
echo "ANDROID_KEYSTORE_PASSWORD is not set — see docs/android-release.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The path is decided here rather than composed in an `env:`
|
||||||
|
# block: `${{ env.HOME }}` evaluates to an empty string in
|
||||||
|
# Gitea's expression context, which turns "$HOME/x.jks" into
|
||||||
|
# "/x.jks" — reported by Gradle as a missing file, a minute in.
|
||||||
|
keystore="${RUNNER_TEMP:-/tmp}/yellowjacket-release.jks"
|
||||||
|
printf '%s' "$KEYSTORE_B64" | base64 -d > "$keystore"
|
||||||
|
chmod 600 "$keystore"
|
||||||
|
|
||||||
|
# **A secret pasted into a web form very often carries a
|
||||||
|
# trailing newline**, and a password is compared byte for byte.
|
||||||
|
# Trim CR and LF from all three, and say so when it mattered —
|
||||||
|
# "the keystore did not open" with a correct password is an
|
||||||
|
# unpleasant thing to debug blind.
|
||||||
|
pass=$(printf '%s' "$KEYSTORE_PASSWORD" | tr -d '\r\n')
|
||||||
|
if [ "${#pass}" -ne "${#KEYSTORE_PASSWORD}" ]; then
|
||||||
|
echo "note: stripped newline(s) from ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
fi
|
||||||
|
alias_want=$(printf '%s' "${KEY_ALIAS:-yellowjacket}" | tr -d '\r\n')
|
||||||
|
keypass=$(printf '%s' "${KEY_PASSWORD:-$pass}" | tr -d '\r\n')
|
||||||
|
|
||||||
|
# Describe the artifact before trying to open it. A truncated
|
||||||
|
# or mis-pasted base64 yields a file that is the wrong size or
|
||||||
|
# has no keystore header at all, and that is a different
|
||||||
|
# problem from a wrong password.
|
||||||
|
size=$(stat -c %s "$keystore")
|
||||||
|
magic=$(od -An -N4 -tx1 "$keystore" | tr -s ' ' | sed 's/^ //')
|
||||||
|
echo "keystore: $size bytes, first four bytes: $magic"
|
||||||
|
|
||||||
|
# The fingerprint of the decoded file, so "is the secret the
|
||||||
|
# keystore I have locally?" is answerable without guessing.
|
||||||
|
# A hash of a *public* certificate store gives nothing away,
|
||||||
|
# and the alternative is comparing byte counts by eye.
|
||||||
|
#
|
||||||
|
# sha256sum ~/path/to/yellowjacket-release.jks
|
||||||
|
#
|
||||||
|
# A password that is right for one keystore and wrong for
|
||||||
|
# another is indistinguishable from a wrong password, and this
|
||||||
|
# is the line that distinguishes them.
|
||||||
|
echo " sha256: $(sha256sum "$keystore" | cut -d' ' -f1)"
|
||||||
|
case "$magic" in
|
||||||
|
"30 82"*) echo " header: PKCS12 (keytool's default since JDK 9)" ;;
|
||||||
|
"fe ed fe ed") echo " header: legacy JKS" ;;
|
||||||
|
*) echo " WARNING: not a keystore header. Is the secret the base64 of the .jks?" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Open it here rather than letting Gradle discover the problem
|
||||||
|
# at :app:validateSigningRelease, a minute of build time in and
|
||||||
|
# reported as a missing file rather than a bad password.
|
||||||
|
if ! keytool -list -keystore "$keystore" -storepass "$pass" >/tmp/ks.txt 2>/tmp/ks.err; then
|
||||||
|
echo "the keystore did not open with ANDROID_KEYSTORE_PASSWORD." >&2
|
||||||
|
echo " password length after trimming: ${#pass}" >&2
|
||||||
|
sed 's/^/ keytool: /' /tmp/ks.err | head -5 >&2
|
||||||
|
echo >&2
|
||||||
|
|
||||||
|
# A password pasted *with its shell quotes* is the one
|
||||||
|
# remaining cause that looks identical to a wrong password:
|
||||||
|
# the secret is two characters longer than the password and
|
||||||
|
# nothing in the error says so. Naming it is safe --
|
||||||
|
# stripping the quotes and carrying on would not be, since a
|
||||||
|
# password may legitimately contain them.
|
||||||
|
unquoted=$(printf '%s' "$pass" | sed "s/^['\"]//;s/['\"]$//")
|
||||||
|
if [ "$unquoted" != "$pass" ] &&
|
||||||
|
keytool -list -keystore "$keystore" -storepass "$unquoted" >/dev/null 2>&1; then
|
||||||
|
echo " ** it opens with the surrounding quotes removed. **" >&2
|
||||||
|
echo " Re-paste ANDROID_KEYSTORE_PASSWORD without them." >&2
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
echo "Check it locally with the same two values:" >&2
|
||||||
|
echo " printf %s \"\$SECRET_B64\" | base64 -d > /tmp/k.jks" >&2
|
||||||
|
echo " keytool -list -keystore /tmp/k.jks -storepass '<password>'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "keystore opens with the supplied password"
|
||||||
|
|
||||||
|
# And check the alias now, for the same reason. It defaults to
|
||||||
|
# `yellowjacket`, so a keystore created with any other alias
|
||||||
|
# would otherwise fail deep inside Gradle.
|
||||||
|
if ! keytool -list -keystore "$keystore" -storepass "$pass" -alias "$alias_want" >/dev/null 2>&1; then
|
||||||
|
echo "alias '$alias_want' is not in this keystore. It holds:" >&2
|
||||||
|
sed -n 's/^\([^,]*\),.*Entry.*$/ \1/p' /tmp/ks.txt >&2
|
||||||
|
echo "Set ANDROID_KEY_ALIAS to one of those." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "alias '$alias_want': present"
|
||||||
|
|
||||||
|
ANDROID_KEYSTORE_FILE="$keystore"
|
||||||
|
ANDROID_KEYSTORE_PASSWORD="$pass"
|
||||||
|
ANDROID_KEY_ALIAS="$alias_want"
|
||||||
|
ANDROID_KEY_PASSWORD="$keypass"
|
||||||
|
export ANDROID_KEYSTORE_FILE ANDROID_KEYSTORE_PASSWORD
|
||||||
|
export ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD
|
||||||
|
|
||||||
|
# ANDROID_SDK is passed explicitly: the Makefile defaults it to
|
||||||
|
# ~/Android/Sdk, which is the developer-machine layout and not
|
||||||
|
# this container's.
|
||||||
|
make android ANDROID_SDK="$ANDROID_HOME" ANDROID_NDK="$ANDROID_NDK_HOME"
|
||||||
|
|
||||||
|
- name: Verify the APK
|
||||||
|
id: apk
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
working-directory: /src
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
apk=bin/yellowjacket.apk
|
||||||
|
[ -s "$apk" ] || { echo "no APK was produced" >&2; ls -la bin || true; exit 1; }
|
||||||
|
bt="$ANDROID_HOME/build-tools/34.0.0"
|
||||||
|
|
||||||
|
ls -la "$apk"
|
||||||
|
"$bt/aapt2" dump badging "$apk" | sed -n '1p;/application-label:/p;/native-code/p'
|
||||||
|
|
||||||
|
# arm64 and *only* arm64. x86_64 Android cannot run this app
|
||||||
|
# (modernc's raw lstat against Android's seccomp filter, which
|
||||||
|
# is every x86_64 device and not merely the emulator), so an
|
||||||
|
# x86_64 slice would be ~31 MB that runs nowhere -- and its
|
||||||
|
# reappearance would mean someone had put the ABI back in
|
||||||
|
# app/build.gradle without knowing that.
|
||||||
|
"$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a'$" || {
|
||||||
|
echo "the APK's ABI set is not exactly arm64-v8a" >&2; exit 1; }
|
||||||
|
|
||||||
|
# The identity the pipeline exists to keep stable.
|
||||||
|
"$bt/aapt2" dump badging "$apk" | grep -q "versionCode='${{ steps.version.outputs.code }}'" || {
|
||||||
|
echo "versionCode is not ${{ steps.version.outputs.code }}" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
"$bt/apksigner" verify --print-certs "$apk" |
|
||||||
|
grep -E 'Signer #1 certificate (DN|SHA-256 digest)'
|
||||||
|
|
||||||
|
# A build signed with the debug key installs once and can never
|
||||||
|
# be updated. It must never reach the registry.
|
||||||
|
if "$bt/apksigner" verify --print-certs "$apk" | grep -q 'CN=Android Debug'; then
|
||||||
|
echo "REFUSING TO PUBLISH: signed with the debug keystore" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "Record that SHA-256. If it ever changes, updates will fail."
|
||||||
|
|
||||||
|
# Two copies: a versioned one for history and a fixed `latest` URL
|
||||||
|
# for Obtainium to watch. Gitea refuses to overwrite an existing
|
||||||
|
# file, so `latest` is deleted first. Credentials are the same
|
||||||
|
# OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with.
|
||||||
|
- name: Publish to the Gitea package registry
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
base="${SERVER_URL}/api/packages/${OWNER}/generic/${PACKAGE_NAME}"
|
||||||
|
apk=bin/yellowjacket.apk
|
||||||
|
|
||||||
|
put() {
|
||||||
|
code=$(curl -s -o /tmp/put.out -w '%{http_code}' \
|
||||||
|
--user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||||
|
--upload-file "$apk" "$1")
|
||||||
|
echo " -> $1 : $code"
|
||||||
|
# 409 is "already there", which is the correct outcome for a
|
||||||
|
# re-run of the same tag and not a failure.
|
||||||
|
if [ "$code" != "201" ] && [ "$code" != "409" ]; then
|
||||||
|
cat /tmp/put.out >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "publishing the versioned copy"
|
||||||
|
put "$base/$VERSION/yellowjacket-$VERSION.apk"
|
||||||
|
|
||||||
|
echo "clearing the previous latest"
|
||||||
|
curl -s -o /dev/null -w ' -> delete latest: %{http_code}\n' \
|
||||||
|
--user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||||
|
-X DELETE "$base/latest/yellowjacket.apk" || true
|
||||||
|
|
||||||
|
echo "publishing latest"
|
||||||
|
put "$base/latest/yellowjacket.apk"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Obtainium URL:"
|
||||||
|
echo " $base/latest/yellowjacket.apk"
|
||||||
|
|
||||||
|
# The generic registry is what Obtainium polls; the release page is
|
||||||
|
# what a person looks at. Same file, already built and already
|
||||||
|
# verified by the step above — so this cannot publish something the
|
||||||
|
# signature check would have refused.
|
||||||
|
- name: Attach the APK to the release
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
./scripts/release-asset.sh "$TAG" bin/yellowjacket.apk \
|
||||||
|
"yellowjacket-${VERSION}-android-arm64.apk"
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
name: Build & publish Arch package
|
name: Build & publish Arch package
|
||||||
|
|
||||||
|
# Keyed on the tag, not on main. It used to publish on every push,
|
||||||
|
# deriving a version from `git describe` — so the registry accumulated a
|
||||||
|
# package per merge and none of them corresponded to anything a user
|
||||||
|
# could be told to install. release.yml decides what a release is now,
|
||||||
|
# and this builds the tag it cuts.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (default: the latest v* tag)"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: arch-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
arch-package:
|
arch-package:
|
||||||
@@ -17,6 +32,7 @@ jobs:
|
|||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
OWNER: ${{ github.repository_owner }}
|
OWNER: ${{ github.repository_owner }}
|
||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
# Arch registry name (the "$repo" in clients' pacman.conf). Arbitrary label.
|
# Arch registry name (the "$repo" in clients' pacman.conf). Arbitrary label.
|
||||||
ARCH_REPO: stable
|
ARCH_REPO: stable
|
||||||
steps:
|
steps:
|
||||||
@@ -26,8 +42,9 @@ jobs:
|
|||||||
# gtk3 was v2's stack and is now only the `-tags gtk3` escape hatch.
|
# gtk3 was v2's stack and is now only the `-tags gtk3` escape hatch.
|
||||||
# These must match the PKGBUILD's depends=() — makepkg installs
|
# These must match the PKGBUILD's depends=() — makepkg installs
|
||||||
# nothing itself, so a mismatch fails at link time, not at check time.
|
# nothing itself, so a mismatch fails at link time, not at check time.
|
||||||
|
# jq is scripts/release-asset.sh's, not the build's.
|
||||||
pacman -Syu --noconfirm --needed \
|
pacman -Syu --noconfirm --needed \
|
||||||
base-devel git go nodejs pnpm curl sudo \
|
base-devel git go nodejs pnpm curl sudo jq \
|
||||||
webkitgtk-6.0 gtk4 alsa-lib
|
webkitgtk-6.0 gtk4 alsa-lib
|
||||||
|
|
||||||
- name: Create unprivileged build user
|
- name: Create unprivileged build user
|
||||||
@@ -36,15 +53,43 @@ jobs:
|
|||||||
install -d -o builder -g builder /build
|
install -d -o builder -g builder /build
|
||||||
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
|
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
|
||||||
|
|
||||||
|
# v0.0.0 is semantic-release's version floor, not a shipment — see
|
||||||
|
# the bootstrap step in release.yml. A clean skip rather than a
|
||||||
|
# failure: a red run against a tag that was never meant to ship is
|
||||||
|
# noise, and this is one of the four workflows that would otherwise
|
||||||
|
# fire on it.
|
||||||
|
- name: Resolve the version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
v="${{ inputs.version }}"
|
||||||
|
[ -n "$v" ] || v="$REF_NAME"
|
||||||
|
case "$v" in v*) ;; *) v="v$v" ;; esac
|
||||||
|
|
||||||
|
if [ "$v" = "v0.0.0" ]; then
|
||||||
|
echo "v0.0.0 is the version floor, not a release; nothing to build"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "building $v"
|
||||||
|
|
||||||
- name: Clone repo at the pushed commit
|
- name: Clone repo at the pushed commit
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
run: |
|
run: |
|
||||||
# Token auth works for private repos and needs no SSH key in CI.
|
# Token auth works for private repos and needs no SSH key in CI.
|
||||||
sudo -u builder git clone \
|
sudo -u builder git clone \
|
||||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \
|
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \
|
||||||
/build/yellowjacket
|
/build/yellowjacket
|
||||||
|
# A tag push carries the tag's own commit in $SHA, so this checks
|
||||||
|
# out exactly what was tagged. pkgver() then reads the tag from
|
||||||
|
# the clone's own git history.
|
||||||
sudo -u builder git -C /build/yellowjacket checkout --detach "$SHA"
|
sudo -u builder git -C /build/yellowjacket checkout --detach "$SHA"
|
||||||
|
|
||||||
- name: Build package with makepkg
|
- name: Build package with makepkg
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
run: |
|
run: |
|
||||||
cd /build/yellowjacket/packaging/arch
|
cd /build/yellowjacket/packaging/arch
|
||||||
# Point the PKGBUILD at this local clone / exact commit; pkgver() then
|
# Point the PKGBUILD at this local clone / exact commit; pkgver() then
|
||||||
@@ -54,6 +99,7 @@ jobs:
|
|||||||
makepkg -f --noconfirm --cleanbuild
|
makepkg -f --noconfirm --cleanbuild
|
||||||
|
|
||||||
- name: Publish to the Gitea Arch registry
|
- name: Publish to the Gitea Arch registry
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
run: |
|
run: |
|
||||||
cd /build/yellowjacket/packaging/arch
|
cd /build/yellowjacket/packaging/arch
|
||||||
# makepkg also produces a -debug package (detached symbols); end users
|
# makepkg also produces a -debug package (detached symbols); end users
|
||||||
@@ -67,3 +113,20 @@ jobs:
|
|||||||
--upload-file "$pkg" \
|
--upload-file "$pkg" \
|
||||||
"${SERVER_URL}/api/packages/${OWNER}/arch/${ARCH_REPO}"
|
"${SERVER_URL}/api/packages/${OWNER}/arch/${ARCH_REPO}"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The pacman registry is for people who have added it to pacman.conf;
|
||||||
|
# the release page is for everyone else. Same file, and it is
|
||||||
|
# already built.
|
||||||
|
- name: Attach the package to the release
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
env:
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
cd /build/yellowjacket/packaging/arch
|
||||||
|
for pkg in yellowjacket-*.pkg.tar.zst; do
|
||||||
|
case "$pkg" in
|
||||||
|
yellowjacket-debug-*) continue ;;
|
||||||
|
esac
|
||||||
|
/build/yellowjacket/scripts/release-asset.sh "$TAG" "$(pwd)/$pkg"
|
||||||
|
done
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# The other three workflows package and publish; none of them test
|
# The other five workflows package, publish or release; none of them test
|
||||||
# anything, so a green tick on this repo used to mean "the Arch package
|
# anything, so a green tick on this repo used to mean "the Arch package
|
||||||
# built", which is not the question anyone was asking. This is the
|
# built", which is not the question anyone was asking. This is the
|
||||||
# workflow that gates.
|
# workflow that gates.
|
||||||
@@ -9,9 +9,23 @@ name: CI
|
|||||||
# before being written here, so every step below is a transcription of
|
# before being written here, so every step below is a transcription of
|
||||||
# something observed working rather than something expected to.
|
# something observed working rather than something expected to.
|
||||||
|
|
||||||
|
# **A branch push and its PR are the same commit, and testing it twice
|
||||||
|
# costs the only runner there is.** `branches: ['**']` here meant every
|
||||||
|
# PR booked four runs — `check` and `e2e` for the branch push, then both
|
||||||
|
# again for `refs/pull/N/head` — on a host with capacity 1, where the
|
||||||
|
# queue is shared with an index build that can hold it for three hours.
|
||||||
|
#
|
||||||
|
# `pull_request` covers feature branches, and `main` is kept because a
|
||||||
|
# post-merge run is the record of the trunk's health. Since main now
|
||||||
|
# refuses direct pushes, that run happens exactly once per merge.
|
||||||
|
#
|
||||||
|
# The trade is explicit: a branch pushed with **no** PR open gets no CI.
|
||||||
|
# That is consistent with the workflow this repo committed to — every
|
||||||
|
# change goes through a PR — and the signal returns the moment one is
|
||||||
|
# opened, on the same commit.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['**']
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -78,7 +92,7 @@ jobs:
|
|||||||
# Cloned by hand rather than with actions/checkout: that is a JS
|
# Cloned by hand rather than with actions/checkout: that is a JS
|
||||||
# action and needs node inside the job container before any step
|
# action and needs node inside the job container before any step
|
||||||
# has had a chance to install it. Same approach as the other
|
# has had a chance to install it. Same approach as the other
|
||||||
# three workflows in this directory.
|
# other workflows in this directory.
|
||||||
- name: Clone repo at this commit
|
- name: Clone repo at this commit
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
name: Attach the desktop build to the release
|
||||||
|
|
||||||
|
# The Arch package goes to the pacman registry and the APK to the generic
|
||||||
|
# one, but a release page with nothing on it to download is a release page
|
||||||
|
# nobody can use. This builds the plain Linux x86_64 binary and attaches
|
||||||
|
# it, so "get the latest version" has an answer that needs no package
|
||||||
|
# manager at all.
|
||||||
|
#
|
||||||
|
# **Linux only, and macOS is not an oversight.** `GOOS=darwin
|
||||||
|
# CGO_ENABLED=0` fails at `wails/v3/pkg/mac: build constraints exclude all
|
||||||
|
# Go files` — the darwin backend is Objective-C behind cgo, so a .app
|
||||||
|
# needs a macOS host, and the runner is a Linux container. That is
|
||||||
|
# exactly why the Homebrew formula builds from source on the user's own
|
||||||
|
# Mac, and it stays the macOS channel.
|
||||||
|
#
|
||||||
|
# Windows *does* cross-compile (GOOS=windows CGO_ENABLED=0 succeeds in a
|
||||||
|
# couple of seconds — nothing in the audio, database or webview path needs
|
||||||
|
# cgo there), and is deliberately not published: no Windows build of this
|
||||||
|
# app has ever been run, and no tier here can exercise one. Shipping it
|
||||||
|
# would be a promise nothing in this repo can keep. Revisit when someone
|
||||||
|
# has actually booted it.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build and attach (default: the latest v* tag)"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: desktop-assets-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ubuntu:24.04
|
||||||
|
volumes:
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
GO_VERSION: '1.25.0'
|
||||||
|
npm_config_store_dir: /cache/pnpm-store
|
||||||
|
steps:
|
||||||
|
# The same set ci.yml's check job installs: the app is cgo, and
|
||||||
|
# without alsa.pc oto/v3 fails at `pkg-config --cflags -- alsa`
|
||||||
|
# before anything is compiled.
|
||||||
|
- name: System packages
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq --no-install-recommends \
|
||||||
|
ca-certificates curl git jq build-essential pkg-config \
|
||||||
|
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev
|
||||||
|
|
||||||
|
- name: Clone repo at this commit
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git clone --quiet \
|
||||||
|
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||||
|
git -C /src checkout --quiet --detach "$SHA"
|
||||||
|
git config --global --add safe.directory /src
|
||||||
|
git -C /src log --oneline -1
|
||||||
|
|
||||||
|
- name: Resolve the version
|
||||||
|
id: version
|
||||||
|
working-directory: /src
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
v="${{ inputs.version }}"
|
||||||
|
if [ -z "$v" ]; then
|
||||||
|
case "$REF_NAME" in
|
||||||
|
v*) v="$REF_NAME" ;;
|
||||||
|
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*') ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
case "$v" in v*) ;; *) v="v$v" ;; esac
|
||||||
|
|
||||||
|
# v0.0.0 is semantic-release's version floor, not a shipment —
|
||||||
|
# see the bootstrap step in release.yml. Nothing is built for
|
||||||
|
# it, and this is a clean skip rather than a failure because a
|
||||||
|
# red run against a tag that was never meant to ship is noise.
|
||||||
|
if [ "$v" = "v0.0.0" ]; then
|
||||||
|
echo "v0.0.0 is the version floor, not a release; nothing to build"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "building $v"
|
||||||
|
|
||||||
|
- name: Go toolchain
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
|
||||||
|
mkdir -p /cache/tool && rm -rf /cache/tool/go
|
||||||
|
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
|
||||||
|
fi
|
||||||
|
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
|
||||||
|
/cache/tool/go/bin/go version
|
||||||
|
|
||||||
|
- name: Node toolchain
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt-get install -y -qq --no-install-recommends nodejs
|
||||||
|
corepack enable
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# `make build-prod` is the production task: -trimpath and -w -s are
|
||||||
|
# already in it, so only the version stamp is passed, through the
|
||||||
|
# LDFLAGS_EXTRA variable this repo added to build/linux/Taskfile.yml.
|
||||||
|
# (`wails3 build` has no -ldflags of its own; that was v2.)
|
||||||
|
- name: Build
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
export PATH="/src/scripts/toolbin:$PATH"
|
||||||
|
commit=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
go generate ./...
|
||||||
|
go tool wails3 task build \
|
||||||
|
LDFLAGS_EXTRA="-X 'main.version=${TAG}' -X 'main.commit=${commit}'"
|
||||||
|
|
||||||
|
# Described, never run: main.go has no flag parsing, so any
|
||||||
|
# invocation here would try to open a window in a container with
|
||||||
|
# no display and hang the job rather than printing a version.
|
||||||
|
test -x bin/yellowjacket
|
||||||
|
ls -la bin/yellowjacket
|
||||||
|
file bin/yellowjacket || true
|
||||||
|
|
||||||
|
# The .desktop file and the icon go in the tarball because without
|
||||||
|
# them the binary is a window with no menu entry — the Arch package
|
||||||
|
# installs both, and this is the same app for people not using it.
|
||||||
|
- name: Package the tarball
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
dir="yellowjacket-${VERSION}-linux-amd64"
|
||||||
|
mkdir -p "/tmp/$dir"
|
||||||
|
cp bin/yellowjacket "/tmp/$dir/"
|
||||||
|
cp packaging/arch/yellowjacket.desktop "/tmp/$dir/"
|
||||||
|
cp frontend/src/assets/images/icons/music/compact-disc.svg \
|
||||||
|
"/tmp/$dir/yellowjacket.svg"
|
||||||
|
tar -C /tmp -czf "/tmp/${dir}.tar.gz" "$dir"
|
||||||
|
ls -la "/tmp/${dir}.tar.gz"
|
||||||
|
|
||||||
|
- name: Attach it to the release
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
./scripts/release-asset.sh "$TAG" \
|
||||||
|
"/tmp/yellowjacket-${VERSION}-linux-amd64.tar.gz"
|
||||||
@@ -14,6 +14,15 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to sync (default: the pushed tag)"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: homebrew-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync-formula:
|
sync-formula:
|
||||||
@@ -30,10 +39,25 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Compute version and tarball checksum
|
- name: Compute version and tarball checksum
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${GITHUB_REF_NAME}" # e.g. v1.3.0
|
TAG="${{ inputs.version }}"
|
||||||
VERSION="${TAG#v}" # e.g. 1.3.0
|
[ -n "$TAG" ] || TAG="${GITHUB_REF_NAME}" # e.g. v0.0.1
|
||||||
|
case "$TAG" in v*) ;; *) TAG="v$TAG" ;; esac
|
||||||
|
VERSION="${TAG#v}" # e.g. 0.0.1
|
||||||
|
|
||||||
|
# v0.0.0 is semantic-release's version floor, not a shipment —
|
||||||
|
# see the bootstrap step in release.yml. Skipped cleanly rather
|
||||||
|
# than failing: this one would otherwise push a formula for a
|
||||||
|
# version that does not exist into a *public* tap.
|
||||||
|
if [ "$VERSION" = "0.0.0" ]; then
|
||||||
|
echo "v0.0.0 is the version floor, not a release; nothing to sync"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
|
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
|
||||||
|
|
||||||
echo "Fetching ${TARBALL}"
|
echo "Fetching ${TARBALL}"
|
||||||
@@ -53,6 +77,7 @@ jobs:
|
|||||||
echo "SHA256=${SHA256}" >> "$GITHUB_ENV"
|
echo "SHA256=${SHA256}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Render the formula with the new version and checksum
|
- name: Render the formula with the new version and checksum
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
src="packaging/homebrew/Formula/yellowjacket.rb"
|
src="packaging/homebrew/Formula/yellowjacket.rb"
|
||||||
@@ -66,6 +91,7 @@ jobs:
|
|||||||
cat yellowjacket.rb
|
cat yellowjacket.rb
|
||||||
|
|
||||||
- name: Push to the Homebrew tap repo
|
- name: Push to the Homebrew tap repo
|
||||||
|
if: steps.version.outputs.skip == 'false'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap
|
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap
|
||||||
|
|||||||
@@ -7,11 +7,29 @@ name: Search index maintenance
|
|||||||
# import older than 6mo -> rebuild (re-import from the newest dump)
|
# import older than 6mo -> rebuild (re-import from the newest dump)
|
||||||
# otherwise -> refresh (fold in new incremental listens)
|
# otherwise -> refresh (fold in new incremental listens)
|
||||||
#
|
#
|
||||||
# A refresh is cheap and no-ops when nothing new has been published, so
|
# **There is deliberately no `push` trigger, and restoring one is a
|
||||||
# running it on every push to main is safe.
|
# decision rather than a cleanup.** A refresh is individually cheap, so
|
||||||
|
# running it on every push to main looked free; what it actually does is
|
||||||
|
# put an unattended job that mutates the only copy of a ~205 GB catalog
|
||||||
|
# on the same trigger as an ordinary code change, on a runner with
|
||||||
|
# capacity 1.
|
||||||
|
#
|
||||||
|
# That is not hypothetical. On 2026-08-17 `fix(database): retire a table
|
||||||
|
# whose shape the schema moved past` landed on main, green — the CI
|
||||||
|
# database is deliberately in the older encoding, so the stale-shape
|
||||||
|
# repair judged its `explore_index` stale and dropped it, and this job
|
||||||
|
# fell back to a full import from the dumps. `fix(database): never
|
||||||
|
# retire the catalog the index build derives` stops that specific repair
|
||||||
|
# and cannot undo it. Every push to main then booked another `budget`
|
||||||
|
# (3h) of the one runner while ordinary CI queued behind it.
|
||||||
|
#
|
||||||
|
# So the rule this file is an instance of: **a job that mutates state
|
||||||
|
# which cannot be rebuilt in ten minutes is triggered deliberately, not
|
||||||
|
# by a push.** The weekly cron keeps the catalog current, and
|
||||||
|
# workflow_dispatch resumes or forces a build — indexbuild picks up from
|
||||||
|
# its checkpoint either way, so nothing is lost by not running on every
|
||||||
|
# merge. See docs/index-cache.md for the snapshot and the restore.
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
schedule:
|
schedule:
|
||||||
# Weekly update pass. The 6-month rebuild is triggered by the same
|
# Weekly update pass. The 6-month rebuild is triggered by the same
|
||||||
# command when it notices the import has aged out.
|
# command when it notices the import has aged out.
|
||||||
@@ -33,6 +51,10 @@ on:
|
|||||||
|
|
||||||
# Runs share one persistent working directory, so they must not overlap.
|
# Runs share one persistent working directory, so they must not overlap.
|
||||||
# A push landing mid-build waits rather than corrupting the checkpoint.
|
# A push landing mid-build waits rather than corrupting the checkpoint.
|
||||||
|
#
|
||||||
|
# That directory holds the only copy of a catalog nothing can cheaply
|
||||||
|
# re-derive: see docs/index-cache.md for the snapshot it takes and the
|
||||||
|
# restore, which is minutes against the hours a rebuild costs.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: search-index
|
group: search-index
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
@@ -42,7 +64,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
# CGO is not needed: the project uses the pure-Go modernc sqlite
|
# CGO is not needed: the project uses the pure-Go modernc sqlite
|
||||||
# driver, and neither command imports the Wails app.
|
# driver, and neither command imports the Wails app — which is a
|
||||||
|
# claim with a test behind it now (cmd/indexbuild/deps_test.go),
|
||||||
|
# because the v3 migration quietly broke it and this job was where
|
||||||
|
# that surfaced.
|
||||||
image: golang:1.25
|
image: golang:1.25
|
||||||
# This host path must exist on the runner and be listed verbatim in
|
# This host path must exist on the runner and be listed verbatim in
|
||||||
# act_runner's container.valid_volumes. It holds explore-staging/
|
# act_runner's container.valid_volumes. It holds explore-staging/
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# The sixth workflow, and the one that decides whether the other three
|
||||||
|
# run at all. On every push to main it reads the Conventional Commits
|
||||||
|
# since the last tag, and if any of them is releasable it writes the
|
||||||
|
# changelog, pushes the tag, and creates the Gitea release whose body is
|
||||||
|
# that changelog section. The publishing workflows are keyed on `v*`, so
|
||||||
|
# the tag push is what starts them.
|
||||||
|
#
|
||||||
|
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
|
||||||
|
# Gitea, like GitHub, does not start a workflow from a ref pushed by a
|
||||||
|
# workflow's own token (go-gitea#33123). The token is what decides this,
|
||||||
|
# not the workflow — so semantic-release is handed a repositoryUrl
|
||||||
|
# carrying a *user* PAT, and the resulting push is attributed to a person
|
||||||
|
# and triggers the `v*` workflows normally.
|
||||||
|
#
|
||||||
|
# That limitation is used deliberately in the bootstrap step below, where
|
||||||
|
# a tag that must *not* trigger anything is pushed with the Actions token
|
||||||
|
# instead.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Cutting a tag is not a thing to cancel halfway: a superseded run must
|
||||||
|
# finish, not be killed between `git push --tags` and the release POST.
|
||||||
|
concurrency:
|
||||||
|
group: release-main
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ubuntu:24.04
|
||||||
|
env:
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
steps:
|
||||||
|
- name: System packages
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq --no-install-recommends ca-certificates curl git jq
|
||||||
|
|
||||||
|
- name: Node toolchain
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt-get install -y -qq --no-install-recommends nodejs
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# By hand rather than actions/checkout, like the other five: that is
|
||||||
|
# a JS action and needs node inside the container before any step has
|
||||||
|
# installed it. The full history is required — semantic-release
|
||||||
|
# reads tags and walks commits, and a shallow clone silently makes
|
||||||
|
# every release look like the first one.
|
||||||
|
- name: Clone repo at this commit
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git clone --quiet \
|
||||||
|
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||||
|
# -B main rather than --detach, which the other five workflows
|
||||||
|
# use: semantic-release resolves the release branch and then
|
||||||
|
# pushes a commit and a tag to it, and a detached HEAD is a
|
||||||
|
# worse starting point for both than a local branch named after
|
||||||
|
# the one being released. Pinned to this commit, not to
|
||||||
|
# whatever main points at by the time the container started.
|
||||||
|
git -C /src checkout --quiet -B main "${{ github.sha }}"
|
||||||
|
git config --global --add safe.directory /src
|
||||||
|
git -C /src log --oneline -1
|
||||||
|
|
||||||
|
# Nothing currently pushes a `chore(release):` commit — main is a
|
||||||
|
# protected branch, so .releaserc.yml carries no @semantic-release/git
|
||||||
|
# and the release page is the changelog. This guard is kept for the
|
||||||
|
# day someone adds that plugin back: without it the commit-back is a
|
||||||
|
# push to the branch this workflow runs on, and the loop is a release
|
||||||
|
# per release. Six lines against that is cheap.
|
||||||
|
- name: Skip a changelog commit, if one ever exists
|
||||||
|
id: guard
|
||||||
|
working-directory: /src
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
subject=$(git log -1 --format='%s')
|
||||||
|
case "$subject" in
|
||||||
|
"chore(release):"*)
|
||||||
|
echo "this is the release commit itself; nothing to do"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# semantic-release calls the first release of a repo with no tags
|
||||||
|
# 1.0.0, and offers no option to say otherwise. A floor tag is the
|
||||||
|
# only way to start at 0.0.1, so this creates one — once, ever.
|
||||||
|
#
|
||||||
|
# **It is pushed with the Actions token on purpose.** v0.0.0 is a
|
||||||
|
# floor, not a shipment: pushing it with a user PAT would start the
|
||||||
|
# Arch, Homebrew and Android workflows for a version that does not
|
||||||
|
# exist. The very limitation the header describes is what makes
|
||||||
|
# this inert.
|
||||||
|
- name: Seed the version floor
|
||||||
|
if: steps.guard.outputs.skip == 'false'
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
ACTIONS_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git fetch --quiet --tags origin
|
||||||
|
|
||||||
|
if [ -n "$(git tag --list 'v[0-9]*')" ]; then
|
||||||
|
echo "floor already set; newest tag is $(git describe --tags --abbrev=0 --match 'v[0-9]*')"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prefer the Actions token because a ref it pushes starts no
|
||||||
|
# workflow, which is the whole point for a tag that is a floor
|
||||||
|
# rather than a shipment. Falling back to the PAT is safe
|
||||||
|
# rather than merely convenient: all four publishing workflows
|
||||||
|
# skip v0.0.0 explicitly, so the worst case is four jobs that
|
||||||
|
# start and immediately say there is nothing to build.
|
||||||
|
token="${ACTIONS_TOKEN:-$PACKAGE_TOKEN}"
|
||||||
|
[ -n "$ACTIONS_TOKEN" ] || echo "note: GITEA_TOKEN is unset; using the PAT"
|
||||||
|
|
||||||
|
# **On the parent, not on HEAD.** The floor marks what has
|
||||||
|
# already been released, so tagging the commit being pushed
|
||||||
|
# leaves nothing between the floor and HEAD — semantic-release
|
||||||
|
# then correctly reports there is nothing to release, which is
|
||||||
|
# exactly what the first run of this workflow did. HEAD^ is the
|
||||||
|
# first parent, so on the merge commit this fires for it is main
|
||||||
|
# as it was before the merge, and everything the merge brought
|
||||||
|
# in is releasable.
|
||||||
|
floor=$(git rev-parse "${{ github.sha }}^" 2>/dev/null || true)
|
||||||
|
if [ -z "$floor" ]; then
|
||||||
|
echo "HEAD has no parent, so no commit can precede the floor" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "no v* tag exists — seeding v0.0.0 so the first release is 0.0.1"
|
||||||
|
git tag v0.0.0 "$floor"
|
||||||
|
git push --quiet \
|
||||||
|
"https://x-access-token:${token}@${SERVER_URL#https://}/${REPO}.git" \
|
||||||
|
refs/tags/v0.0.0
|
||||||
|
echo "seeded v0.0.0 at $floor (parent of ${{ github.sha }})"
|
||||||
|
|
||||||
|
# Pinned rather than installed into the repo: this is a Go project
|
||||||
|
# and a package.json at its root invites the npm plugin and every
|
||||||
|
# tool that looks for one. conventional-changelog-conventionalcommits
|
||||||
|
# is in the list because both the analyzer and the notes generator
|
||||||
|
# name that preset and neither depends on it.
|
||||||
|
#
|
||||||
|
# **That preset is held at 9 and the reason is worth keeping.** At
|
||||||
|
# 10 it is silently incompatible with the writer that
|
||||||
|
# release-notes-generator@14 pulls in (^8): every release note comes
|
||||||
|
# out as a bare `## 0.0.1 (date)` heading with **no sections and no
|
||||||
|
# commits under it**, and nothing errors. The version would have
|
||||||
|
# been right, the tag would have been right, every job would have
|
||||||
|
# been green, and the release body would have been empty. Check the
|
||||||
|
# notes, not the exit code, before moving any of these.
|
||||||
|
- name: Run semantic-release
|
||||||
|
if: steps.guard.outputs.skip == 'false'
|
||||||
|
working-directory: /src
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git config user.name "yellowjacket-ci"
|
||||||
|
git config user.email "yj@yellowjacket.app"
|
||||||
|
|
||||||
|
npx --yes \
|
||||||
|
-p semantic-release@25 \
|
||||||
|
-p @semantic-release/commit-analyzer@13 \
|
||||||
|
-p @semantic-release/release-notes-generator@14 \
|
||||||
|
-p @semantic-release/changelog@7 \
|
||||||
|
-p @semantic-release/exec@7 \
|
||||||
|
-p conventional-changelog-conventionalcommits@9 \
|
||||||
|
semantic-release \
|
||||||
|
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
|
||||||
@@ -63,10 +63,28 @@ bin/
|
|||||||
# packaging tasks that depend on it. A derived file with one source.
|
# packaging tasks that depend on it. A derived file with one source.
|
||||||
build/linux/yellowjacket.desktop
|
build/linux/yellowjacket.desktop
|
||||||
|
|
||||||
# `wails3 task common:update:build-assets` regenerates the mobile trees
|
# iOS is not carried. `wails3 update build-assets` regenerates the tree
|
||||||
# whether or not anything asks for them. This is a desktop player and
|
# whether or not anything asks for it, so it is ignored rather than
|
||||||
# cannot target iOS/Android, so their includes: entries are dropped from
|
# deleted-and-rediscovered on every asset refresh, and its includes:
|
||||||
# Taskfile.yml and the trees themselves are not carried — ignored rather
|
# entry is dropped from Taskfile.yml.
|
||||||
# than deleted-and-rediscovered on every asset refresh.
|
#
|
||||||
|
# build/android/ *is* carried — see plan 015. Note that `update
|
||||||
|
# build-assets` does NOT regenerate it (only `generate build-assets`
|
||||||
|
# does, and that rewrites the whole of build/), so the tree is committed
|
||||||
|
# and edited by hand like any other source. Only its output is ignored,
|
||||||
|
# below.
|
||||||
build/ios/
|
build/ios/
|
||||||
build/android/
|
|
||||||
|
# Android build output. jniLibs holds the ~30 MB per-ABI c-shared
|
||||||
|
# libraries the Go build produces; gen/ and overlay.json are written by
|
||||||
|
# `wails3 android overlay:gen`; the rest is Gradle's.
|
||||||
|
build/android/app/src/main/jniLibs/
|
||||||
|
build/android/app/build/
|
||||||
|
build/android/build/
|
||||||
|
build/android/.gradle/
|
||||||
|
build/android/gen/
|
||||||
|
build/android/overlay.json
|
||||||
|
|
||||||
|
# Written by @semantic-release/changelog purely to carry the release notes
|
||||||
|
# into scripts/gitea-release.sh; the release page is the changelog.
|
||||||
|
.release-notes.md
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ linters:
|
|||||||
- usetesting
|
- usetesting
|
||||||
- whitespace
|
- whitespace
|
||||||
- wsl_v5
|
- wsl_v5
|
||||||
|
exclusions:
|
||||||
|
paths:
|
||||||
|
# Wails scaffold, not ours. `build/android/` is generated by
|
||||||
|
# `wails3 generate build-assets` and carried verbatim (plan 015),
|
||||||
|
# and it contains one Go file -- scripts/deps/install_deps.go, the
|
||||||
|
# interactive SDK installer behind `task android:install:deps`.
|
||||||
|
# It trips 24 of the strict linters above, and reformatting
|
||||||
|
# upstream's file to our house style would be undone by the next
|
||||||
|
# refresh and would make the diff against upstream unreadable.
|
||||||
|
# `make android-setup` is what this repo uses instead.
|
||||||
|
- build/android/
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- gci
|
- gci
|
||||||
|
|||||||
@@ -96,6 +96,15 @@ reference, because you need them *before* the failure, not after.
|
|||||||
Run against the `bulk` seed a measurement session left behind and a
|
Run against the `bulk` seed a measurement session left behind and a
|
||||||
third of them fail (13 of 36, when it was measured), in a list that
|
third of them fail (13 of 36, when it was measured), in a list that
|
||||||
reads exactly like a regression in whatever you are holding. `make dev-headless SEED=default` first.
|
reads exactly like a regression in whatever you are holding. `make dev-headless SEED=default` first.
|
||||||
|
- **The catalog is stubbed out locally now, like CI.**
|
||||||
|
`dev-headless.sh` defaults `YJ_CORE_INDEX_URL` to a dead address
|
||||||
|
because it was the only launcher that did not — `seed-sandbox.sh` and
|
||||||
|
`ci.yml` always have. Without it the app downloads the real ~1M-row
|
||||||
|
Explore catalog into the run's `YJ_HOME`, and specs that stage their
|
||||||
|
own catalog rows then search a million real ones and fail *locally
|
||||||
|
only*, which reads as a regression and is an environment. Pass
|
||||||
|
`YJ_CORE_INDEX_URL=<real url>` when you want the real catalog to
|
||||||
|
explore by hand.
|
||||||
- **…and the suite spends state it cannot always give back.**
|
- **…and the suite spends state it cannot always give back.**
|
||||||
`view-lifecycle.spec.ts` **skips an autotag album** on every run, out
|
`view-lifecycle.spec.ts` **skips an autotag album** on every run, out
|
||||||
of the eleven the seed has, and does not put it back — so around the
|
of the eleven the seed has, and does not put it back — so around the
|
||||||
@@ -151,6 +160,7 @@ only climb when it cannot.
|
|||||||
| Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive |
|
| Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive |
|
||||||
| Something whose answer is a *number*, not a pass | `make perf` against a bulk-seeded app | ~1 min + setup |
|
| Something whose answer is a *number*, not a pass | `make perf` against a bulk-seeded app | ~1 min + setup |
|
||||||
| A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | |
|
| A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | |
|
||||||
|
| Anything that has to survive on a phone | `make android-smoke` against a booted emulator | ~1 min + setup |
|
||||||
|
|
||||||
Two targets are once-per-clone prerequisites that are **not**
|
Two targets are once-per-clone prerequisites that are **not**
|
||||||
dependencies of the targets needing them, so on a fresh checkout each
|
dependencies of the targets needing them, so on a fresh checkout each
|
||||||
@@ -426,3 +436,9 @@ fails the build otherwise, including in files no lint pass compiles.
|
|||||||
and what breaks in it.
|
and what breaks in it.
|
||||||
- [schema-change.md](references/schema-change.md) — the two-file
|
- [schema-change.md](references/schema-change.md) — the two-file
|
||||||
schema/migration checklist.
|
schema/migration checklist.
|
||||||
|
- [android-tier.md](references/android-tier.md) — the emulator tier,
|
||||||
|
and the three reasons a failure there looks like a success. **Read
|
||||||
|
its first section before running anything on Android**: Go's stdout
|
||||||
|
does not reach logcat, `os.Exit` leaves no panic and no tombstone,
|
||||||
|
and ActivityManager restarts a dying app fast enough that `pidof`
|
||||||
|
always answers.
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
# The Android tier
|
||||||
|
|
||||||
|
A sixth tier, and the only one where **the app failing looks exactly
|
||||||
|
like the app working**. Read the first section before you run anything;
|
||||||
|
it is the difference between a diagnosis and an afternoon.
|
||||||
|
|
||||||
|
This tier answers "does the phone build run", nothing else. It is not a
|
||||||
|
spec tier, it does not run in CI, and the app is not a usable Android
|
||||||
|
player yet (plan 015 says why, at length).
|
||||||
|
|
||||||
|
## Three facts that make failure invisible
|
||||||
|
|
||||||
|
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
|
||||||
|
`/dev/null`. Every `slog` line the app writes is discarded — including
|
||||||
|
the one naming the error it is about to exit on. `setprop
|
||||||
|
log.redirect-stdio true` does not help: it redirects the *Java*
|
||||||
|
runtime's `System.out`, and the Go code is a c-shared native library.
|
||||||
|
|
||||||
|
**`os.Exit` is a silent death.** `main()` ends several failure paths in
|
||||||
|
`os.Exit(1)`. From Android's side that is a process that vanished:
|
||||||
|
`ActivityManager: Process com.wails.app has died`, `Zygote: exited due
|
||||||
|
to signal 9`, and **no** panic, **no** `AndroidRuntime` stack, **no**
|
||||||
|
tombstone under `/data/tombstones` and nothing in `logcat -b crash` or
|
||||||
|
dropbox. All three of the places you would look are empty, and the one
|
||||||
|
signal that is present — SIGKILL — reads as "the system killed it",
|
||||||
|
which is the wrong hypothesis.
|
||||||
|
|
||||||
|
**ActivityManager restarts it, so a dead app looks alive.** A
|
||||||
|
crash-looping app is respawned several times a second, so `pidof` always
|
||||||
|
answers and `am start` always reports `Status: ok`. "Did it start" is
|
||||||
|
the wrong question. `make android-smoke` asks the right one — is it the
|
||||||
|
*same pid* a few seconds later.
|
||||||
|
|
||||||
|
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
|
||||||
|
followed immediately by a new pid doing the same thing. That means the
|
||||||
|
native library loaded, the JNI bridge came up, Go's `main()` ran, and
|
||||||
|
`main()` left. Work backwards through its `os.Exit(1)` paths.
|
||||||
|
|
||||||
|
## What to run
|
||||||
|
|
||||||
|
One-time, ~3.5 GB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make android-setup # SDK pieces + the yj-test AVD, idempotent
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make android # arm64-v8a APK -> bin/yellowjacket.apk (~16 MB)
|
||||||
|
make android-emulator # boot headless in the background, wait for boot
|
||||||
|
make android-install # adb install -r
|
||||||
|
make android-smoke # launch, then assert the same pid survives 10s
|
||||||
|
make android-logs # filtered logcat, follow
|
||||||
|
make android-emulator-stop # console kill, then the saved PID
|
||||||
|
```
|
||||||
|
|
||||||
|
`make android-smoke SECONDS=30` for a longer window. On failure it
|
||||||
|
prints the last 40 app-relevant logcat lines and how to read them.
|
||||||
|
|
||||||
|
Never `pkill -f emulator` — the pattern matches the invoking shell's own
|
||||||
|
command line and kills it, silently dropping the rest of your compound
|
||||||
|
command. The emulator is addressed by its saved pid in
|
||||||
|
`.dev/emulator.pid`, same discipline as `make dev-stop`.
|
||||||
|
|
||||||
|
**adb is addressed by AVD name, not by whatever is plugged in.** The
|
||||||
|
script resolves `ANDROID_SERIAL` from `ro.boot.qemu.avd_name` before
|
||||||
|
any device command, because a second emulator (another project's, or
|
||||||
|
this one's own corpse left `offline` by a previous run) makes a bare
|
||||||
|
`adb` fail with "more than one device" — which `cmd_install` reported
|
||||||
|
as *"no device — run 'make android-emulator' first"* immediately after
|
||||||
|
that had succeeded. Serials are assigned in boot order and change
|
||||||
|
between runs, so the AVD name is the identity. Set `ANDROID_SERIAL`
|
||||||
|
yourself and it is honoured; one device that is not ours (a phone) is
|
||||||
|
taken as the target.
|
||||||
|
|
||||||
|
## Things that cost a cycle
|
||||||
|
|
||||||
|
- **`ANDROID_HOME` must carry a platform, and Arch's does not.**
|
||||||
|
`/opt/android-sdk` (the `android-sdk` package) has an NDK and
|
||||||
|
build-tools but `platforms/` is *empty*, so Gradle fails with a
|
||||||
|
compileSdk error that reads like a version mismatch. The Makefile
|
||||||
|
defaults `ANDROID_SDK` to `~/Android/Sdk` (user-owned, writable,
|
||||||
|
where sdkmanager puts things) and `ANDROID_NDK` to `/opt/android-ndk`
|
||||||
|
separately, because the Go half wants the NDK and the Gradle half
|
||||||
|
wants the platform and they are in different places.
|
||||||
|
- **The NDK is pinned to r26d** (`26.3.11579264`, Arch's
|
||||||
|
`android-ndk-26`). Newer NDKs have broken the Wails Android build
|
||||||
|
before. CI pins the same one.
|
||||||
|
- **Without KVM the emulator still works and is unusably slow** — a 30 s
|
||||||
|
boot becomes tens of minutes, which reads as a hung target rather than
|
||||||
|
a slow one. `make android-setup` checks and warns.
|
||||||
|
- **`-no-snapshot` is deliberate.** A snapshot-resumed emulator carries
|
||||||
|
the previous run's app state, and a smoke result that depends on what
|
||||||
|
the last run left behind is not a result.
|
||||||
|
- **The logcat filter is not optional.** The emulator emits thousands of
|
||||||
|
lines a second, nearly all WindowManager transitions; an unfiltered
|
||||||
|
`adb logcat` buries the six lines that matter. `make android-logs`
|
||||||
|
filters to `WailsBridge`, the app's own tag, `GoLog`, `AndroidRuntime`,
|
||||||
|
`DEBUG` and `libc:F`.
|
||||||
|
- **`run-as` does not work on a release-signed APK** (`package not
|
||||||
|
debuggable`), so you cannot read the app's data directory or its
|
||||||
|
environment that way. Ask the device instead, or build a debug variant.
|
||||||
|
- **The `google_apis` system image, not `default`.** This app is a
|
||||||
|
WebView app; `google_apis` ships the Chrome-based WebView that
|
||||||
|
actually renders it.
|
||||||
|
|
||||||
|
## The current state of the build
|
||||||
|
|
||||||
|
**The app starts. The x86_64 emulator cannot run it, and that is not a
|
||||||
|
bug in the app.**
|
||||||
|
|
||||||
|
`modernc.org/libc` — which `modernc.org/sqlite`, and therefore the whole
|
||||||
|
database layer, sits on — issues a **raw `lstat` syscall on
|
||||||
|
linux/amd64** (`libc_linux_amd64.go`'s `Xlstat64` calls
|
||||||
|
`unix.Syscall(unix.SYS_LSTAT, …)`). Android's seccomp policy forbids
|
||||||
|
syscall 6 on x86_64, because bionic never issues it, so the process
|
||||||
|
takes `SIGSYS` the first time anything touches the database:
|
||||||
|
|
||||||
|
```
|
||||||
|
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
|
||||||
|
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
|
||||||
|
```
|
||||||
|
|
||||||
|
**arm64 is unaffected, and structurally so.** There is no `lstat`
|
||||||
|
syscall on arm64 at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
|
||||||
|
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which
|
||||||
|
Android permits. `grep -c SYS_LSTAT ccgo_linux_arm64.go` is 0. Go's own
|
||||||
|
`syscall` package already uses `fstatat` on both architectures, which
|
||||||
|
is why this is *only* the modernc path.
|
||||||
|
|
||||||
|
So: **verify on arm64, and on this machine that means a real device.**
|
||||||
|
`make android-smoke` on an x86_64 AVD reports a `SIGSYS` tombstone that
|
||||||
|
says nothing about your change.
|
||||||
|
|
||||||
|
**Do not reach for an arm64 system image — it will not run here, and
|
||||||
|
finding that out costs a 3.8 GB download.** Emulator 37 refuses
|
||||||
|
outright:
|
||||||
|
|
||||||
|
```
|
||||||
|
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
|
||||||
|
emulator on x86_64 host. System image must match the host
|
||||||
|
architecture.
|
||||||
|
```
|
||||||
|
|
||||||
|
Google dropped cross-architecture emulation; there is no flag. The
|
||||||
|
options are an arm64 host, a physical device, or `adb connect` to one.
|
||||||
|
|
||||||
|
**The x86_64 ABI is therefore gone from the build** (`abiFilters` in
|
||||||
|
`build/android/app/build.gradle`, `android:package` rather than
|
||||||
|
`package:fat` in the Makefile, and a `native-code: 'arm64-v8a'$`
|
||||||
|
assertion in `android-apk.yml` that fails if it comes back). It could
|
||||||
|
not run on any Android until modernc fixes this — x86 Chromebooks
|
||||||
|
included — and dropping it took the artifact from 27 MB to 15.9 MB.
|
||||||
|
The tombstone was at least honest while it lasted: unlike the
|
||||||
|
`os.Exit` that came before it, it left a real crash record with a
|
||||||
|
backtrace.
|
||||||
|
|
||||||
|
### The emulator still installs it, and it still does not run
|
||||||
|
|
||||||
|
The obvious guess about dropping x86_64 — that `make android-install`
|
||||||
|
would now refuse with `INSTALL_FAILED_NO_MATCHING_ABIS` — is **wrong,
|
||||||
|
and was measured wrong before it was written down.** Google's
|
||||||
|
`google_apis` x86_64 images carry arm64 translation:
|
||||||
|
|
||||||
|
```
|
||||||
|
ro.product.cpu.abilist = x86_64,arm64-v8a
|
||||||
|
```
|
||||||
|
|
||||||
|
So the arm64-only APK installs, the loader maps `lib/arm64/libwails.so`
|
||||||
|
and runs it (the tombstone says `Guest architecture: 'arm64'`). It then
|
||||||
|
dies **before any of our code**, with SIGILL rather than SIGSYS:
|
||||||
|
|
||||||
|
```
|
||||||
|
signal 4 (SIGILL), code -6 (SI_TKILL)
|
||||||
|
#00 pc 00000000015911d0 .../lib/arm64/libwails.so
|
||||||
|
```
|
||||||
|
|
||||||
|
Disassembling that offset names the reason exactly:
|
||||||
|
|
||||||
|
```
|
||||||
|
15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1
|
||||||
|
```
|
||||||
|
|
||||||
|
That is Go's `internal/cpu` reading the arm64 CPU-feature ID register
|
||||||
|
at runtime init, which the translator does not implement. So it is not
|
||||||
|
"our Go program is unlucky": **no Go binary starts under this
|
||||||
|
translation layer**, and no amount of work on this app changes it.
|
||||||
|
|
||||||
|
The three failures are worth holding side by side, because each looks
|
||||||
|
like the app's fault and none is:
|
||||||
|
|
||||||
|
| build | on x86_64 Android | signal |
|
||||||
|
|---|---|---|
|
||||||
|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
|
||||||
|
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
|
||||||
|
| arm64, real device | — | unverified, still |
|
||||||
|
|
||||||
|
**A physical arm64 device remains the only verification path.**
|
||||||
|
|
||||||
|
### What was fixed to get here
|
||||||
|
|
||||||
|
`backend/system`'s `buildUserDirPath` switched on `runtime.GOOS` with a
|
||||||
|
`default:` returning `errUnsupportedOS`, so Android failed at startup
|
||||||
|
and `main()` called `os.Exit(1)` six milliseconds after the bridge came
|
||||||
|
up. `main()` now calls `system.UseHomeOverride(application.Mobile.
|
||||||
|
StoragePath())` before anything asks for a path — a documented,
|
||||||
|
build-tag-free API that returns `""` on desktop, where the setter is a
|
||||||
|
no-op. `backend/system` gained no import of the Wails application
|
||||||
|
package, which matters for the same reason `backend/events` is split by
|
||||||
|
the `indexbuild` tag.
|
||||||
|
|
||||||
|
### What is still not done
|
||||||
|
|
||||||
|
The shell is still a desktop shell, and the x86_64 half of the APK is
|
||||||
|
still dead weight. Everything in plan 016's section A is now built:
|
||||||
|
storage access, an in-app folder picker (Android's directory dialog
|
||||||
|
returns an error, since the Storage Access Framework yields tree URIs
|
||||||
|
rather than paths), MPRIS excluded, and a MediaSession with a transport
|
||||||
|
notification and audio focus.
|
||||||
|
|
||||||
|
### Compiling the `android`-tagged Go by hand
|
||||||
|
|
||||||
|
`make lint` and `make test` never see it: their three tag sets are all
|
||||||
|
linux/amd64, so the only thing that compiles `backend/mediacontrols/
|
||||||
|
android.go` is `make android` — a full APK build for a Go type error.
|
||||||
|
The short way round:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
|
||||||
|
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
|
||||||
|
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...
|
||||||
|
```
|
||||||
|
|
||||||
|
**`CXX` is not optional.** Without it the oboe C++ sources in `oto`
|
||||||
|
compile against the host sysroot and fail on `android/log.h` and
|
||||||
|
`sys/system_properties.h`, which reads like a broken or missing NDK.
|
||||||
|
Restrict it to `./backend/...`: `./...` additionally builds
|
||||||
|
`build/android/gen`, a scaffold shim that only resolves inside the
|
||||||
|
wails task and fails with `undefined: main` on its own.
|
||||||
|
|
||||||
|
A Go method added to a bound service also reaches the frontend unless
|
||||||
|
it says not to — `//wails:ignore` above the func, which `make bindings`
|
||||||
|
then honours. `Player.SetDuck` is driven by OS audio focus and carries
|
||||||
|
one.
|
||||||
|
|
||||||
|
## The scaffold's own tasks
|
||||||
|
|
||||||
|
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
|
||||||
|
they are the right thing to reach for when you want something one-off:
|
||||||
|
|
||||||
|
```
|
||||||
|
wails3 task android:run # debug build + emulator install + launch
|
||||||
|
wails3 task android:run:device # same, first connected physical device
|
||||||
|
wails3 task android:deploy-device # production APK to a device
|
||||||
|
wails3 task android:bundle:fat # AAB, for a Play Store upload
|
||||||
|
wails3 task android:studio # open build/android/ in Android Studio
|
||||||
|
wails3 task android:device:list
|
||||||
|
wails3 task android:logs:all
|
||||||
|
wails3 task android:clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Two are deliberately **not** wrapped. `android:logs` greps logcat for
|
||||||
|
`(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses
|
||||||
|
the app's own process tag (`app.yellowjacket` — lowercase, so `Wails`
|
||||||
|
does not match it) and misses `ActivityManager`'s "has died" line, which
|
||||||
|
is the one that tells you it crashed; `make android-logs` filters by tag
|
||||||
|
instead. And `ensure-emulator` boots whatever `-list-avds | tail -1`
|
||||||
|
returns, with no pidfile and no boot wait, so it cannot be stopped or
|
||||||
|
sequenced.
|
||||||
|
|
||||||
|
## The identity is declared twice
|
||||||
|
|
||||||
|
`applicationId` in `build/android/app/build.gradle` is what Gradle
|
||||||
|
installs. `APP_ID` in `build/android/Taskfile.yml` is what every
|
||||||
|
adb-driven task uninstalls, launches and filters. **Nothing enforces
|
||||||
|
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in
|
||||||
|
`build/config.yml` does not work in beta.8 — `wails3 task` never reads
|
||||||
|
that file (verified with `--dry`), and even when set it feeds only the
|
||||||
|
adb commands, never Gradle. Change both or the official `run`/`deploy`
|
||||||
|
tasks address a package that is not installed.
|
||||||
|
|
||||||
|
Related, and it will bite once: the launcher activity is
|
||||||
|
`com.wails.app.MainActivity` and the applicationId is
|
||||||
|
`app.yellowjacket`. `am start -n app.yellowjacket/.MainActivity`
|
||||||
|
resolves the leading dot against the *applicationId* and fails with a
|
||||||
|
class-not-found that reads like a broken build. Always the
|
||||||
|
fully-qualified form.
|
||||||
|
|
||||||
|
## What only a device can answer
|
||||||
|
|
||||||
|
The emulator cannot run this app (three separate reasons, none of them
|
||||||
|
ours — see plan 016), so the phone in someone's pocket is a tier, and
|
||||||
|
asking for it is cheap. The first run of it, on 2026-08-17, confirmed
|
||||||
|
the whole of A4 and found two faults **no other tier can see**:
|
||||||
|
|
||||||
|
- **The back gesture.** `MainActivity.onBackPressed` asks
|
||||||
|
`webView.canGoBack()`. Nothing in a desktop shell has a back gesture,
|
||||||
|
so no spec had ever called `page.goBack()` and the app had never
|
||||||
|
pushed a history entry — back quit from any depth. It is a history
|
||||||
|
entry per navigation now, which is also what made it assertable in the
|
||||||
|
browser tier (`e2e/specs/back-navigation.spec.ts`).
|
||||||
|
- **The safe area.** `targetSdk 35` forces edge-to-edge, so the
|
||||||
|
transport and the tab bar sat under the gesture bar. **A browser
|
||||||
|
viewport has no system bars**: `phone-shell.spec.ts` at 390x844 will
|
||||||
|
keep passing on a build the device is clipping 48dp off. Insets are
|
||||||
|
handled in `applyWindowInsets()`.
|
||||||
|
|
||||||
|
So when asking for a device run, ask about what the platform *adds* —
|
||||||
|
system bars, the back gesture, focus and audio interruptions,
|
||||||
|
permission dialogs, the keyboard — not about what the app draws. The
|
||||||
|
drawing is what the other five tiers already cover.
|
||||||
|
|
||||||
|
## Asking the device, not just looking at it
|
||||||
|
|
||||||
|
A real phone can be inspected, and that turns this tier from "reported
|
||||||
|
symptoms" into evidence. Three commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make android-screenshot # what the screen shows (.dev/ by default)
|
||||||
|
make android-inspect # forward the WebView's devtools socket
|
||||||
|
make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'
|
||||||
|
```
|
||||||
|
|
||||||
|
Four things about it, each of which costs an hour if met cold:
|
||||||
|
|
||||||
|
- **Only a `debuggable` build has a devtools socket**, and a debug build
|
||||||
|
carries `applicationIdSuffix ".dev"` so it installs **beside** the
|
||||||
|
release app. That matters more than convenience: the two are signed by
|
||||||
|
different certificates, and Android's only remedy for a changed
|
||||||
|
certificate is an uninstall, which takes the user's library with it.
|
||||||
|
Never uninstall to make room for a build.
|
||||||
|
- **Playwright cannot drive it.** `connectOverCDP` calls
|
||||||
|
`Browser.setDownloadBehavior`, a WebView answers "Browser context
|
||||||
|
management is not supported", and the connection dies before the first
|
||||||
|
evaluate. `scripts/android-eval.mjs` is raw CDP over Node's built-in
|
||||||
|
WebSocket for that reason.
|
||||||
|
- **Wireless adb drops when the screen sleeps.** The symptoms are
|
||||||
|
`device offline` mid-session and a `fetch failed` from the eval
|
||||||
|
script. Plug in over USB for anything longer than a couple of probes.
|
||||||
|
- **The socket name carries the pid**, which changes on every launch, so
|
||||||
|
it is resolved rather than remembered.
|
||||||
|
|
||||||
|
**And the reason to bother: the phone is an engine, not a screen.** The
|
||||||
|
first device here renders in **Chrome 113** at 424x439 CSS px. Every
|
||||||
|
other tier runs a current Chromium or WebKit, so a spec that passes at
|
||||||
|
that viewport says nothing about the phone — 113 has no Popover API and
|
||||||
|
no relaxed CSS nesting, and a dropped CSS declaration renders as
|
||||||
|
"present but wrong", which is the hardest failure to read from a
|
||||||
|
picture. Get the version first; it reframes every other symptom.
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
# 015 — Android release pipeline
|
||||||
|
|
||||||
|
Ship an Android APK from CI on every version tag, published to the Gitea
|
||||||
|
generic package registry so Obtainium can poll a plain URL.
|
||||||
|
|
||||||
|
The baseline is `~/Development/ljos`, whose `.gitea/workflows/ci.yml`
|
||||||
|
`android:` job has been through the failure modes already. Most of what
|
||||||
|
follows is a transcription of that job onto this repo's conventions;
|
||||||
|
where it differs, the difference is argued.
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
**This ships a pipeline, not a usable Android music player.** The
|
||||||
|
success criterion is a signed, installable APK that launches — not an
|
||||||
|
app anyone would want. Explicitly out of scope, and each is real:
|
||||||
|
|
||||||
|
- `backend/mediacontrols/mpris_linux.go` **will be compiled on Android**.
|
||||||
|
Go's `android` GOOS implies the `linux` build tag, so the `//go:build
|
||||||
|
linux` file is in the build and MPRIS will look for a session bus that
|
||||||
|
does not exist. It compiles; it will error at runtime.
|
||||||
|
- `backend/system` resolves XDG paths. Android has no XDG.
|
||||||
|
- The explore catalog artifact is ~0.6 GB. Nothing on a phone wants that.
|
||||||
|
- The shell is a desktop shell: an eleven-item sidebar, a 800×600
|
||||||
|
measured minimum, a transport bar. None of that is a phone layout.
|
||||||
|
- The library scanner walks a filesystem Android does not grant.
|
||||||
|
|
||||||
|
Those are the *next* plan, if there is one. Conflating them with this one
|
||||||
|
is how a build pipeline takes six weeks.
|
||||||
|
|
||||||
|
## Phase 0 — the gate [DONE 2026-08-16]
|
||||||
|
|
||||||
|
**Passed, further than asked.** No source changes were needed; a full
|
||||||
|
27 MB fat APK built first try, both ABIs, production-stripped. Numbers,
|
||||||
|
the environment and four non-obvious findings are in
|
||||||
|
`.planning/NOTES.md` — including a scaffold bug that put a *debug*
|
||||||
|
library in the release APK's phone ABI, fixed here.
|
||||||
|
|
||||||
|
**It also installs and launches on an emulator, and then exits.** One
|
||||||
|
line stops it: `backend/system/buildUserDirPath` switches on
|
||||||
|
`runtime.GOOS` and Android takes the `default:` branch returning
|
||||||
|
`errUnsupportedOS`, so `main()` hits `os.Exit(1)` six milliseconds
|
||||||
|
after the JNI bridge comes up. That is the *first* thing that stops it,
|
||||||
|
not the only one — see the "not this" section above, all of which is
|
||||||
|
still true and still out of scope.
|
||||||
|
|
||||||
|
The emulator tier that found it is now part of the harness:
|
||||||
|
`scripts/android-emulator.sh`, the `make android-*` targets, and
|
||||||
|
`.pi/skills/yellowjacket-dev/references/android-tier.md`. It exists
|
||||||
|
because the failure is invisible in all three places anyone would look
|
||||||
|
(no panic, no tombstone, no crash buffer) and ActivityManager restarts
|
||||||
|
the app fast enough that `pidof` always answers — so the tier's
|
||||||
|
assertion is "same pid after N seconds", not "it started".
|
||||||
|
|
||||||
|
Original phase 0 text follows, kept because its reasoning is what the
|
||||||
|
later phases rest on.
|
||||||
|
|
||||||
|
|
||||||
|
Everything downstream is wasted if the c-shared link fails. Establish it
|
||||||
|
by hand, locally, before writing a line of YAML.
|
||||||
|
|
||||||
|
Already established, by probe rather than by assumption:
|
||||||
|
|
||||||
|
```
|
||||||
|
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./backend/... ./internal/...
|
||||||
|
```
|
||||||
|
|
||||||
|
compiles the entire tree. Exactly two packages fail, and both fail only
|
||||||
|
because their Android implementation is cgo:
|
||||||
|
|
||||||
|
- `ebitengine/oto/v3` — `driver_android.go` needs the bundled **oboe**
|
||||||
|
C++ backend. Oto supports Android natively; there is no Java audio
|
||||||
|
glue to write.
|
||||||
|
- `wails/v3/pkg/application` — `mobile_features_android.go` needs the
|
||||||
|
JNI bridge.
|
||||||
|
|
||||||
|
`modernc.org/sqlite` (the whole database layer), `beep`, `godbus` and
|
||||||
|
every `backend/` package are clean. **No source changes are known to be
|
||||||
|
required**, which is the single most surprising finding here and the
|
||||||
|
reason this plan is worth doing at all.
|
||||||
|
|
||||||
|
What Phase 0 must actually verify:
|
||||||
|
|
||||||
|
1. Install NDK **r26d** (`26.3.11579264`) locally. Pinned, not "whatever
|
||||||
|
sdkmanager gives you" — ljos's AGENTS.md records newer NDKs breaking
|
||||||
|
this build.
|
||||||
|
2. Generate the scaffolding (Phase 1) and run
|
||||||
|
`wails3 task android:compile:go:shared ARCH=arm64` by hand.
|
||||||
|
3. Confirm `build/android/app/src/main/jniLibs/arm64-v8a/libwails.so`
|
||||||
|
exists and is an ARM64 shared object.
|
||||||
|
4. Repeat for `amd64` (the emulator ABI).
|
||||||
|
|
||||||
|
**If the link fails, stop and re-plan.** The likely culprits, in order:
|
||||||
|
alsa (oto must select oboe, not ALSA — if it reaches for `alsa.pc` the
|
||||||
|
build tags are wrong), and `main.go`'s `//go:embed all:frontend/dist`
|
||||||
|
combined with the generated `main_android.gen.go` overlay.
|
||||||
|
|
||||||
|
Deliverable: a note in `.planning/NOTES.md` recording the exact command
|
||||||
|
and the NDK version that produced a `.so`, or the reason it cannot.
|
||||||
|
|
||||||
|
## Phase 1 — un-ignore and commit the Android scaffolding [DONE]
|
||||||
|
|
||||||
|
Done as a side-effect of phase 0, which could not run without it. One
|
||||||
|
correction to the text below: **step 1 is wrong.** `update
|
||||||
|
build-assets` does not generate the android tree (NOTES.md explains);
|
||||||
|
it was generated with `generate build-assets` into a scratch dir and
|
||||||
|
`android/` copied across. CLAUDE.md is corrected to match. Steps 2-5
|
||||||
|
were done as written.
|
||||||
|
|
||||||
|
|
||||||
|
`build/android/` is gitignored (`.gitignore:72`) and its `includes:`
|
||||||
|
entry was dropped from `Taskfile.yml` during plan 009. That was correct
|
||||||
|
when nothing could target Android and is what has to be undone.
|
||||||
|
|
||||||
|
1. `wails3 task common:update:build-assets` — beta.8 embeds
|
||||||
|
`internal/commands/build_assets/android/`, so this generates the tree.
|
||||||
|
2. Remove `build/android/` from `.gitignore`; add `build/ios/`'s reason
|
||||||
|
to a comment so the asymmetry is explained rather than looking like an
|
||||||
|
oversight.
|
||||||
|
3. Add `android: ./build/android/Taskfile.yml` to `Taskfile.yml`'s
|
||||||
|
`includes:`.
|
||||||
|
4. **Gitignore the tree's own output**, or the repo grows a few hundred
|
||||||
|
Gradle intermediates. ljos has exactly this problem — its
|
||||||
|
`app/build/android/app/build/**` is committed. Ignore:
|
||||||
|
- `build/android/app/build/`
|
||||||
|
- `build/android/app/src/main/jniLibs/`
|
||||||
|
- `build/android/overlay.json` and `build/android/gen/`
|
||||||
|
5. `make build-prod` and `make test` still pass — the new include must
|
||||||
|
not perturb the desktop path.
|
||||||
|
|
||||||
|
**The refresh hazard has to be written down.** CLAUDE.md's Packaging
|
||||||
|
section already says `build/`'s platform metadata is regenerated from
|
||||||
|
`build/config.yml` and hand edits are lost. Phase 2 edits `build.gradle`
|
||||||
|
by hand. Extend that paragraph to name `build/android/app/build.gradle`
|
||||||
|
specifically, because the loss is silent and the symptom (a debug-signed
|
||||||
|
APK) appears months later as a failed update.
|
||||||
|
|
||||||
|
## Phase 2 — make the APK identifiable and updatable [DONE 2026-08-16]
|
||||||
|
|
||||||
|
**Narrower than planned, because beta.8's scaffold is ahead of ljos's
|
||||||
|
beta.3: the release signing config already exists** and reads the four
|
||||||
|
`ANDROID_KEYSTORE_*` variables with a debug-keystore fallback. So this
|
||||||
|
phase was identity and versioning only. Verified end to end:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| package | `app.yellowjacket` (was `com.wails.app`) |
|
||||||
|
| versionCode / versionName | `10301` / `1.3.1`, from `YJ_VERSION_CODE` / `YJ_VERSION` |
|
||||||
|
| label | `YellowJacket` |
|
||||||
|
| signing | throwaway keystore -> `Signer #1 DN: CN=YellowJacket Test`, not the debug key |
|
||||||
|
| ABIs | arm64-v8a + x86_64, both production-stripped |
|
||||||
|
|
||||||
|
Installs and launches under the new identity. Still exits on the known
|
||||||
|
`buildUserDirPath` bug, which is phase 0's finding and not this phase's.
|
||||||
|
|
||||||
|
Two things this phase learned that the text below did not know:
|
||||||
|
|
||||||
|
- **The identity has to be declared twice.** `applicationId` in
|
||||||
|
`app/build.gradle` is what Gradle installs; `APP_ID` in
|
||||||
|
`build/android/Taskfile.yml` is what every adb-driven task targets.
|
||||||
|
`ANDROID.md` says to set `APP_ID` in `build/config.yml` — that does
|
||||||
|
nothing in beta.8, verified with `--dry`. Both are set, each
|
||||||
|
commented pointing at the other.
|
||||||
|
- **The launcher activity is not under the applicationId.** It stays
|
||||||
|
`com.wails.app.MainActivity` (the scaffold's Java package), so
|
||||||
|
`am start -n app.yellowjacket/.MainActivity` resolves the dot against
|
||||||
|
the wrong package and fails. `scripts/android-emulator.sh` carries the
|
||||||
|
fully-qualified name and a comment saying why.
|
||||||
|
|
||||||
|
The `keytool` PKCS12 note below was confirmed verbatim: given a
|
||||||
|
`-keypass` differing from `-storepass` it prints "Different store and
|
||||||
|
key passwords not supported for PKCS12 KeyStores. Ignoring
|
||||||
|
user-specified -keypass value."
|
||||||
|
|
||||||
|
Original phase 2 text follows.
|
||||||
|
|
||||||
|
|
||||||
|
Edit `build/android/app/build.gradle`, following ljos's, whose comments
|
||||||
|
are worth reading before writing this:
|
||||||
|
|
||||||
|
- `applicationId "app.yellowjacket"` — matches `config.yml`'s
|
||||||
|
`productIdentifier`. The `namespace` stays `com.wails.app` (it is the
|
||||||
|
Java package, not the app identity).
|
||||||
|
- `versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")`
|
||||||
|
— **`Integer.parseInt`, not `(...) as Integer`**. Groovy binds the
|
||||||
|
parentheses to `versionCode` first, so the cast reads as
|
||||||
|
`versionCode("1") as Integer`, which sets a String and then casts the
|
||||||
|
setter's null return; Gradle fails the whole project with "Value is
|
||||||
|
null" at that line.
|
||||||
|
- `versionName System.getenv("YJ_VERSION") ?: "0.0.0"`.
|
||||||
|
- `abiFilters 'arm64-v8a', 'x86_64'`.
|
||||||
|
- A `release` signing config reading `ANDROID_KEYSTORE_FILE` /
|
||||||
|
`_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`, falling
|
||||||
|
back to the debug keystore only when no keystore is supplied.
|
||||||
|
|
||||||
|
**Android orders releases by an integer and refuses anything not greater
|
||||||
|
than what is installed.** A hardcoded `versionCode 1` means the first
|
||||||
|
install is the last: every later build is rejected as a downgrade and the
|
||||||
|
only fix is an uninstall. `1.3.1 -> 10301`, monotonic as long as minor
|
||||||
|
and patch stay under 100.
|
||||||
|
|
||||||
|
**Signing is not optional past the first install.** Android refuses to
|
||||||
|
update an app whose signing key changed, and the debug keystore differs
|
||||||
|
between every machine and every runner — so an unsigned CI build is a
|
||||||
|
decision to reinstall by hand forever. The job must **refuse to build**
|
||||||
|
without the keystore rather than quietly produce an APK that can never be
|
||||||
|
updated.
|
||||||
|
|
||||||
|
There is **one password and two required secrets**. keytool has defaulted
|
||||||
|
to PKCS12 since JDK 9 regardless of the `.jks` extension, and PKCS12
|
||||||
|
cannot hold a separate key password — given `-keypass` it warns and
|
||||||
|
ignores it. So `ANDROID_KEY_PASSWORD` defaults to the store password and
|
||||||
|
`ANDROID_KEY_ALIAS` to `yellowjacket`. Asking for a second password that
|
||||||
|
cannot exist is how someone sets a wrong value and debugs Gradle at
|
||||||
|
midnight.
|
||||||
|
|
||||||
|
Add `make android` → `PATH="$(TOOLBIN):$$PATH" go tool wails3 task
|
||||||
|
android:package:fat`, beside `build-prod`. `make skill-check` fails on a
|
||||||
|
documented target that does not exist, so document it only once it does.
|
||||||
|
|
||||||
|
## Phase 3 — the workflow [DONE 2026-08-16]
|
||||||
|
|
||||||
|
`.gitea/workflows/android-apk.yml`, plus `docs/android-release.md` as
|
||||||
|
the operating document its error messages point at (phase 4's
|
||||||
|
documentation half; the secrets themselves still have to be created by
|
||||||
|
hand — see the table there).
|
||||||
|
|
||||||
|
Three departures from the text below, all argued in the file:
|
||||||
|
|
||||||
|
- **No `continue-on-error`.** The plan inherited it from ljos, where
|
||||||
|
the Android job shares a pipeline with a server deploy that must
|
||||||
|
never go red over a phone build. Here it is standalone and can
|
||||||
|
neither delay nor redden anything, so a release step that fails
|
||||||
|
silently is strictly worse than one that fails visibly.
|
||||||
|
- **No cached `wails3` binary.** The plan budgeted for ljos's
|
||||||
|
`tools-bin` copy. Unnecessary: the CLI is a vendored `go tool`, and
|
||||||
|
the runner already bind-mounts `GOCACHE`/`GOMODCACHE` for every job,
|
||||||
|
so it is warm from `ci.yml`'s own `make bindings-check`. The GTK and
|
||||||
|
WebKit *dev* headers are still installed, because `go tool wails3`
|
||||||
|
links them.
|
||||||
|
- **A fourth cache volume, `/cache/gradle`.** Not in the plan and worth
|
||||||
|
~700 MB a run.
|
||||||
|
|
||||||
|
Four publish-gates were added and each was checked against a real APK:
|
||||||
|
both ABIs present, `versionCode` equal to the one derived from the tag,
|
||||||
|
a non-empty artifact, and **not signed with the debug key** — verified
|
||||||
|
by pointing the check at a deliberately debug-signed build, which it
|
||||||
|
refused.
|
||||||
|
|
||||||
|
Rehearsed locally with the exact CI invocation
|
||||||
|
(`make android ANDROID_SDK=... ANDROID_NDK=...`, `YJ_VERSION`,
|
||||||
|
`YJ_VERSION_CODE`, a throwaway keystore): `app.yellowjacket`,
|
||||||
|
versionCode 10301, versionName 1.3.1, label YellowJacket, both ABIs,
|
||||||
|
`Signer #1 DN: CN=YellowJacket`. Not yet run on the runner.
|
||||||
|
|
||||||
|
Original phase 3 text follows.
|
||||||
|
|
||||||
|
|
||||||
|
New file: `.gitea/workflows/android-apk.yml`. **Not a job in `ci.yml`.**
|
||||||
|
`ci.yml` runs on every branch push and is the workflow that gates; the
|
||||||
|
runner is capacity 1, and a 45-minute Android build in it would put every
|
||||||
|
push behind an SDK download.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
```
|
||||||
|
|
||||||
|
This is where the baseline genuinely diverges. ljos computes its version
|
||||||
|
in CI (`scripts/next-version.sh`) and gates the Android job on
|
||||||
|
`needs.release.outputs.version != ''`, with an `always()` whose absence
|
||||||
|
would silently kill the manual path. **This repo has no release
|
||||||
|
automation** — tags are pushed by hand and `homebrew-formula.yml` already
|
||||||
|
keys on `v*`. So there is no `needs:`, no `always()`, and no status
|
||||||
|
function to get wrong: the tag *is* the version, and a dispatch falls
|
||||||
|
back to `git describe --tags --abbrev=0`.
|
||||||
|
|
||||||
|
Container, matching `ci.yml`'s conventions (`ubuntu:24.04`, clone by hand
|
||||||
|
with `PACKAGE_TOKEN` rather than `actions/checkout`, which is a JS action
|
||||||
|
needing node before any step has installed it):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
container:
|
||||||
|
image: ubuntu:24.04
|
||||||
|
volumes:
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
The SDK path must be inside the runner's `valid_volumes` allowlist —
|
||||||
|
a directory outside it makes the job **fail to start**, not silently skip
|
||||||
|
the mount. `/cache/tool` is already allowed and already holds the Go
|
||||||
|
toolchain `ci.yml` downloads.
|
||||||
|
|
||||||
|
`continue-on-error: true` and `timeout-minutes: 45`. Advisory, because a
|
||||||
|
tag's other three workflows must not go red over a phone build, and a
|
||||||
|
backstop because a wedged SDK download must not hold the only runner slot
|
||||||
|
for hours.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
|
||||||
|
1. **System packages.** `ci.yml`'s set plus `unzip` and `openjdk-17-jdk`.
|
||||||
|
`libasound2-dev` stays — it is for the *host* `wails3` build, not the
|
||||||
|
Android cross-build, which uses oboe.
|
||||||
|
2. **Go toolchain** — reuse `ci.yml`'s `/cache/tool/go` block verbatim.
|
||||||
|
3. **Android SDK and NDK (cached).** ljos's `install_if_missing`
|
||||||
|
idempotent guard, unchanged: cmdline-tools 11076708, `platform-tools`,
|
||||||
|
`platforms;android-34`, `build-tools;34.0.0`, `ndk;26.3.11579264`.
|
||||||
|
sdkmanager is itself idempotent but still spends minutes verifying,
|
||||||
|
which is why the explicit directory guards are there. ~3 GB and most of
|
||||||
|
the job's wall clock on the first run; a directory listing after.
|
||||||
|
4. **wails3.** Cheaper here than in ljos, which pins
|
||||||
|
`go install …/wails3@$version` against `app/go.mod`. This repo vendors
|
||||||
|
the CLI (`go tool wails3`, `scripts/toolbin/wails3`), so the version is
|
||||||
|
already pinned by `go.mod` and there is nothing to drift. It still
|
||||||
|
*links* GTK and WebKit, so cache the built binary in
|
||||||
|
`/cache/android-sdk/tools-bin` keyed on the wails version — and note
|
||||||
|
ljos's finding that **caching the binary alone turned a slow job into
|
||||||
|
a broken one**: `wails3` is dynamically linked, so the runtime
|
||||||
|
packages are needed even on a cache hit. Here they are already in
|
||||||
|
step 1.
|
||||||
|
5. **Frontend + codegen.** `pnpm install --frozen-lockfile && pnpm build`
|
||||||
|
(pnpm, not ljos's npm), then `make generate`. `main.go` embeds
|
||||||
|
`frontend/dist`, so nothing Go-side typechecks without it.
|
||||||
|
6. **Decode the keystore.** Refuse to build if `ANDROID_KEYSTORE_B64` is
|
||||||
|
unset, with the sentence explaining why (Phase 2). Decide the absolute
|
||||||
|
path *here* and export it via `$GITHUB_ENV` — **`${{ env.HOME }}`
|
||||||
|
evaluates to an empty string in Gitea's expression context**, which
|
||||||
|
turned `$HOME/x.jks` into `/x.jks` and surfaced as a missing file
|
||||||
|
fifty-five seconds into a Gradle run.
|
||||||
|
7. **Build.** Compute `YJ_VERSION_CODE` from the tag, verify the keystore
|
||||||
|
opens with `keytool -list` *before* Gradle does (Gradle only notices at
|
||||||
|
`:app:validateSigningRelease`, a minute in, and reports it as a missing
|
||||||
|
file), then `make android`.
|
||||||
|
8. **Verify the signature.** `apksigner verify --print-certs`, and print
|
||||||
|
the SHA-256 with the note that a change to it breaks every future
|
||||||
|
update. **Nothing here pipes into `head`**: under `set -o pipefail`,
|
||||||
|
`head -1` exits early, the producer takes SIGPIPE, and the step fails
|
||||||
|
with 141 *after* printing a perfectly good APK. Use `find … -print
|
||||||
|
-quit` and a captured variable.
|
||||||
|
9. **Publish** to `api/packages/${OWNER}/generic/yellowjacket-android`,
|
||||||
|
authenticating `--user "${OWNER}:${PACKAGE_TOKEN}"` — the same
|
||||||
|
credential pair `arch-package.yml` already uses, not ljos's
|
||||||
|
`REGISTRY_USER`/`REGISTRY_TOKEN`. Two copies: a versioned one for
|
||||||
|
history and a fixed `latest/yellowjacket.apk` that Obtainium watches.
|
||||||
|
Gitea refuses to overwrite, so delete `latest` first. The generic
|
||||||
|
registry is readable **without credentials**, which is what lets
|
||||||
|
Obtainium poll a plain URL with no token and no public source mirror.
|
||||||
|
|
||||||
|
## Phase 4 — secrets and documentation
|
||||||
|
|
||||||
|
Secrets to create on the repo (all under Settings → Actions → Secrets):
|
||||||
|
|
||||||
|
| Secret | Required | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` |
|
||||||
|
| `ANDROID_KEYSTORE_PASSWORD` | yes | |
|
||||||
|
| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` |
|
||||||
|
| `ANDROID_KEY_PASSWORD` | no | defaults to the store password |
|
||||||
|
| `PACKAGE_TOKEN` | already exists | used by `arch-package.yml` |
|
||||||
|
|
||||||
|
Write the keytool command, the Obtainium URL and the signing-key warning
|
||||||
|
into a docs page — this is the part of ljos's setup that lives in
|
||||||
|
`docs/clients.md` and is referenced from the workflow's error messages,
|
||||||
|
so the messages have somewhere to point.
|
||||||
|
|
||||||
|
Then extend CLAUDE.md's CI section: it currently says "four workflows,
|
||||||
|
three of them package and publish; only `ci.yml` gates". That becomes
|
||||||
|
five, with the same sentence still true.
|
||||||
|
|
||||||
|
## Order and stopping points
|
||||||
|
|
||||||
|
Phase 0 gates everything. Phases 1–2 are one commit's worth of work and
|
||||||
|
are verifiable locally without CI. Phase 3 is the only part that needs a
|
||||||
|
runner, and its first run will be slow and will probably fail once on
|
||||||
|
something in the SDK step — budget for that rather than treating it as a
|
||||||
|
setback.
|
||||||
|
|
||||||
|
**Stop after Phase 0 if the c-shared link does not work.** Every later
|
||||||
|
phase is scaffolding for a build that does not exist, and the honest
|
||||||
|
outcome is a NOTES.md entry saying which package cannot cross-compile and
|
||||||
|
what it would take.
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
# 015 — Multi-artist credits, navigable
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
A track credited to more than one artist has exactly one navigable
|
||||||
|
artist in this app, and the others are punctuation.
|
||||||
|
|
||||||
|
`audio_files` carries `artist_credit` (the credit as tagged, for
|
||||||
|
display) and `artist_id` (one artist, for grouping and browsing).
|
||||||
|
`primaryArtist()` (`backend/library/artistcredit.go:53`) resolves that
|
||||||
|
one artist by *string-parsing* the credit: it strips a " feat. "
|
||||||
|
clause, and deliberately does not split on `&`, `x`, `with` or `,`
|
||||||
|
because those appear inside real artist names. So "Lana Del Rey ft.
|
||||||
|
Sean Lennon" stores Lana Del Rey and discards Sean Lennon entirely,
|
||||||
|
and "Alina Baraz & Galimatias" stores one artist whose name is the
|
||||||
|
whole credit.
|
||||||
|
|
||||||
|
### What the measurement says
|
||||||
|
|
||||||
|
Measured 2026-08-16 against a real 26,069-file library (19,840 mp3,
|
||||||
|
6,229 flac; 57 unreadable, m4a/ogg not examined), plus an 80+80
|
||||||
|
MusicBrainz `inc=artist-credits` sample.
|
||||||
|
|
||||||
|
- **13%** of a random sample of the library's recordings have more
|
||||||
|
than one credited artist in MusicBrainz (10 of 79 resolved).
|
||||||
|
Extrapolates to ~3,250 of the 24,989 files carrying a recording
|
||||||
|
MBID.
|
||||||
|
- **0.86%** of files (224) carry any structured multi-artist signal in
|
||||||
|
their own tags. mp3 carries **zero** files with multiple
|
||||||
|
`MUSICBRAINZ_ARTISTID` values across 19,840 files; flac has 87.
|
||||||
|
- **1,286** files say "feat." in `ARTIST`; **1,159 of them (90%)**
|
||||||
|
have nothing structured behind it. A sample of 80 such files was
|
||||||
|
multi-artist in MB **80 of 80 times**.
|
||||||
|
|
||||||
|
CLAUDE.md currently justifies plan 013's removal of `artist_credit` /
|
||||||
|
`artist_credit_artist` with "3 credits of 2,823 listed more than one
|
||||||
|
artist". That figure measured **our own writer**, not the library:
|
||||||
|
`cachedLinkArtist` was called exactly once per credit
|
||||||
|
(`e7748f1^:backend/library/library.go:1842`), so a collaboration could
|
||||||
|
never have been recorded, and the three were resolution collisions on
|
||||||
|
shared credit text. Dropping the join table was still correct — it only
|
||||||
|
ever held one row, so it was pure join cost — but the stated evidence
|
||||||
|
does not support "multi-artist is rare". Correcting that claim is part
|
||||||
|
of this plan.
|
||||||
|
|
||||||
|
### Why the tags cannot answer it
|
||||||
|
|
||||||
|
Deriving the decomposition locally, with no network, works **79% of the
|
||||||
|
time** (169 of 215 files with a multi-value `ARTISTS` tag: mp3 69/105,
|
||||||
|
flac 100/110), and the failures are systematic rather than random:
|
||||||
|
|
||||||
|
```
|
||||||
|
ARTIST = '2Pac feat. Snoop Dogg, Nate Dogg, Hussein Fatal & Yaki Kadafi'
|
||||||
|
ARTISTS = ['2Pac', 'Snoop Doggy Dogg', 'Nate Dogg', 'Fatal', 'Yaki Kadafi']
|
||||||
|
```
|
||||||
|
|
||||||
|
`ARTISTS` holds **canonical** artist names; `ARTIST` holds
|
||||||
|
**as-credited** names. Locating one inside the other fails on
|
||||||
|
"Snoop Doggy Dogg" vs "Snoop Dogg", on "Fatal" vs "Hussein Fatal", and
|
||||||
|
on Unicode (`Michel'le` vs `Michel’le`, `K-Ci` vs `K‐Ci` — U+2010, not
|
||||||
|
a hyphen). That distinction is precisely what a join phrase encodes,
|
||||||
|
and it is why this cannot be a tag-parsing feature.
|
||||||
|
|
||||||
|
Two format details that will mislead anyone re-running the probe:
|
||||||
|
Picard writes `ARTISTS` **slash-joined into one TXXX frame** on mp3 and
|
||||||
|
as **true repeated Vorbis keys** on flac, so a probe splitting only on
|
||||||
|
NUL undercounts mp3 to zero.
|
||||||
|
|
||||||
|
## The shape
|
||||||
|
|
||||||
|
MusicBrainz models a credit as ordered parts, and the credit *string*
|
||||||
|
is derived from them — `artist_credit.name` is a cached render, nothing
|
||||||
|
more. Each participant is `(position, artist, name, join_phrase)`,
|
||||||
|
where `artist` is the MBID (canonical, what you navigate to) and `name`
|
||||||
|
is the credited spelling (what you display).
|
||||||
|
|
||||||
|
**Join phrases are assembly instructions, not disassembly
|
||||||
|
instructions.** Rendering is a concatenation, never a search:
|
||||||
|
|
||||||
|
```
|
||||||
|
for each (position, artist_mbid, credited_name, join_phrase):
|
||||||
|
emit link(credited_name -> artist_mbid)
|
||||||
|
emit text(join_phrase)
|
||||||
|
```
|
||||||
|
|
||||||
|
The link positions are known **by construction**. This is load-bearing:
|
||||||
|
if we instead located each `credited_name` inside the stored
|
||||||
|
`artist_credit` text, we would reintroduce the mismatch above — the
|
||||||
|
stored string may have come from the tags while the parts come from the
|
||||||
|
catalog, and those **disagree for ~1 in 3 multi-artist files** (61 of
|
||||||
|
90 sampled credits rendered exactly equal to the tag string).
|
||||||
|
Divergences seen: `'Skrillex feat. Swae Lee'` tagged vs
|
||||||
|
`'Skrillex & Swae Lee'` in MB; `'STRFKR'` vs `'Starfucker'`;
|
||||||
|
`'Zedd feat. Hayley Williams'` vs `'... of Paramore'`. Either MB was
|
||||||
|
edited after tagging or Picard versions differ; either way the search
|
||||||
|
would miss or match the wrong span.
|
||||||
|
|
||||||
|
So `audio_files.artist_credit` stops being the source of truth and
|
||||||
|
becomes the **fallback**, used only where there are no parts.
|
||||||
|
|
||||||
|
## Where the data comes from
|
||||||
|
|
||||||
|
The catalog carries the decomposition; no user ever makes a
|
||||||
|
per-recording call. Two sources were ruled out first, both cheaply:
|
||||||
|
|
||||||
|
- **The canonical dump — which is what CI already pulls
|
||||||
|
(`dumpimport.go:84-85`) — does not have it.**
|
||||||
|
`canonical_musicbrainz_data.csv` gives `artist_mbids` (ordered list)
|
||||||
|
and `artist_credit_name`, but that last column is the *rendered*
|
||||||
|
string. Splitting it on CI needs the as-credited names, so CI would
|
||||||
|
fail exactly the way a local parse does.
|
||||||
|
- **The JSON dumps do not cover the catalog.**
|
||||||
|
`json-dumps/recording.tar.xz` is 31 MB / 368 MB uncompressed and
|
||||||
|
holds **153,691 recordings**, not ~35M. Measured against the test
|
||||||
|
library's 24,885 recording MBIDs: **0.00% overlap, zero rows**. It is
|
||||||
|
some other subset and is not usable.
|
||||||
|
|
||||||
|
That leaves the core dump, **`mbdump.tar.bz2`** (7.1 GB compressed at
|
||||||
|
the 20260815 export), from
|
||||||
|
`https://data.metabrainz.org/pub/musicbrainz/data/fullexport/`. Four
|
||||||
|
members are needed:
|
||||||
|
|
||||||
|
| member | why | approx rows |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `mbdump/artist_credit_name` | `(artist_credit, position, artist, name, join_phrase)` — the payload | ~4M |
|
||||||
|
| `mbdump/artist` | `id -> gid`, since the above references artist *row ids* | ~2.6M |
|
||||||
|
| `mbdump/recording` | `gid -> artist_credit`, to key credits by recording MBID | ~35M |
|
||||||
|
| `mbdump/release_group` | same, for album credits | ~2M |
|
||||||
|
|
||||||
|
### Coverage is not a concern
|
||||||
|
|
||||||
|
Of 24,885 distinct recording MBIDs in the test library, **24,808
|
||||||
|
(99.7%)** already have an `explore_index` recording row, measured
|
||||||
|
against a database at 2,052,200 rows — i.e. shipped-artifact coverage,
|
||||||
|
not a local build's. The popularity filter does not strand the long
|
||||||
|
tail here.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- **Phase 1 — done.** `backend/explore/dumpcredits.go` +
|
||||||
|
`dumpcreditswrite.go`, wired into `dumpimport.go`'s `run` behind its
|
||||||
|
own `credits_import_done` marker.
|
||||||
|
- **Phase 2 — done.** `cmd/indexexport` writes the two tables;
|
||||||
|
`artifactimport.go` reads them behind `artifactHasCredits()`.
|
||||||
|
- **Phase 4 — done, and it does not need Phase 3.** `explore.GetCredits`
|
||||||
|
reads the catalog tables keyed on the *recording* MBID, which both
|
||||||
|
sides of the app already carry — a catalog row has one and so does a
|
||||||
|
local file (`library.Track.RecordingMBID`). So one binding serves the
|
||||||
|
Explore pages and the library's own lists, and all ten artist-link
|
||||||
|
call sites render credits today without a local table.
|
||||||
|
- **Phase 3 (`file_artists`) — not started, and now an
|
||||||
|
offline-resilience task rather than a prerequisite.** The table is
|
||||||
|
deliberately *not* declared yet: nothing writes or reads it, and a
|
||||||
|
schema file plus a datamap note describing behaviour that does not
|
||||||
|
exist is a claim the code cannot back. Its remaining
|
||||||
|
value is that credits currently vanish when the catalog is absent or
|
||||||
|
still downloading, which is precisely the `no-index` state
|
||||||
|
`ShelfPage.State` exists to describe. Materialising into
|
||||||
|
`file_artists` is what makes a library stand on its own.
|
||||||
|
|
||||||
|
**Nothing renders yet in practice**, because no published artifact
|
||||||
|
carries credit tables — every credit falls back to its single link
|
||||||
|
until an index build with Phase 1 runs and is exported.
|
||||||
|
|
||||||
|
**Column layouts are verified against the real 20260815 export**, not
|
||||||
|
taken from the schema docs — `artist(id, gid, …)`,
|
||||||
|
`artist_credit(id, name, artist_count, …)`,
|
||||||
|
`artist_credit_name(credit, position, artist, name, join_phrase)` and
|
||||||
|
`recording(id, gid, name, artist_credit, …)` were each read out of the
|
||||||
|
dump. `release_group` shares `recording`'s first four columns and is
|
||||||
|
the one layout still taken on trust; `ErrDumpShape` turns a wrong guess
|
||||||
|
into a loud failure rather than a quietly wrong catalog.
|
||||||
|
|
||||||
|
**Still unrun: the ingest against the real 7.1 GB dump.** Everything is
|
||||||
|
covered by tests over a synthetic tar, which cannot catch a surprise in
|
||||||
|
the other ~35M rows.
|
||||||
|
|
||||||
|
### Phase 1 — Ingest credits on CI
|
||||||
|
|
||||||
|
New dump stage in `cmd/indexbuild`, behind the `indexbuild` tag with
|
||||||
|
the rest of `dumpimport.go`'s stages.
|
||||||
|
|
||||||
|
**Constraint from `b98840e`:** `cmd/indexbuild` is built
|
||||||
|
`CGO_ENABLED=0` in a plain `golang` container and must not reach the
|
||||||
|
Wails `application` package — `TestIndexToolsDoNotImportWails` walks
|
||||||
|
`go list -deps -tags indexbuild`. Nothing here should need it, but a
|
||||||
|
new `ServiceStartup` hook on a package this imports is how it comes
|
||||||
|
back. Go's `compress/bzip2` is pure Go and decompress-only, which is
|
||||||
|
all this needs.
|
||||||
|
|
||||||
|
**Measured, 20260815 export.** Tar members are **alphabetical**, and
|
||||||
|
that is favourable: `artist` (435 MB), `artist_credit` (414 MB) and
|
||||||
|
`artist_credit_name` (237 MB) all fall inside the first ~900 MB
|
||||||
|
compressed, while `recording` and `release_group` come later. So the
|
||||||
|
maps are complete before the rows that consume them arrive, and no
|
||||||
|
recording data is ever buffered.
|
||||||
|
|
||||||
|
Pure-Go `compress/bzip2` decompresses at **26 MB/s uncompressed /
|
||||||
|
8.7 MB/s compressed** (measured on a 250 MB prefix, 3.01x ratio) —
|
||||||
|
**~13.7 min** for the whole file single-threaded, and less because the
|
||||||
|
stream can stop after `release_group` rather than reading the
|
||||||
|
`series`/`tag`/`track`/`url`/`work` tail. The 2 MB/s origin throttle
|
||||||
|
dominates, as it already does for every other dump here.
|
||||||
|
|
||||||
|
Do not, however, *depend* on the ordering: assert it and fall back to
|
||||||
|
buffering if a future export reorders, rather than silently emitting
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
- `artist` -> `map[int32]uuid16` (~2.6M x ~20 B = ~60 MB)
|
||||||
|
- `artist_credit_name` -> `map[int32][]creditPart` (~4M x ~40 B =
|
||||||
|
~200 MB)
|
||||||
|
- `recording` / `release_group` -> emit `gid -> credit_id` **only for
|
||||||
|
MBIDs already in `explore_index`** (the kept set is ~1.4M x 16 B =
|
||||||
|
~22 MB), which is what keeps 35M rows from being held
|
||||||
|
|
||||||
|
Peak ~300 MB, one sequential pass.
|
||||||
|
|
||||||
|
**Only multi-artist credits are stored.** A single-artist credit is
|
||||||
|
`(name, "")` and is already fully described by `explore_index`'s
|
||||||
|
`artist_name` / `artist_mbid`; storing it would triple the table for
|
||||||
|
nothing. Post-filter after loading, once the row count per credit is
|
||||||
|
known.
|
||||||
|
|
||||||
|
New tables (and `datamap` entries, or `TestCatalogCoversSchema` fails
|
||||||
|
the build — both are `Cache`, matching `explore_index`):
|
||||||
|
|
||||||
|
```
|
||||||
|
artist_credit_part(credit_id, position, artist_mbid, credited_name, join_phrase)
|
||||||
|
```
|
||||||
|
|
||||||
|
with `explore_index.artist_credit_id` as the link. Credits are
|
||||||
|
**shared** — an album's twelve tracks by one artist share one credit
|
||||||
|
row — which is the opposite of 013's local verdict, and correctly so:
|
||||||
|
1:1 in a local library, genuinely many-to-one at 2M-row catalog scale.
|
||||||
|
|
||||||
|
### Phase 2 — Ship them in the artifact
|
||||||
|
|
||||||
|
`cmd/indexexport` currently creates exactly two tables in the artifact
|
||||||
|
(`explore_index`, `artifact_meta`, at `cmd/indexexport/*.go:147,170`),
|
||||||
|
so this is a structural addition, not a column.
|
||||||
|
|
||||||
|
Estimated size: ~13% of 1.4M recordings, deduplicated by shared credit,
|
||||||
|
at ~2.3 parts each — order 400k rows, ~18 MB uncompressed. Against a
|
||||||
|
~0.6 GB install that is acceptable; it must be measured rather than
|
||||||
|
assumed before merge.
|
||||||
|
|
||||||
|
`artifactimport.go` must read it **only if present**, on the writer
|
||||||
|
handle where `core` is attached — the `artifactHasTotals()` /
|
||||||
|
`artifactStoresText()` pattern (`artifactimport.go:145-175`), one step
|
||||||
|
up from a column to a table. An artifact published before this exists
|
||||||
|
is still a perfectly good catalog and must import as one that declines
|
||||||
|
to answer. Adding this to the importer's SELECT list without the probe
|
||||||
|
is how every already-published artifact starts failing.
|
||||||
|
|
||||||
|
`artifactCatalogColumns` gains `artist_credit_id`; it is kept in sync
|
||||||
|
with the exporter by `TestArtifactColumnsMatchExporter`.
|
||||||
|
|
||||||
|
### Phase 3 — Materialize locally
|
||||||
|
|
||||||
|
```
|
||||||
|
file_artists(audio_file_id, position, artist_id, credited_name, join_phrase)
|
||||||
|
```
|
||||||
|
|
||||||
|
`credited_name` is stored **per row**, not looked up from
|
||||||
|
`artists.name` — that is the Snoop-Doggy-Dogg distinction, and it is
|
||||||
|
the whole point.
|
||||||
|
|
||||||
|
Filled at scan/import time by joining `audio_files.recording_mbid`
|
||||||
|
against the catalog. **Materialized rather than resolved live**,
|
||||||
|
because the catalog is a downloaded artifact that can be absent or
|
||||||
|
still arriving — that is why `ShelfPage.State` has a `no-index` value —
|
||||||
|
and a library whose track rows lose their artists when the catalog is
|
||||||
|
missing is worse than today.
|
||||||
|
|
||||||
|
That implies a backfill for the case where the catalog arrives *after*
|
||||||
|
the library was scanned. It registers with `jobs` (progress, cancel)
|
||||||
|
like every other long pass, and takes a **distinct kind** from
|
||||||
|
`index-build`, since `job-controls.ts` keys its "you will discard hours
|
||||||
|
of downloading" confirmation on that kind.
|
||||||
|
|
||||||
|
`artists` gains rows for guests who own no files. **This changes what
|
||||||
|
the artists grid shows** and is an open question below.
|
||||||
|
|
||||||
|
### Phase 4 — Render
|
||||||
|
|
||||||
|
`utils/explore-link.ts` gains a credit-rendering entry point taking
|
||||||
|
ordered parts and returning a `TemplateResult`. Every row and detail
|
||||||
|
view already renders artist names through it, so they inherit
|
||||||
|
multi-artist links without individually knowing credits exist — the
|
||||||
|
property that made centralising it worthwhile.
|
||||||
|
|
||||||
|
Its existing fallback philosophy already covers the no-parts case: "a
|
||||||
|
list where some rows are clickable and others silently are not reads as
|
||||||
|
a bug, not as a statement about metadata." Where there are no parts
|
||||||
|
(no recording MBID, or no catalog row — ~4% of the test library) render
|
||||||
|
today's behaviour: the flat `artist_credit` string with one link to the
|
||||||
|
primary artist. **Do not split the string there.** There is genuinely
|
||||||
|
no information to split on, and that is the one place the temptation
|
||||||
|
returns.
|
||||||
|
|
||||||
|
`primaryArtist()` stays exactly as it is. It remains the fallback and
|
||||||
|
is still what `artist_id` means.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **Catalog credit vs tagged credit, when they disagree** (~1 in 3
|
||||||
|
multi-artist files). Rendering the catalog's decomposition is what
|
||||||
|
makes names navigable; preserving the file's is what makes the app
|
||||||
|
reflect the user's files. Leaning toward: render the catalog
|
||||||
|
decomposition, keep `artist_credit` as the fallback string. Wants a
|
||||||
|
deliberate decision, not an accident.
|
||||||
|
2. **Do guest artists appear in the artists grid?** Phase 3 creates
|
||||||
|
`artists` rows for people who own no files. The grid currently means
|
||||||
|
"artists in your library" and joins `audio_files`. A guest on one
|
||||||
|
track is arguably in the library and arguably not. Whichever way,
|
||||||
|
the ownership question stays "is there a file" — that rule does not
|
||||||
|
bend.
|
||||||
|
3. **`release_group` credits** are ingested in the same pass for
|
||||||
|
nearly nothing, but album-artist rendering is a separate surface.
|
||||||
|
Ship the data in phase 1, render in a follow-up rather than widening
|
||||||
|
phase 4.
|
||||||
|
4. **Our own `tagwriter`** does not write `ARTISTS` or multiple
|
||||||
|
`MUSICBRAINZ_ARTISTID` frames, so autotagging a folder degrades the
|
||||||
|
very field this rests on — the same shape as the existing
|
||||||
|
track-totals note. Out of scope here; worth recording.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Coverage: re-run the library probe and assert `file_artists` is
|
||||||
|
populated for ~13% of files, not ~0.9%.
|
||||||
|
- `TestCatalogCoversSchema` / `TestLifetimesMatchSchema` for the new
|
||||||
|
tables.
|
||||||
|
- `TestIndexToolsDoNotImportWails` still passes with the new stage.
|
||||||
|
- An artifact **without** the credits table imports cleanly (the
|
||||||
|
`artifactHasTotals` regression shape).
|
||||||
|
- Round-trip: a known multi-artist recording renders each name as a
|
||||||
|
separate link with the correct join phrases between them.
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# 017 — Releases that happen by themselves
|
||||||
|
|
||||||
|
> **Status: built, not yet run.** Phases 0–4 have landed on this branch;
|
||||||
|
> phase 5 is the merge itself and cannot be done until then. The old
|
||||||
|
> `v1.x` tags are already deleted from `origin`. Verified locally against
|
||||||
|
> a scratch remote: semantic-release computes **0.0.1** from these
|
||||||
|
> commits and renders correct sectioned notes.
|
||||||
|
>
|
||||||
|
> **One thing found by testing that no amount of reading would have
|
||||||
|
> caught.** `conventional-changelog-conventionalcommits@10` — the current
|
||||||
|
> release, and my first pin — is silently incompatible with the writer
|
||||||
|
> `release-notes-generator@14` depends on: the version is right, the tag
|
||||||
|
> is right, every step reports success, and the release body is a bare
|
||||||
|
> `## 0.0.1 (date)` heading with **nothing under it**. It is pinned to 9
|
||||||
|
> in both `release.yml` and `make release-dry`, with the reason written
|
||||||
|
> beside it. Four of my seven original pins were wrong majors besides;
|
||||||
|
> they were guesses, and `npm view` was the fix.
|
||||||
|
|
||||||
|
The goal in one sentence: **a merge to `main` computes the next version
|
||||||
|
from the commits it contains, cuts a tag and a Gitea release whose body
|
||||||
|
is the changelog, and every publishing channel builds that tag.** The
|
||||||
|
first release under this scheme is `v0.0.1`, and the five existing `v1.x`
|
||||||
|
tags go.
|
||||||
|
|
||||||
|
## What is there now
|
||||||
|
|
||||||
|
Measured, not remembered:
|
||||||
|
|
||||||
|
- **Five tags and zero releases.** `v1.3.0`, `v1.4.0`, `v1.4.1`,
|
||||||
|
`v1.5.0`, `v1.6.0` exist on `origin`;
|
||||||
|
`GET /api/v1/repos/yonlu/yellowjacket/releases` returns `[]`. So there
|
||||||
|
is no release page to preserve and nothing but the tags to remove.
|
||||||
|
- **`CHANGELOG.md` is stale and belongs to another repo.** Its newest
|
||||||
|
entry is `1.3.0` and every link in it points at
|
||||||
|
`github.com/onion-4-dinner/yellowjacket` — it was written by a
|
||||||
|
semantic-release run against a GitHub remote this project no longer
|
||||||
|
has.
|
||||||
|
- **`.releaserc.yml` is a complete semantic-release config that nothing
|
||||||
|
invokes**, which CLAUDE.md already says in as many words.
|
||||||
|
- **Root `package.json` is literally `{}`** — the stub left behind by
|
||||||
|
whatever was going to run it.
|
||||||
|
- The triggers today are: `arch-package` on **push to `main`**,
|
||||||
|
`homebrew-formula` on **`v*`**, `android-apk` on **`v*`**, `ci` on
|
||||||
|
every branch, `index-artifact` on cron/dispatch. So Arch publishes a
|
||||||
|
`git describe` version on every merge and the other two publish only
|
||||||
|
when a human remembers to push a tag.
|
||||||
|
|
||||||
|
## Decision 1 — semantic-release, with `exec` in place of the `github` plugin
|
||||||
|
|
||||||
|
**Revised: the first draft of this plan proposed a shell script and the
|
||||||
|
argument for it does not hold.** Recorded here rather than deleted,
|
||||||
|
because the reasoning is what the decision rests on.
|
||||||
|
|
||||||
|
What I said, and what checking it showed:
|
||||||
|
|
||||||
|
- *"The two plugins that would carry the work do not fit."* Half true.
|
||||||
|
`@semantic-release/github` genuinely does not speak Gitea's `/api/v1`
|
||||||
|
— but the replacement is **`@semantic-release/exec`**, which is
|
||||||
|
first-party, published 2026-06, and peer-deps `semantic-release >=24.1`.
|
||||||
|
Its `publishCmd` is one `curl` at the Gitea release endpoint with
|
||||||
|
`${nextRelease.notes}` as the body. The Gitea-shaped part of this is
|
||||||
|
five lines, and the part I proposed to hand-roll — parsing conventional
|
||||||
|
commits, ordering semver, rendering grouped notes — is the part with
|
||||||
|
the edge cases and none of it is Gitea-shaped at all.
|
||||||
|
- *"`@semantic-release/git` commits the changelog back to `main`, which
|
||||||
|
re-triggers everything."* True, and it is the one real risk — but it
|
||||||
|
is a two-line guard (skip the job when `HEAD`'s subject is
|
||||||
|
`chore(release):`), not a reason to write a version calculator. That
|
||||||
|
guard is needed under **either** design, since either one writes a
|
||||||
|
changelog commit.
|
||||||
|
- *"A Node dependency tree at the root of a Go repo."* The commitlint
|
||||||
|
precedent does not transfer. commitlint was a dependency to regex one
|
||||||
|
line; this is a dependency to do something with real complexity, it is
|
||||||
|
`npx`-only so nothing lands in the repo, and Node is already installed
|
||||||
|
in CI for the frontend.
|
||||||
|
- *"It cannot be told to produce `0.0.1`."* Wrong — that is a property
|
||||||
|
of which commits are in the range, not of the tool. Identical under
|
||||||
|
both designs. See below.
|
||||||
|
|
||||||
|
Note also that **`@saithodev/semantic-release-gitea` is a dead end** and
|
||||||
|
should not be reached for: last published 2022, depends on `got@10` and
|
||||||
|
`fs-extra@8`, and declares no peer dependency on semantic-release at all
|
||||||
|
— i.e. it is untested against anything since v19, against a core now at
|
||||||
|
v25. `exec` + `curl` is both simpler and maintained.
|
||||||
|
|
||||||
|
So `.releaserc.yml` stays, and its plugin list becomes five **first-party**
|
||||||
|
plugins, all published within the last six months:
|
||||||
|
|
||||||
|
| plugin | job |
|
||||||
|
| --- | --- |
|
||||||
|
| `commit-analyzer` | the version |
|
||||||
|
| `release-notes-generator` | the notes |
|
||||||
|
| `changelog` | writes `CHANGELOG.md` |
|
||||||
|
| `git` | commits it back |
|
||||||
|
| `exec` | `curl`s the Gitea release |
|
||||||
|
|
||||||
|
The `releaseRules` and `presetConfig` blocks already in the file are
|
||||||
|
kept verbatim — they are the same bump table `commit-check.sh` already
|
||||||
|
enforces the grammar for, and nothing about the project's commit
|
||||||
|
convention changes.
|
||||||
|
|
||||||
|
Two mechanical details that decide whether this works at all:
|
||||||
|
|
||||||
|
- **semantic-release pushes the tag itself**, as core behaviour, using
|
||||||
|
`repositoryUrl`. The remote here is `ssh://git@git.ljones.me:2222/…`,
|
||||||
|
which would need an SSH key in CI — so the run passes
|
||||||
|
`--repository-url "https://x-access-token:$PACKAGE_TOKEN@git.ljones.me/yonlu/yellowjacket.git"`
|
||||||
|
on the command line rather than committing a token to the config.
|
||||||
|
**That is also what satisfies Decision 2**: the tag push is attributed
|
||||||
|
to a real user, not to the Actions token.
|
||||||
|
- **The empty root `package.json` (`{}`) goes.** semantic-release does
|
||||||
|
not need one when `--repository-url` is explicit, and leaving a
|
||||||
|
package manifest at the root of a Go repo invites the npm plugin and
|
||||||
|
every tool that looks for one.
|
||||||
|
|
||||||
|
Invocation is pinned in the workflow, not installed into the repo:
|
||||||
|
|
||||||
|
```
|
||||||
|
npx --yes \
|
||||||
|
-p semantic-release@25 \
|
||||||
|
-p @semantic-release/commit-analyzer@14 \
|
||||||
|
-p @semantic-release/release-notes-generator@15 \
|
||||||
|
-p @semantic-release/changelog@6 \
|
||||||
|
-p @semantic-release/git@10 \
|
||||||
|
-p @semantic-release/exec@7 \
|
||||||
|
-p conventional-changelog-conventionalcommits@9 \
|
||||||
|
semantic-release --repository-url "…"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Exact majors get pinned from `npm view` at implementation time;
|
||||||
|
`conventional-changelog-conventionalcommits` is in the list because both
|
||||||
|
the analyzer and the notes generator name that preset and neither
|
||||||
|
depends on it.)
|
||||||
|
|
||||||
|
## Decision 2 — how the publish workflows learn about the tag
|
||||||
|
|
||||||
|
**Gitea, like GitHub, does not start a workflow from a tag pushed by a
|
||||||
|
workflow's own token** (go-gitea#33123, and the forum thread it points
|
||||||
|
at). This is the one load-bearing unknown in the plan.
|
||||||
|
|
||||||
|
The remedy is to push the tag with a *user* PAT — `secrets.PACKAGE_TOKEN`
|
||||||
|
is already in this repo and already used by `arch-package` and
|
||||||
|
`android-apk` to clone and to publish — so the push is attributed to a
|
||||||
|
person and the `v*` triggers fire normally. That keeps the three publish
|
||||||
|
workflows completely unchanged in shape.
|
||||||
|
|
||||||
|
**It is verified in phase 5, not assumed.** The fallback, if it does not
|
||||||
|
fire, is an explicit `POST
|
||||||
|
/api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches` per
|
||||||
|
channel from the release job. That needs `workflow_dispatch` (with a
|
||||||
|
`version` input) added to `homebrew-formula.yml` and `arch-package.yml`;
|
||||||
|
`android-apk.yml` already has both. **Add those inputs in phase 3
|
||||||
|
regardless** — a hand-triggered rebuild of one channel is worth having
|
||||||
|
whether or not the fallback is needed.
|
||||||
|
|
||||||
|
The alternative — one `release.yml` with the three publishes as
|
||||||
|
`needs:` jobs — is rejected: it means either copying ~400 lines of
|
||||||
|
Android and Arch setup into it or relying on `workflow_call`, and it
|
||||||
|
puts every merge to `main` behind an up-to-60-minute Android build on a
|
||||||
|
runner with capacity 1.
|
||||||
|
|
||||||
|
## Decision 3 — 1.6.0 → 0.0.1 is a downgrade, and the answer is reinstall
|
||||||
|
|
||||||
|
**Decided: no version-code offset, no epoch. The version number stays
|
||||||
|
honest and existing installs are replaced by hand.** Every channel is a
|
||||||
|
downgrade and each declines differently, so what to expect:
|
||||||
|
|
||||||
|
- **Arch: no upgrade is offered, silently.** `pkgver()` derives from
|
||||||
|
`git describe`, so after the wipe it reads `0.0.1.rN.gHASH`, which
|
||||||
|
pacman orders *below* the `1.3.0.rN.*` in the registry. `pacman -R
|
||||||
|
yellowjacket && pacman -S yellowjacket` is the remedy. (`epoch=1` in
|
||||||
|
the PKGBUILD would have avoided it for one line — but an epoch can
|
||||||
|
never be removed, and it puts a permanent `1:` in front of every
|
||||||
|
version string this project will ever have.)
|
||||||
|
- **Homebrew: no upgrade is offered, silently.** Brew has no epoch at
|
||||||
|
all. `brew uninstall yellowjacket && brew install …`.
|
||||||
|
- **Android: a hard refusal.** `versionCode` is
|
||||||
|
`maj*10000 + min*100 + pat`, so `0.0.1` is **1** against the **10300**
|
||||||
|
an installed 1.3.0 carries, and the install fails with
|
||||||
|
`INSTALL_FAILED_VERSION_DOWNGRADE`. Uninstall first — **and that takes
|
||||||
|
the app's library and config with it**, which is the same data loss
|
||||||
|
`android-apk.yml`'s keystore guard exists to prevent, arrived at from
|
||||||
|
the other direction. The workflow's own `code -le 0` guard still passes
|
||||||
|
at 1, so nothing in CI stops or warns about this.
|
||||||
|
|
||||||
|
All three go in the release notes for `v0.0.1` and in
|
||||||
|
`packaging/homebrew/README.md` / `docs/android-release.md`, because a
|
||||||
|
channel that silently offers no upgrade is indistinguishable from a
|
||||||
|
broken pipeline six months from now.
|
||||||
|
|
||||||
|
## Landing exactly `v0.0.1`
|
||||||
|
|
||||||
|
Determinism comes from two things:
|
||||||
|
|
||||||
|
1. **Seed `v0.0.0` on `6fb7b5e`** (current `origin/main`) after wiping
|
||||||
|
the old tags. That is the floor, and the analyser's range starts
|
||||||
|
there.
|
||||||
|
2. **This branch carries no `feat:` commit.** Everything in it is
|
||||||
|
`ci:`/`docs:`/`chore:`/`build:`, plus at least one `fix:` — which is
|
||||||
|
honest, since wiring up release machinery that was configured and
|
||||||
|
never run *is* a fix. One patch-level commit in `v0.0.0..HEAD`
|
||||||
|
computes `0.0.1` and nothing else can.
|
||||||
|
|
||||||
|
This is a property of the commit range, not of the tool — it would have
|
||||||
|
been the same constraint under the shell script.
|
||||||
|
|
||||||
|
This is a real constraint on the branch, not an accounting trick: a
|
||||||
|
single `feat:` commit here makes the first release `v0.1.0`.
|
||||||
|
|
||||||
|
`v0.0.0` itself gets no release object — it is a floor, not a shipment.
|
||||||
|
|
||||||
|
## Decision 4 — what the release page carries
|
||||||
|
|
||||||
|
Four artifacts, and the fourth is the interesting one. Measured on this
|
||||||
|
machine rather than assumed:
|
||||||
|
|
||||||
|
| asset | built by | state |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `yellowjacket-<v>-android-arm64.apk` | `android-apk.yml` | already built, verified, signed |
|
||||||
|
| `yellowjacket-<v>-linux-amd64.tar.gz` | new job | binary + `.desktop` + icon |
|
||||||
|
| `yellowjacket-<v>-x86_64.pkg.tar.zst` | `arch-package.yml` | already built; free to attach |
|
||||||
|
| `yellowjacket-<v>-windows-amd64.zip` | new job | **compiles; has never been run** |
|
||||||
|
|
||||||
|
**macOS cannot be one of them.** `GOOS=darwin CGO_ENABLED=0` fails at
|
||||||
|
`wails/v3/pkg/mac: build constraints exclude all Go files` — the darwin
|
||||||
|
backend is Objective-C behind cgo, so a `.app` needs a macOS host and
|
||||||
|
the runner is a Linux container. That is precisely why the Homebrew
|
||||||
|
channel builds from source on the user's own Mac, and it stays the
|
||||||
|
answer for macOS.
|
||||||
|
|
||||||
|
**Windows is newly possible and should be labelled honestly.**
|
||||||
|
`GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -tags production`
|
||||||
|
succeeds in 2.5 s and produces a 40 MB `.exe` — nothing in the audio,
|
||||||
|
database or webview path needs cgo on Windows (oto uses WinMM through
|
||||||
|
`x/sys`, sqlite is modernc's pure-Go driver, WebView2 is COM syscalls,
|
||||||
|
and MPRIS is `linux && !android`-tagged). But **compiling is not
|
||||||
|
running**: no Windows build of this app has ever been started, no CI tier
|
||||||
|
can exercise one, and `backend/system`'s `%LOCALAPPDATA%` path has never
|
||||||
|
resolved on a real machine. It ships marked as untested in the release
|
||||||
|
notes, or it does not ship — an unlabelled Windows download is a promise
|
||||||
|
nothing here can keep.
|
||||||
|
|
||||||
|
### The race the ordering creates
|
||||||
|
|
||||||
|
semantic-release runs **prepare** (changelog commit, tag push) before
|
||||||
|
**publish** (the `exec` curl that creates the release object). The tag
|
||||||
|
push is what starts the publishing workflows — so a fast one can reach
|
||||||
|
its upload step *before the release exists*, and
|
||||||
|
`POST /releases/{id}/assets` needs an id.
|
||||||
|
|
||||||
|
The capacity-1 runner serialises things enough that this would usually
|
||||||
|
work, which is the worst kind of bug. So each upload step **polls
|
||||||
|
`GET /api/v1/repos/…/releases/tags/{tag}` with a bounded retry** before
|
||||||
|
uploading, and fails loudly on timeout rather than skipping the asset.
|
||||||
|
That is ~8 lines of shell, shared by all three publishers.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
**Phase 0 — clear the ground.**
|
||||||
|
Delete `v1.3.0`–`v1.6.0` locally and on `origin`; push `v0.0.0` at
|
||||||
|
`6fb7b5e` — this is the floor semantic-release reads, and without it the
|
||||||
|
first release is `1.0.0` by its own rule. Delete the empty root
|
||||||
|
`package.json`. Truncate `CHANGELOG.md` to a header plus a line saying
|
||||||
|
history before `0.0.1` is in `git log` — the existing content is another
|
||||||
|
repo's links and cannot be repaired, only replaced, and the `changelog`
|
||||||
|
plugin prepends to whatever it finds.
|
||||||
|
|
||||||
|
**Phase 1 — `.releaserc.yml`.**
|
||||||
|
Swap `@semantic-release/github` for `@semantic-release/exec`, whose
|
||||||
|
`publishCmd` POSTs to
|
||||||
|
`/api/v1/repos/yonlu/yellowjacket/releases` with `tag_name`, `name` and
|
||||||
|
`body` taken from `${nextRelease.*}`. Keep `commit-analyzer`,
|
||||||
|
`release-notes-generator`, `changelog` and `git` exactly as written; fix
|
||||||
|
the `git` plugin's commit message so it passes `commit-check`
|
||||||
|
(`chore(release): ${nextRelease.version}` — the existing one already
|
||||||
|
does, but the trailing `${nextRelease.notes}` in the body is worth
|
||||||
|
keeping deliberate rather than incidental). `make release-dry` wraps
|
||||||
|
`semantic-release --dry-run` so the next version is answerable without
|
||||||
|
pushing anything.
|
||||||
|
|
||||||
|
`scripts/commit-check.sh`'s header already points at `.releaserc.yml`
|
||||||
|
for the type list and stays correct — that coupling survives this plan
|
||||||
|
rather than being broken by it.
|
||||||
|
|
||||||
|
**Phase 2 — `.gitea/workflows/release.yml`.**
|
||||||
|
On `push: branches: [main]`. Node 22, the pinned `npx` line from
|
||||||
|
Decision 1, `--repository-url` carrying `PACKAGE_TOKEN`. Concurrency
|
||||||
|
group `release-main` with `cancel-in-progress: false` — cutting a tag is
|
||||||
|
not a thing to cancel halfway.
|
||||||
|
|
||||||
|
The one guard that matters: **the job exits early when `HEAD`'s subject
|
||||||
|
starts `chore(release):`**, so the changelog commit the `git` plugin
|
||||||
|
pushes cannot re-enter this workflow. That is checked in shell rather
|
||||||
|
than left to `[skip ci]`, whose handling in Gitea is one more thing that
|
||||||
|
would have to be verified.
|
||||||
|
|
||||||
|
**Phase 3 — rewire the publish workflows.**
|
||||||
|
`arch-package.yml` moves from `push: branches: [main]` to
|
||||||
|
`push: tags: ['v*']` plus `workflow_dispatch`, so a merge no longer
|
||||||
|
publishes an untagged package. `homebrew-formula.yml` gains
|
||||||
|
`workflow_dispatch` with a `version` input and takes its version from
|
||||||
|
the input when there is no tag. `android-apk.yml` needs neither.
|
||||||
|
|
||||||
|
**Phase 3b — the assets.**
|
||||||
|
`scripts/release-asset.sh` is the shared uploader: wait for the release
|
||||||
|
by tag, then `POST /releases/{id}/assets?name=…`. `android-apk.yml` and
|
||||||
|
`arch-package.yml` each call it with the artifact they already built.
|
||||||
|
A new `desktop-assets` job — `push: tags: ['v*']`, in the same
|
||||||
|
`ubuntu:24.04` container `ci.yml` uses — builds the Linux binary via
|
||||||
|
`make build-prod` and the Windows one via the `CGO_ENABLED=0`
|
||||||
|
cross-compile, and uploads both. It is a separate job from the Arch one
|
||||||
|
because that runs in an `archlinux` container as an unprivileged
|
||||||
|
`makepkg` user, and grafting two unrelated builds onto it would make one
|
||||||
|
failure look like the other.
|
||||||
|
|
||||||
|
**Phase 4 — say that the upgrade is a reinstall, and that Windows is untried.**
|
||||||
|
No code change: a note in `packaging/homebrew/README.md`, one in
|
||||||
|
`docs/android-release.md`, the three-channel downgrade warning written
|
||||||
|
into the `v0.0.1` release notes, and a standing line in the notes
|
||||||
|
template marking the Windows asset unverified until someone runs it.
|
||||||
|
|
||||||
|
**Phase 5 — cut it and watch.** *(the only phase left)*
|
||||||
|
Merge, then verify with `gitea_ci` that (a) `release.yml` ran, seeded
|
||||||
|
`v0.0.0` and produced `v0.0.1`, (b) the release exists **with a non-empty
|
||||||
|
body** — check the body, not the exit code — and (c) **all four publish
|
||||||
|
workflows started from the tag**. If (c) is empty, that is Decision 2's
|
||||||
|
fallback and the `workflow_dispatch` inputs added in phase 3 are already
|
||||||
|
there to drive it.
|
||||||
|
|
||||||
|
The expected sequence on the merge is: `release.yml` seeds `v0.0.0`
|
||||||
|
(triggering nothing), releases `0.0.1`, and pushes both the changelog
|
||||||
|
commit and the tag — at which point `release.yml` fires a second time on
|
||||||
|
the changelog commit and exits at the `chore(release):` guard, while the
|
||||||
|
four `v*` workflows start. On a capacity-1 runner they will queue behind
|
||||||
|
each other, Android last and longest.
|
||||||
|
|
||||||
|
**Phase 6 — the documentation that will otherwise be wrong.**
|
||||||
|
CLAUDE.md's *Commits* section currently explains `.releaserc.yml` and
|
||||||
|
says nothing runs it; the CI section says there are five workflows and
|
||||||
|
that only `ci.yml` gates. Both change. `docs/android-release.md`
|
||||||
|
describes tags as hand-pushed. `make skill-check` fails on a `.pi/`
|
||||||
|
reference to a make target that does not exist, so `make release-dry`
|
||||||
|
gets documented or nothing does.
|
||||||
|
|
||||||
|
## Open questions for you
|
||||||
|
|
||||||
|
1. **Ship the Windows `.exe` or not?** It builds, and it has never run.
|
||||||
|
Marked-as-untested is the assumption; say if you would rather hold it
|
||||||
|
back until someone boots it.
|
||||||
|
|
||||||
|
Resolved: semantic-release stays, with `exec` in place of the `github`
|
||||||
|
plugin (Decision 1). Reinstalls are accepted, so no epoch and no
|
||||||
|
versionCode offset (Decision 3). The release carries the APK, a Linux
|
||||||
|
tarball, the Arch package and — pending (1) — a Windows zip; macOS is
|
||||||
|
not buildable here and stays a Homebrew-from-source channel (Decision 4).
|
||||||
|
`v0.0.0` has to be a real tag under this design — semantic-release reads
|
||||||
|
git tags for its floor and has no "treat absence as 0.0.0" knob that
|
||||||
|
also stops it calling the first release `1.0.0`.
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
# 016 — What Android parity would actually take
|
||||||
|
|
||||||
|
> **Status: all of section A is done.** A1–A3 landed with "let the app
|
||||||
|
> reach the user's music"; A4 (MediaSession, transport notification,
|
||||||
|
> audio focus) landed with "survive the screen locking". The direction
|
||||||
|
> taken is **option 1, the full librarian**: `MANAGE_EXTERNAL_STORAGE`
|
||||||
|
> plus an in-app folder browser, which keeps the path-keyed model
|
||||||
|
> intact. B1/B2 remain, both awaiting a decision rather than work. The
|
||||||
|
> sections below are kept as written, because they are the argument the
|
||||||
|
> decision rests on — see "What is left" at the end for the current
|
||||||
|
> state.
|
||||||
|
|
||||||
|
Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and
|
||||||
|
versioned, and publishes from CI. This is the assessment of what stands
|
||||||
|
between that and an Android app worth installing.
|
||||||
|
|
||||||
|
**The headline: parity is the wrong target, and choosing it would be
|
||||||
|
the expensive mistake.** Four of the blockers below are not porting work
|
||||||
|
— they are the Android platform declining to support the model this app
|
||||||
|
is built on. The decision to make first is in "The fork in the road" at
|
||||||
|
the end; everything before it is evidence for that decision.
|
||||||
|
|
||||||
|
Severity is what the app *does* today, verified against the source and
|
||||||
|
the generated manifest, not guessed.
|
||||||
|
|
||||||
|
## A. It cannot work at all until these are fixed
|
||||||
|
|
||||||
|
### A1. The app can read no music. (deepest)
|
||||||
|
|
||||||
|
`build/android/app/src/main/AndroidManifest.xml` requests INTERNET,
|
||||||
|
VIBRATE, ACCESS_NETWORK_STATE, USE_BIOMETRIC, POST_NOTIFICATIONS, the
|
||||||
|
two location permissions, CAMERA and the two FOREGROUND_SERVICE ones.
|
||||||
|
**There is no storage or media permission of any kind.** At
|
||||||
|
`targetSdk 35` that means the app can see its own private directory and
|
||||||
|
nothing else.
|
||||||
|
|
||||||
|
Adding `READ_MEDIA_AUDIO` is necessary and *not sufficient*, because it
|
||||||
|
grants access through **MediaStore**, not through the filesystem. This
|
||||||
|
app's entire model is absolute paths: `audio_files.file_path` is the
|
||||||
|
primary key of ownership, `AddLibrary(path)` takes a directory, the
|
||||||
|
scanner walks it with `os.ReadDir`, and every one of
|
||||||
|
`GetFilePathsByAlbums` / `ByGenres` / `ByRecordingMBIDs` exists to hand
|
||||||
|
paths to the player. Scoped storage does not offer a stable directory
|
||||||
|
to walk.
|
||||||
|
|
||||||
|
The honest options are three, and they are not close in cost:
|
||||||
|
|
||||||
|
- **MediaStore as the library source.** Query the content resolver,
|
||||||
|
keep MediaStore IDs (or content URIs) beside or instead of paths, and
|
||||||
|
open audio through a `ContentResolver` file descriptor. This is the
|
||||||
|
Android-native answer and it touches the schema, the scanner, the
|
||||||
|
player's file opening and every path-keyed query.
|
||||||
|
- **`MANAGE_EXTERNAL_STORAGE`.** Keeps the path model intact and is
|
||||||
|
effectively barred from Google Play except for genuine file managers.
|
||||||
|
Viable *only* because we distribute through Obtainium — which is a
|
||||||
|
real point in its favour here, and worth stating plainly rather than
|
||||||
|
dismissing.
|
||||||
|
- **App-private storage only**, i.e. the user copies music into the
|
||||||
|
app's sandbox. Trivial to build, and nobody wants it.
|
||||||
|
|
||||||
|
### A2. The first-run flow cannot complete.
|
||||||
|
|
||||||
|
`first-run-wizard.ts` calls `DirectoryPicker()`, which is
|
||||||
|
`frontendutil.DirectoryPicker` → `app.Dialog.OpenFile().
|
||||||
|
CanChooseDirectories(true)`. Wails' own `ANDROID.md` lists open-directory
|
||||||
|
dialogs as **"❌ Returns an error — SAF yields tree URIs, not filesystem
|
||||||
|
paths"**. So the one action the wizard exists to perform fails, and
|
||||||
|
`<first-run-wizard>` intercepts all pointer events until a library
|
||||||
|
exists — so the app is not merely empty, it is inert.
|
||||||
|
|
||||||
|
Whatever A1 resolves to decides this: a MediaStore library needs no
|
||||||
|
picker at all, and a SAF tree needs the picker to return a URI the
|
||||||
|
backend can use.
|
||||||
|
|
||||||
|
### A3. MPRIS is compiled into the Android build.
|
||||||
|
|
||||||
|
`mpris_linux.go` is `//go:build linux`, and **`android` implies
|
||||||
|
`linux`** (documented, and the reason it is in the APK). It will look
|
||||||
|
for a session bus that does not exist. It needs `//go:build linux &&
|
||||||
|
!android`, and its Android counterpart is A4.
|
||||||
|
|
||||||
|
This one is cheap and should be done regardless — it is a two-character
|
||||||
|
build-tag change plus whatever `mediacontrols.New` returns instead.
|
||||||
|
|
||||||
|
### A4. Playback will be killed the moment the screen locks.
|
||||||
|
|
||||||
|
The scaffold's `WailsForegroundService` is typed **`dataSync`**
|
||||||
|
(`foregroundServiceType="dataSync"`, `FOREGROUND_SERVICE_TYPE_DATA_SYNC`),
|
||||||
|
and the manifest requests `FOREGROUND_SERVICE_DATA_SYNC`. A music player
|
||||||
|
needs `mediaPlayback` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, plus a
|
||||||
|
`MediaSession` for lock-screen and notification transport controls,
|
||||||
|
plus **audio focus** — pause on a phone call, duck for a notification,
|
||||||
|
pause on headphone unplug. None of that exists today. `oto` will happily
|
||||||
|
keep writing to a stream nobody can hear.
|
||||||
|
|
||||||
|
This is the difference between "an app that plays audio" and "a music
|
||||||
|
player", and it is Java-side work in the scaffold plus a Go-side bridge.
|
||||||
|
|
||||||
|
## B. It works, but wrongly
|
||||||
|
|
||||||
|
### B1. The x86_64 half of the APK cannot run on any Android.
|
||||||
|
|
||||||
|
Established in plan 015: `modernc.org/libc`'s `Xlstat64` issues a raw
|
||||||
|
`lstat` on linux/amd64, which Android's seccomp forbids, so the process
|
||||||
|
takes `SIGSYS` the first time it touches the database. arm64 is
|
||||||
|
structurally unaffected (no `lstat` syscall exists; it routes through
|
||||||
|
`fstatat`).
|
||||||
|
|
||||||
|
So ~31 MB of the artifact is dead weight on *every* Android device,
|
||||||
|
including x86 Chromebooks. Options: drop `x86_64` from `abiFilters`
|
||||||
|
(smaller APK, no emulator target — which does not work anyway), or
|
||||||
|
carry it against a future modernc fix. **Dropping it is the honest
|
||||||
|
default**; it is also the only item in this plan that is a five-minute
|
||||||
|
change.
|
||||||
|
|
||||||
|
### B2. The UI is a desktop shell.
|
||||||
|
|
||||||
|
`MinWidth`/`MinHeight` are 800×600 and were *measured* — below ~780 the
|
||||||
|
header subtitle wraps the title out of its bar. A phone is ~360–430 CSS
|
||||||
|
px wide. The sidebar collapses to icons below 900px, which is a
|
||||||
|
laptop-sized breakpoint, not a phone one. Beyond width: the app is built
|
||||||
|
on hover (the marquee's `hover` mode, tooltips), right-click context
|
||||||
|
menus, a keyboard shortcut layer with its own overlay and settings page,
|
||||||
|
multi-select with ctrl/shift, and a resizable-column track list. None of
|
||||||
|
those are gestures.
|
||||||
|
|
||||||
|
This is not a stylesheet pass. It is a second front end for the views
|
||||||
|
worth having on a phone, sharing the stores and bindings — which the
|
||||||
|
architecture supports, since a view is already a lazily-loaded chunk
|
||||||
|
behind `VIEW_LOADERS`.
|
||||||
|
|
||||||
|
### B3. Tag writing cannot reach the user's files.
|
||||||
|
|
||||||
|
`tagwriter` rewrites tags in place, and autotag's whole purpose is
|
||||||
|
applying them to a folder. Under scoped storage that is impossible
|
||||||
|
outside the sandbox without a SAF write grant per tree. If A1 lands on
|
||||||
|
MediaStore, in-place tag writing needs `MediaStore` write requests and
|
||||||
|
user confirmation per file on Android 11+.
|
||||||
|
|
||||||
|
Autotagging is arguably a desktop-only feature and saying so is a
|
||||||
|
legitimate answer.
|
||||||
|
|
||||||
|
### B4. The Explore catalog is a ~0.6 GB download into app-private storage.
|
||||||
|
|
||||||
|
It works — but with no awareness of a metered connection and no
|
||||||
|
accounting for a device where that is a meaningful fraction of free
|
||||||
|
space. At minimum it needs to be opt-in on mobile and to refuse a
|
||||||
|
metered network by default. `Android.NetworkJSON()` reports
|
||||||
|
`{connected,type}`, so the signal is available.
|
||||||
|
|
||||||
|
## C. Inert, and fine
|
||||||
|
|
||||||
|
Window geometry, menus and the system tray are documented no-ops on
|
||||||
|
mobile. The keyboard shortcut layer is harmless but its Settings page
|
||||||
|
is dead weight. `profiling` is already compiled out of production
|
||||||
|
builds. These cost nothing and need no work.
|
||||||
|
|
||||||
|
## D. Unknown until it runs on a device
|
||||||
|
|
||||||
|
**Nothing in section A or B has been observed on Android**, because the
|
||||||
|
x86_64 emulator cannot run the app (B1) and emulator 37 refuses arm64
|
||||||
|
images on an x86_64 host. Everything above is read from the source, the
|
||||||
|
generated manifest and Wails' own documentation. The first real device
|
||||||
|
run will find things this list does not have, and the most likely
|
||||||
|
places are audio latency and buffering under `oto`/oboe, and SQLite
|
||||||
|
behaviour on app-private storage.
|
||||||
|
|
||||||
|
## The fork in the road
|
||||||
|
|
||||||
|
The four blockers in section A are all the same question wearing
|
||||||
|
different clothes: **is the Android app a librarian, or a player?**
|
||||||
|
|
||||||
|
YellowJacket on the desktop is a *librarian*. It scans folders,
|
||||||
|
deduplicates covers, detects duplicate tracks, reconciles against
|
||||||
|
MusicBrainz, rewrites tags on disk, and manages downloads. That model
|
||||||
|
rests on owning a filesystem, which is precisely what Android declines
|
||||||
|
to give.
|
||||||
|
|
||||||
|
Three coherent products, and only the first is "parity":
|
||||||
|
|
||||||
|
1. **Full librarian on Android.** Requires `MANAGE_EXTERNAL_STORAGE`
|
||||||
|
(Obtainium-only distribution, which we already have), a phone UI for
|
||||||
|
every view, and media-session playback. Largest scope by far; the
|
||||||
|
result is an app almost nobody has asked for on a phone.
|
||||||
|
2. **A player for music already on the phone.** MediaStore as the
|
||||||
|
source, no scanner, no autotag, no downloads; the library, queue,
|
||||||
|
playlists, favourites and Explore-as-browsing all still make sense.
|
||||||
|
This is a genuinely good Android app and it is *not* parity — it is
|
||||||
|
a subset with a different data source.
|
||||||
|
3. **A companion to the desktop app.** The phone browses and controls
|
||||||
|
the desktop's library over the network, or syncs a subset. Smallest
|
||||||
|
Android surface, and it leans on the thing that already works.
|
||||||
|
|
||||||
|
**Option 2 is the recommendation** if the goal is an app people use;
|
||||||
|
option 3 if the goal is the least work for the most value. Option 1 is
|
||||||
|
the only one that answers "feature parity" literally, and it is the one
|
||||||
|
worth arguing hardest against.
|
||||||
|
|
||||||
|
> **Decided:** option 1's *data model* (the librarian keeps its
|
||||||
|
> filesystem and its scanner — A1 shipped that) with option 2's
|
||||||
|
> *surface*. The phone is a player over the library this app already
|
||||||
|
> builds; it does not get every view. The list is below.
|
||||||
|
|
||||||
|
## The phone gets a subset (decided)
|
||||||
|
|
||||||
|
B2 is not a stylesheet pass and not a second front end either. A view
|
||||||
|
is already a lazily-loaded chunk behind `VIEW_LOADERS` /
|
||||||
|
`DETAIL_LOADERS` in `index.ts`, and the stores and bindings are shared,
|
||||||
|
so the phone build is **a different loader table and a different
|
||||||
|
chrome**, over the same stores.
|
||||||
|
|
||||||
|
**In**, because each is something a person does with a phone in their
|
||||||
|
hand:
|
||||||
|
|
||||||
|
- **Home** — the shelves are already a phone-shaped surface.
|
||||||
|
- **Library browse** — albums, artists, genres. The grids are already
|
||||||
|
virtualized and card-shaped.
|
||||||
|
- **Now playing** — which on a phone is a *view*, not a 4em bar.
|
||||||
|
- **The queue.**
|
||||||
|
- **Search** — the header box, scoped as it already is.
|
||||||
|
- **Playlists**, including smart ones, as lists to play rather than to
|
||||||
|
edit.
|
||||||
|
|
||||||
|
**Out**, and each for a reason rather than by omission:
|
||||||
|
|
||||||
|
- **Autotag** — the review UI is a wide table and the action rewrites
|
||||||
|
files on disk; B3 has not been verified even as *possible* yet.
|
||||||
|
- **Downloads** — two tab panels of client configuration.
|
||||||
|
- **Explore** — the catalog is a ~0.6 GB download (B4); browsing it is
|
||||||
|
the last thing to earn a phone's storage.
|
||||||
|
- **Settings** — not the page. The phone needs a handful of settings
|
||||||
|
(theme, the library folder, playback) and not the 93 controls the
|
||||||
|
desktop page carries.
|
||||||
|
- **Jobs**, **shortcuts overlay**, **column configuration** — a phone
|
||||||
|
has no keyboard and no resizable columns, and the jobs indicator is
|
||||||
|
enough.
|
||||||
|
|
||||||
|
What the shell has to lose, from the audit at the top of this section:
|
||||||
|
the 800×600 minimum, the 11-item sidebar (a phone wants a bottom tab
|
||||||
|
bar over the five things above), hover as a route to anything,
|
||||||
|
right-click as the only route to a context menu (long-press is the
|
||||||
|
gesture), and ctrl/shift multi-select.
|
||||||
|
|
||||||
|
One rule for the work: **no view forks.** A phone layout that copies a
|
||||||
|
view's template is two templates to fix every bug in. Where a view
|
||||||
|
cannot serve both, the split belongs at the chunk boundary that already
|
||||||
|
exists.
|
||||||
|
|
||||||
|
Phase 1 followed that rule and found its cost: reusing `<app-sidebar>`
|
||||||
|
inside the drawer means reusing its `data-testid`s too, and a second
|
||||||
|
copy standing by in the DOM broke 30 specs that had nothing to do with
|
||||||
|
the phone. The rule holds — a second list of destinations would be
|
||||||
|
worse — but a shared component must be rendered only when it is wanted,
|
||||||
|
and the guard belongs in a test that names the reason.
|
||||||
|
|
||||||
|
## What is worth doing regardless of that decision
|
||||||
|
|
||||||
|
Cheap, independently useful, and each unblocks measurement:
|
||||||
|
|
||||||
|
1. **Drop `x86_64` from `abiFilters`** (B1) — or keep it and document
|
||||||
|
why. Five minutes.
|
||||||
|
2. **`//go:build linux && !android` on `mpris_linux.go`** (A3), so the
|
||||||
|
Android build stops carrying a D-Bus client. Small.
|
||||||
|
3. **A device smoke run**, which needs someone's phone and the published
|
||||||
|
APK. Everything in D depends on it, and it is the single highest
|
||||||
|
information-per-minute action available.
|
||||||
|
4. **Make the first-run wizard fail legibly** rather than inertly (A2)
|
||||||
|
— the picker's error already routes through `describeError`, but the
|
||||||
|
wizard still blocks pointer events, so an Android user sees a dead
|
||||||
|
screen rather than a sentence. Even under option 3 this is the right
|
||||||
|
behaviour.
|
||||||
|
|
||||||
|
|
||||||
|
## What is left (updated after A4)
|
||||||
|
|
||||||
|
**A4 is done.** `backend/mediacontrols/android.go` is a `Handler`
|
||||||
|
beside the MPRIS one, and the Java half is
|
||||||
|
`WailsForegroundService.java`: a `MediaSession`, a `MediaStyle`
|
||||||
|
transport notification and audio focus. It needed no new JNI and no new
|
||||||
|
Gradle dependency — `application.Android.StartForegroundService(json)`
|
||||||
|
going out, `WailsBridge.emitEvent` → the application event bus coming
|
||||||
|
back, and the platform `android.media.session` API rather than
|
||||||
|
androidx.media, which minSdk 21 makes available anyway.
|
||||||
|
|
||||||
|
Four decisions in it are worth keeping:
|
||||||
|
|
||||||
|
- **Ducking is a player concept, not a volume change.**
|
||||||
|
`Player.SetDuck` re-applies the *user's* level with an attenuation
|
||||||
|
offset, so `getUserVolume` still reports what the user chose and
|
||||||
|
nothing is persisted or emitted. A duck that wrote through to the
|
||||||
|
volume would let one notification tone permanently turn the music
|
||||||
|
down.
|
||||||
|
- **The duck path is pre-Oreo only.** From API 26 the framework ducks
|
||||||
|
the app itself and sends no `CAN_DUCK` focus change, so asking to be
|
||||||
|
told instead (`setWillPauseWhenDucked`) would mean pausing for every
|
||||||
|
notification tone, and doing both would attenuate twice.
|
||||||
|
- **An unchanged payload is not an event here either.** Every push
|
||||||
|
crosses JNI and re-delivers an Intent, and the player pushes state on
|
||||||
|
several paths that can agree.
|
||||||
|
- **After the first start, updates use `startService`.** From Android
|
||||||
|
12 an app in the background may not *start* a foreground service, but
|
||||||
|
it may keep delivering intents to one it already has — which is every
|
||||||
|
track change with the screen off.
|
||||||
|
|
||||||
|
The contract with Java — the payload keys, the state words, the command
|
||||||
|
names — is in `androidpayload.go`, deliberately *without* the `android`
|
||||||
|
build tag, so `go test` exercises it on every platform. Everything left
|
||||||
|
in `android.go` is untested by construction: it compiles only under a
|
||||||
|
cross-compiler and runs only on a phone.
|
||||||
|
|
||||||
|
**B1 is done: x86_64 is dropped.** 27.1 MB → 15.9 MB, measured. Three
|
||||||
|
places had to agree — `abiFilters`, the Makefile's `android:package`
|
||||||
|
(or Go still compiles a library Gradle then discards) and the
|
||||||
|
`native-code: 'arm64-v8a'$` assertion in `android-apk.yml`, whose
|
||||||
|
anchor is what stops it also matching the fat APK's line. Adding the
|
||||||
|
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
|
||||||
|
|
||||||
|
**B2, the desktop shell.** Scope decided (below); **all four phases are
|
||||||
|
done.**
|
||||||
|
|
||||||
|
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
|
||||||
|
`<bottom-nav>` is the primary navigation, and the shell fits 320px
|
||||||
|
exactly — measured, from 652px in a 360px viewport before.
|
||||||
|
- *Phase 2, the full-screen now-playing view.* Where phase 1's seek bar
|
||||||
|
and volume went. A detail view, so Back pops the nav stack; it
|
||||||
|
composes the real transport components rather than copying them; and
|
||||||
|
it hides the bottom bar while it is up, so it carries its own queue
|
||||||
|
button.
|
||||||
|
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
|
||||||
|
listener, installed once from `index.ts`, which turns a 500 ms
|
||||||
|
stationary touch into a synthetic `contextmenu` at the touch point.
|
||||||
|
Every menu in the app opens from that event, so all six components
|
||||||
|
gained the gesture without one of them changing — which is the same
|
||||||
|
argument `ContextMenuController` rests on, one layer lower. The
|
||||||
|
details that are not obvious are in `NOTES.md` (2026-08-17); the one
|
||||||
|
worth repeating is that ours is told from the browser's own
|
||||||
|
long-press event by **identity**, not `isTrusted`, because a test
|
||||||
|
cannot dispatch a trusted event and that path would otherwise be the
|
||||||
|
only uncovered one.
|
||||||
|
|
||||||
|
- *Phase 4, the track list.* A phone draws `titleArtist` (title over
|
||||||
|
artist) plus the duration, and drops the column headers and the resize
|
||||||
|
handles — a column set rather than a second row template, so the row
|
||||||
|
and everything delegated on it is unchanged. Verified at the device's
|
||||||
|
own 424x439: `24px 304px 80px`, 52 px rows, no truncation, no
|
||||||
|
overflow. The device also found the bug in it, which no browser
|
||||||
|
viewport would have: saved *desktop* column widths reached the phone
|
||||||
|
through an id-keyed store and gave the duration column 55% of the row.
|
||||||
|
|
||||||
|
**B2 and B4 are complete.** B4 is `backend/explore/netpolicy.go`: the
|
||||||
|
catalog download is skipped on a cellular connection unless
|
||||||
|
`AllowMeteredCatalogDownload` is on, with the toggle in Settings' Search
|
||||||
|
Index section. The policy and the JSON parsing are in `explore` (tested
|
||||||
|
on every platform) and only the platform call is injected from `app.go`,
|
||||||
|
because `cmd/indexbuild` imports `explore` and must not link Wails. Two
|
||||||
|
things the plan got slightly wrong: the portable API is
|
||||||
|
`application.Mobile.NetworkJSON()` rather than `Android`'s, and it
|
||||||
|
reports no metered flag — so cellular is the signal and a metered Wi-Fi
|
||||||
|
cannot be seen.
|
||||||
|
|
||||||
|
What is left in this plan is B3 (tag writing, which needs a device) and
|
||||||
|
the standing question of the Light Phone's Chrome 113 — which so far has
|
||||||
|
cost nothing: menus, dialogs and long-press all work on it.
|
||||||
|
|
||||||
|
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
|
||||||
|
with all-files access, `tagwriter` can write in place.
|
||||||
|
|
||||||
|
### What the first device run answered (2026-08-17)
|
||||||
|
|
||||||
|
A4 **works**: playback survives the screen locking, and the transport
|
||||||
|
notification appears with cover art — which also settles the service's
|
||||||
|
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
|
||||||
|
the lock-screen session in one observation. Everything below in "what
|
||||||
|
none of section A answered" was written before this and is now answered
|
||||||
|
except the OEM permission-flow variance.
|
||||||
|
|
||||||
|
It also found two faults no browser tier can see, both fixed and both
|
||||||
|
awaiting the next APK for confirmation (`NOTES.md`, same date):
|
||||||
|
|
||||||
|
- **Back quit the app from any depth.** The scaffold asks
|
||||||
|
`webView.canGoBack()`; the frontend had never used `history`. A
|
||||||
|
navigation is a history entry now, and `navStack` is gone rather than
|
||||||
|
kept beside it.
|
||||||
|
- **The transport was under the gesture bar** — or so the version
|
||||||
|
number said. `applyWindowInsets()` in `MainActivity` is right and
|
||||||
|
stays, but the phone is **Android 14**, where the system still insets
|
||||||
|
the window: the fix is pre-emptive and the symptom has another cause.
|
||||||
|
Still open, along with icons that do not appear at all. The phone's
|
||||||
|
WebView is **Chrome 113**, which is the lead (no Popover API, no
|
||||||
|
relaxed CSS nesting), and `make android-inspect` / `android-eval` are
|
||||||
|
how it gets asked.
|
||||||
|
|
||||||
|
The standing item is unchanged in kind: **B3 (tag writing) and the
|
||||||
|
permission flow still need a device**, and so does confirming these two.
|
||||||
|
|
||||||
|
### What none of section A answered
|
||||||
|
|
||||||
|
Nothing here has been observed on a device. The permission flow in
|
||||||
|
particular is the kind of thing that behaves differently across OEM
|
||||||
|
builds — `ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` is
|
||||||
|
implemented inconsistently, which is why there is a fallback to the
|
||||||
|
global list, and neither path has been exercised.
|
||||||
|
|
||||||
|
A4 adds its own list of things only a device can answer, and they are
|
||||||
|
the likely first failures: whether the notification appears at all
|
||||||
|
(POST_NOTIFICATIONS is requested from `startForegroundService`, so a
|
||||||
|
user who declines gets a service with an invisible notification),
|
||||||
|
whether audio focus arrives while `oto`/oboe holds the output, whether
|
||||||
|
the lock screen picks up the session, and whether cover art decoded
|
||||||
|
from a `MANAGE_EXTERNAL_STORAGE` path is readable by the service.
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
# semantic-release configuration
|
# semantic-release configuration.
|
||||||
# Runs on main branch pushes to auto-determine version from conventional commits.
|
#
|
||||||
# Creates a git tag + GitHub Release draft; a separate workflow builds binaries.
|
# Runs on pushes to main from .gitea/workflows/release.yml: determine the
|
||||||
|
# version from the Conventional Commits since the last tag, write the
|
||||||
|
# changelog, commit it, push the tag, and create the Gitea release.
|
||||||
|
#
|
||||||
|
# **There is no `@semantic-release/github` plugin here and there must not
|
||||||
|
# be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea
|
||||||
|
# community plugin (@saithodev/semantic-release-gitea) was considered and
|
||||||
|
# rejected: last published 2022, depends on got@10, and declares no peer
|
||||||
|
# dependency on semantic-release at all — i.e. untested against anything
|
||||||
|
# since v19, against a core now at v25. `exec` is first-party, current,
|
||||||
|
# and the Gitea-shaped part is one curl.
|
||||||
|
#
|
||||||
|
# The type list below is the one scripts/commit-check.sh enforces the
|
||||||
|
# grammar for — keep the two in step, or semantic-release will silently
|
||||||
|
# decline to release something the commit hook accepted.
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
@@ -63,19 +77,41 @@ plugins:
|
|||||||
section: Build
|
section: Build
|
||||||
hidden: true
|
hidden: true
|
||||||
|
|
||||||
# Write CHANGELOG.md.
|
# Render the notes to a file.
|
||||||
|
#
|
||||||
|
# **This plugin is here to carry the notes, not to maintain a document.**
|
||||||
|
# It is how they reach the Gitea API *without being interpolated into a
|
||||||
|
# shell command*: release notes are rendered commit messages — arbitrary
|
||||||
|
# text carrying backticks, quotes and `$` — so templating
|
||||||
|
# ${nextRelease.notes} into `publishCmd` would be a shell injection with
|
||||||
|
# the commit log as its input. scripts/gitea-release.sh reads the top
|
||||||
|
# section of this file instead, and the only thing interpolated below is
|
||||||
|
# a semver string.
|
||||||
|
#
|
||||||
|
# The target is a gitignored build artifact rather than CHANGELOG.md,
|
||||||
|
# because nothing commits it back — see below.
|
||||||
- - "@semantic-release/changelog"
|
- - "@semantic-release/changelog"
|
||||||
- changelogFile: CHANGELOG.md
|
- changelogFile: .release-notes.md
|
||||||
|
changelogTitle: "# Release notes"
|
||||||
|
|
||||||
# Commit the changelog back to the repo.
|
# Create the Gitea release, whose body is that section.
|
||||||
- - "@semantic-release/git"
|
# `publish` runs after `prepare`, so the tag already exists by here.
|
||||||
- assets:
|
- - "@semantic-release/exec"
|
||||||
- CHANGELOG.md
|
- publishCmd: "./scripts/gitea-release.sh ${nextRelease.version}"
|
||||||
message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
|
||||||
|
# **There is deliberately no @semantic-release/git here.**
|
||||||
|
#
|
||||||
|
# `main` is a protected branch with `enable_push: false` and an empty
|
||||||
|
# push whitelist, so a changelog commit-back would be rejected by the
|
||||||
|
# pre-receive hook — *after* the tag had already been pushed, leaving a
|
||||||
|
# tagged release the run then reported as failed. The alternative was to
|
||||||
|
# whitelist the CI user, which weakens a protection someone set on
|
||||||
|
# purpose and lets a bot push to main without passing the checks every
|
||||||
|
# human PR has to.
|
||||||
|
#
|
||||||
|
# So the release page is the changelog. Tags are not protected, so the
|
||||||
|
# tag push semantic-release does itself is unaffected. CHANGELOG.md in
|
||||||
|
# the repo is a signpost to the releases page and is not written by any
|
||||||
|
# of this; a file that claimed to be a changelog and silently stopped
|
||||||
|
# updating would be worse than no file at all.
|
||||||
|
|
||||||
# Create the GitHub Release (draft, so the build workflow can attach binaries).
|
|
||||||
- - "@semantic-release/github"
|
|
||||||
- draft: true
|
|
||||||
successComment: false
|
|
||||||
failComment: false
|
|
||||||
releasedLabels: false
|
|
||||||
|
|||||||
@@ -1,377 +1,21 @@
|
|||||||
## [1.3.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.3...v1.3.0) (2026-03-20)
|
# Changelog
|
||||||
|
|
||||||
### Features
|
The changelog is the releases page:
|
||||||
|
|
||||||
* **09-01:** add scan control events and cancelled metrics field ([c695024](https://github.com/onion-4-dinner/yellowjacket/commit/c695024241a7513b8fedb3fbf7ff364d0515b392))
|
<https://git.ljones.me/yonlu/yellowjacket/releases>
|
||||||
* **09-01:** add scan control fields and per-scan cancellable context ([cf22e52](https://github.com/onion-4-dinner/yellowjacket/commit/cf22e52a64850a80b9fcc63c21d81313e6bd56ab))
|
|
||||||
* **09-02:** add frontend keyboard shortcut service, store, and controller ([40d4815](https://github.com/onion-4-dinner/yellowjacket/commit/40d48151dd798b57eed9f54a572ae4735356d09e))
|
|
||||||
* **09-02:** add shortcuts config package with default bindings and Wails persistence ([6285ca9](https://github.com/onion-4-dinner/yellowjacket/commit/6285ca9dc4e6f211197e377d01c485b1ef65c300))
|
|
||||||
* **09-03:** add scan control UI with pause/resume/cancel and confirmation dialog ([3914369](https://github.com/onion-4-dinner/yellowjacket/commit/391436927c826f2f17a4523be7829aefc04a6b12))
|
|
||||||
* **09-04:** add keyboard shortcuts section to config page with conflict detection ([0451fb3](https://github.com/onion-4-dinner/yellowjacket/commit/0451fb38805ff2c27e43deb152daa892e733d2db))
|
|
||||||
* **10-01:** implement migration 6 and pre-migration backup ([1179f56](https://github.com/onion-4-dinner/yellowjacket/commit/1179f56c3680112692e71e8dc7ce946446fa8a8a))
|
|
||||||
* **10-01:** update SQL schema files for multi-library fresh installs ([535855b](https://github.com/onion-4-dinner/yellowjacket/commit/535855b383a457dd2be3298b4361313bef22b39d))
|
|
||||||
* **10-02:** add migration 6 integration tests and NewTestDBWithLibrary helper ([bc15189](https://github.com/onion-4-dinner/yellowjacket/commit/bc151891b50e59e41da2e00dbfafbecaad11b4ac))
|
|
||||||
* **10-02:** add sqlc queries for libraries and update playlist queries for phantom support ([02548dd](https://github.com/onion-4-dinner/yellowjacket/commit/02548dd55e59b28f3d6c8d9614f209140c979250))
|
|
||||||
* **11-01:** per-library scan pipeline with queue coordinator ([943db1c](https://github.com/onion-4-dinner/yellowjacket/commit/943db1cf274bdf59daf28ab6c20f78ef5ef53105))
|
|
||||||
* **11-02:** update config-page with per-library progress display and queue-aware cancel dialog ([d01591d](https://github.com/onion-4-dinner/yellowjacket/commit/d01591d6cc054a63b832c05a3164a72fdcaba342))
|
|
||||||
* **11-02:** update library-manager with per-library progress and Scan All button ([d61f122](https://github.com/onion-4-dinner/yellowjacket/commit/d61f122b567e8ac2b30fa96c637cbebc14493c89))
|
|
||||||
* **12-01:** add queue compaction method and wire removal hooks ([5995dfd](https://github.com/onion-4-dinner/yellowjacket/commit/5995dfd01d61cd4d2c0749eeeee2a1f93b739d68))
|
|
||||||
* **12-01:** implement library CRUD methods and orphan cleanup pipeline ([bd44f83](https://github.com/onion-4-dinner/yellowjacket/commit/bd44f8306c9129b9420ad81938bcf8105a1cb55a))
|
|
||||||
* **12-02:** make config sections collapsible with chevron dropdown ([12c6782](https://github.com/onion-4-dinner/yellowjacket/commit/12c678284c7582bd85cd52722f4d405b0bd0e20f))
|
|
||||||
* **12-02:** remove Libraries sidebar nav item and view routing ([e199712](https://github.com/onion-4-dinner/yellowjacket/commit/e199712a56e1cb3c0fc43d3340abb892a6f5fa7b))
|
|
||||||
* **12-02:** replace config-page library section with full library management UI ([ffc5d96](https://github.com/onion-4-dinner/yellowjacket/commit/ffc5d9639cf7c916a4f846590ae0d67cf13afe27))
|
|
||||||
* **12-02:** selectable library list with checkbox scan targeting ([13a42ae](https://github.com/onion-4-dinner/yellowjacket/commit/13a42aea2287d7ed0ec9ff9856f52c1fa7767338))
|
|
||||||
* **12-02:** show scan progress bar inline in library list entry ([df824c6](https://github.com/onion-4-dinner/yellowjacket/commit/df824c6989e92b2aefaa1ddf05b131ee319612d8))
|
|
||||||
* **13-01:** add library-filtered Go query methods and FTS search ([5f7de50](https://github.com/onion-4-dinner/yellowjacket/commit/5f7de5060a5bc557b96203267de694ef366ed507))
|
|
||||||
* **13-01:** add library-filtered sqlc queries for all browse views ([5cc58ce](https://github.com/onion-4-dinner/yellowjacket/commit/5cc58ce66ab70d8d5a570df5067f79ae2201037e))
|
|
||||||
* **13-02:** add library filter dropdown and wire all views to respect active filter ([42b8cf9](https://github.com/onion-4-dinner/yellowjacket/commit/42b8cf9f52133499ffcd7363bd39dd0c1069e091))
|
|
||||||
* **15-01:** migrate FTS5 search_index to contentless_delete=1 ([cb5155b](https://github.com/onion-4-dinner/yellowjacket/commit/cb5155b8906357ff77c5c579d57d02cf2eec6abe))
|
|
||||||
* **15-02:** create backend/fileutil package with AtomicWrite ([4d64b5d](https://github.com/onion-4-dinner/yellowjacket/commit/4d64b5dcfe43951e8ec63383bbf72c99107c63c4))
|
|
||||||
* **16-01:** add selectAll() to SelectionController and dispatch shortcut:select-all event ([f567762](https://github.com/onion-4-dinner/yellowjacket/commit/f5677628ef283b67370630b564f23178e43da3d2))
|
|
||||||
* **16-01:** wire shortcut:select-all listener in track-list, queue-panel, and playlist-view ([906ea28](https://github.com/onion-4-dinner/yellowjacket/commit/906ea28751ce9f96fdeeb9410ab5f6518f09fcb9))
|
|
||||||
* **16-02:** add go-flac dependencies and implement FLAC tag writer ([3642cbe](https://github.com/onion-4-dinner/yellowjacket/commit/3642cbe0d58f8912a786a4fc5380c40403add94a))
|
|
||||||
* **16-03:** implement DB sync module for tag write pipeline ([2966079](https://github.com/onion-4-dinner/yellowjacket/commit/2966079625cd42412411429af02184d015526e9b))
|
|
||||||
* **16-03:** WriteTrackTags pipeline with player safety, scan mutex, events, and app wiring ([64322f9](https://github.com/onion-4-dinner/yellowjacket/commit/64322f93538515d5a3e486dc14691b9c9dcf6f66))
|
|
||||||
* **17-01:** add TrackMetadataChanged handler and remove selection gate on Track Details ([fc5cf70](https://github.com/onion-4-dinner/yellowjacket/commit/fc5cf70e4c1be3d3f1545c140db5202601a08109))
|
|
||||||
* **17-01:** add WriteTrackTagsByPath and ImageFilePicker backend methods ([4235b4a](https://github.com/onion-4-dinner/yellowjacket/commit/4235b4a4d555882ce86628a88dd4e4eeee2c9097))
|
|
||||||
* **17-02:** implement save flow, cover art editing, and error handling ([265a9ea](https://github.com/onion-4-dinner/yellowjacket/commit/265a9ea8ceba893f956a03546e9ac4189adc7716))
|
|
||||||
* **18-01:** add BatchWriteProgress event constant ([3dba0e1](https://github.com/onion-4-dinner/yellowjacket/commit/3dba0e143c091327d305d39d2fa7a687ec47e172))
|
|
||||||
* **18-01:** add BatchWriteTrackTags with progress, cancellation, and partial failure ([f557ffd](https://github.com/onion-4-dinner/yellowjacket/commit/f557ffd652179b7cf8f8ff4a06824f30edf08007))
|
|
||||||
* **18-02:** add batch edit mode to track-details component ([6dab32b](https://github.com/onion-4-dinner/yellowjacket/commit/6dab32b36b497d54e8645e969aa79737ad3523ab))
|
|
||||||
* **18-02:** wire batch track-details to all 4 view context menus ([656985a](https://github.com/onion-4-dinner/yellowjacket/commit/656985add92663440baebb871f8cd6d5723117fd))
|
|
||||||
* **19-01:** implement WAV RIFF parser/writer and writeWavTags ([e6610ff](https://github.com/onion-4-dinner/yellowjacket/commit/e6610ff15e041213b6898ad48ff63b7060b312e7))
|
|
||||||
* **20-01:** implement OGG Vorbis tag writer with custom page parser and CRC32 ([5e98c03](https://github.com/onion-4-dinner/yellowjacket/commit/5e98c036342b9e174abdc6d00db21c2e2901f18b))
|
|
||||||
* **quick-17:** create playlist-details subpage component ([dc5c7d6](https://github.com/onion-4-dinner/yellowjacket/commit/dc5c7d6ca6cfbfac15546c048f1b33aaf47209c6))
|
|
||||||
* **quick-18:** replace track-info with multi-column grid layout in playlist-details ([ce23177](https://github.com/onion-4-dinner/yellowjacket/commit/ce2317722870f932792dc6456a63235ff4611466))
|
|
||||||
|
|
||||||
### Bug Fixes
|
Every release there is generated from the Conventional Commits it
|
||||||
|
contains, by `.gitea/workflows/release.yml` on merge to `main`. Each one
|
||||||
|
carries its notes as its body, grouped by change type, with a link to the
|
||||||
|
commit behind every line.
|
||||||
|
|
||||||
* **09-05:** emit VolumeChanged event and persist state in ChangeVolume and MuteToggle ([bb3fd20](https://github.com/onion-4-dinner/yellowjacket/commit/bb3fd204f0895f357a14479b40754f397aae74c4))
|
**This file is not generated and is not a copy of that.** `main` is a
|
||||||
* **10-01:** move library_id index to migration 6 to fix existing DB startup ([75b2a34](https://github.com/onion-4-dinner/yellowjacket/commit/75b2a349ebd6fada5cbc92bfae9854cc2cd53c63))
|
protected branch, so nothing pushes a changelog commit back to it — and a
|
||||||
* **12-02:** claim orphaned tracks when adding library with matching path ([f60b6b5](https://github.com/onion-4-dinner/yellowjacket/commit/f60b6b525546ef77a3329fe92f03f336b7435a0e))
|
file that claimed to be a changelog while silently never updating would
|
||||||
* **12-02:** count failed saves as skipped so scan progress bar advances ([b36e472](https://github.com/onion-4-dinner/yellowjacket/commit/b36e472212957ff089f4f5d35f3978a754e23502))
|
be worse than no file at all. `make release-dry` prints what the next
|
||||||
* **12-02:** delete artist_credit_artist before artist_credit in removal pipeline ([890284d](https://github.com/onion-4-dinner/yellowjacket/commit/890284ddb1d0fb95e423bddf27b40fb0db2d11e5))
|
merge would release.
|
||||||
* **12-02:** dismiss inline rename on click outside ([9272b06](https://github.com/onion-4-dinner/yellowjacket/commit/9272b060bf98118e37f19a8c0834034691bfe6a2))
|
|
||||||
* **12-02:** downgrade per-file save error to Debug, add warning count to scan summary ([cf18c39](https://github.com/onion-4-dinner/yellowjacket/commit/cf18c39dbd849d60218228cf1d2285ab2071e788))
|
|
||||||
* **12-02:** invalidate library store cache on LibraryRemoved event ([b093fbb](https://github.com/onion-4-dinner/yellowjacket/commit/b093fbb10a24054c4ef62b0bd13f28d9bfe6f121))
|
|
||||||
* **12-02:** keep Add Library button visible during scan ([649e516](https://github.com/onion-4-dinner/yellowjacket/commit/649e516aa30090665e9f10e89c1ccce378e36b96))
|
|
||||||
* **12-02:** move Add Library button inline with scan buttons ([771345d](https://github.com/onion-4-dinner/yellowjacket/commit/771345dd9d3870b3a907e1cce09c7456ab7ccd85))
|
|
||||||
* **12-02:** move scan buttons above library list, default to none selected ([ba3f840](https://github.com/onion-4-dinner/yellowjacket/commit/ba3f840a28fe2c6ca40c558305814d29c233d6e0))
|
|
||||||
* **12-02:** refresh library track counts after scan completes ([1f872aa](https://github.com/onion-4-dinner/yellowjacket/commit/1f872aa005a9405d9bc1f64a4b1dd2f1f1d4a16c))
|
|
||||||
* **12-02:** reorder orphan cleanup to delete FK children before recordings ([1d735c3](https://github.com/onion-4-dinner/yellowjacket/commit/1d735c3a5f5a78996d6ddbe5c787adf040fe2f21))
|
|
||||||
* **12-02:** replace removed Scan() import with ScanAllLibraries() ([0559822](https://github.com/onion-4-dinner/yellowjacket/commit/05598224e4d5532d2e2a3a7e5d3b5411240b1024))
|
|
||||||
* **12-02:** resolve phantom tracks caused by empty library root after TOML cleanup ([717e249](https://github.com/onion-4-dinner/yellowjacket/commit/717e249c368fd1cc8d5c8f945c352175708691cf))
|
|
||||||
* **12-02:** serialize ScanWarning.Err as string instead of error interface ([ac8cbb3](https://github.com/onion-4-dinner/yellowjacket/commit/ac8cbb3296bd561a305627668c211dce7209df25))
|
|
||||||
* **12-02:** soft scan claims orphaned library_id=0 tracks on startup ([1ad099a](https://github.com/onion-4-dinner/yellowjacket/commit/1ad099a9d35fc722475e238d3443fd5473566acd))
|
|
||||||
* **12-02:** soft scan on launch — only scan libraries with changed file counts ([92c4d23](https://github.com/onion-4-dinner/yellowjacket/commit/92c4d23a9a1e545fab497816ee3dce43a181cded))
|
|
||||||
* **12-02:** wait for scan to stop before library removal, surface errors in UI ([cf00498](https://github.com/onion-4-dinner/yellowjacket/commit/cf004986c95732d00208e83467267904ea3f2ef6))
|
|
||||||
* **13-02:** auto-resolve phantom playlist tracks after library scan ([93262b9](https://github.com/onion-4-dinner/yellowjacket/commit/93262b9ae0f737d2893839ac585776207b3b44b6))
|
|
||||||
* **13-02:** defer virtualizer event delegation until element exists ([f05d2bb](https://github.com/onion-4-dinner/yellowjacket/commit/f05d2bb603f5ea827164466fd0795a6c6e662529))
|
|
||||||
* **13-02:** resolve phantom playlist tracks using M3U8 paths after scan ([9f595b7](https://github.com/onion-4-dinner/yellowjacket/commit/9f595b7ac10c2191b5469004901cbbc1331c1abb))
|
|
||||||
* **14-01:** downgrade main-panel from contain:strict to layout+style+paint ([4b7d35d](https://github.com/onion-4-dinner/yellowjacket/commit/4b7d35d7ec4c8b14453a8f8250cd154b8c4c2537))
|
|
||||||
* **14-perf:** fix scroll jumping and input latency ([3b2e189](https://github.com/onion-4-dinner/yellowjacket/commit/3b2e189e7d0e6d00393d087565190fd307774257))
|
|
||||||
* **17-02:** fix cover art replace and remove ([d7c2965](https://github.com/onion-4-dinner/yellowjacket/commit/d7c2965752ae0ac9009d00f2431d5919a24558b7))
|
|
||||||
* **17-02:** handle float64 numeric values from Wails JSON deserialization ([900db2e](https://github.com/onion-4-dinner/yellowjacket/commit/900db2e56cca254873a3a5a7a384008feac4211b))
|
|
||||||
* **17-02:** refresh cover art URLs after save ([8cd4914](https://github.com/onion-4-dinner/yellowjacket/commit/8cd4914842f61c0c6b49e0216c7816e201a3c94a))
|
|
||||||
* **17-02:** refresh track-details dialog data after successful save ([ffcdc41](https://github.com/onion-4-dinner/yellowjacket/commit/ffcdc41b0d4fad8ed428dbaa55f6cdd38c096822))
|
|
||||||
* **18-02:** add field labels above title/artist/album inputs in batch edit mode ([9df2d67](https://github.com/onion-4-dinner/yellowjacket/commit/9df2d6764a0b0566dda33cff675debea4a61dea8))
|
|
||||||
* **18-02:** add field labels to all track-details states (single/batch, read/edit) ([d430ad8](https://github.com/onion-4-dinner/yellowjacket/commit/d430ad884bfd38bea93389d8be730ff00388a7be))
|
|
||||||
* **19-01:** add album_artist TPE2 mapping to applyTextChanges ([8f4c4a0](https://github.com/onion-4-dinner/yellowjacket/commit/8f4c4a0c2b14eeeaeccb972a40addb11f3d65437))
|
|
||||||
* preserve scroll position in cached grid views ([54df917](https://github.com/onion-4-dinner/yellowjacket/commit/54df917ffdd69c4f7ffaeccf2d161261ca80d84e))
|
|
||||||
* **queue-panel:** set flow layout _itemSize to match actual track item height ([288d9de](https://github.com/onion-4-dinner/yellowjacket/commit/288d9deae22d437fcd7857b368827db7b62c24f6))
|
|
||||||
* **queue-panel:** suppress virtualizer scroll corrections during scrollbar drag ([0bd8cef](https://github.com/onion-4-dinner/yellowjacket/commit/0bd8cefa00dcae2f8bd9579de2aefd58e0a9e6c9))
|
|
||||||
* **quick-19:** multi-root path resolution for playlist M3U8 tracks ([9144ded](https://github.com/onion-4-dinner/yellowjacket/commit/9144dedc2742925dc252d491763b4f2929238d0e))
|
|
||||||
* **S21/T01:** fix all lint warnings and upgrade wsl to wsl_v5 ([f16157a](https://github.com/onion-4-dinner/yellowjacket/commit/f16157a2134cbeb1787ff851d4875d77f2f3f86b))
|
|
||||||
|
|
||||||
### Performance
|
History before `v0.0.1` is in `git log`. The versions before it were cut
|
||||||
|
by hand and are not on the releases page; the entries this file used to
|
||||||
* **12-02:** increase scan batch size from 50 to 300 ([21ea71e](https://github.com/onion-4-dinner/yellowjacket/commit/21ea71e2575d76258bd81d89ab8ac883aa3bed36))
|
hold were generated against a GitHub remote this project no longer has,
|
||||||
* **12-02:** skip FTS5 rebuild during library removal ([30f4461](https://github.com/onion-4-dinner/yellowjacket/commit/30f4461e6957e20d3dc607fa0886a75b5c21b3cf))
|
and every link in them was dead.
|
||||||
* **14-01:** add CSS containment to app shell layout boundaries ([efa06f7](https://github.com/onion-4-dinner/yellowjacket/commit/efa06f7edf1e4acdc3d8865cad264403257ae40d))
|
|
||||||
* **14-01:** add GPU promotion and containment to all scroll containers ([ac8a52e](https://github.com/onion-4-dinner/yellowjacket/commit/ac8a52e110f9f8ebdc3433b60594370352126a18))
|
|
||||||
* **14-02:** replace innerHTML navigation with view caching system ([ad91043](https://github.com/onion-4-dinner/yellowjacket/commit/ad9104374a628342e0ea30cf409ff43de2c2f86e))
|
|
||||||
* **14-03:** add notification batching to queue store and granular change tracking to library store ([d0c05dc](https://github.com/onion-4-dinner/yellowjacket/commit/d0c05dc1d43a4fe12cc07f3cff25375b08a74ba0))
|
|
||||||
* **14-03:** eliminate per-item closure allocation in scroll render paths ([2f7ed70](https://github.com/onion-4-dinner/yellowjacket/commit/2f7ed7030425ed0ebb7a1a186917a79a7b26b850))
|
|
||||||
* **14-04:** RAF-throttle scroll position saves and add overflow-anchor to queue panel ([6ca0b3c](https://github.com/onion-4-dinner/yellowjacket/commit/6ca0b3c5a84769af064ebe45a6eaac014d1a270a))
|
|
||||||
* auto-detect NVIDIA+Wayland for DMABuf workaround ([915591a](https://github.com/onion-4-dinner/yellowjacket/commit/915591aea962beb60da2e96ac0f57307f646f675))
|
|
||||||
* inline SVGs, memoize grid slices, batch store notifications ([a4eac39](https://github.com/onion-4-dinner/yellowjacket/commit/a4eac394cebefd29d0ebcb4b1e331444dcb8fbaf))
|
|
||||||
* reduce software rendering overhead for NVIDIA+Wayland ([199c910](https://github.com/onion-4-dinner/yellowjacket/commit/199c91013fd806f6aefce49357df8a32b46faaa0))
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
* **quick-17:** simplify playlist-view to navigate instead of expand ([955cd68](https://github.com/onion-4-dinner/yellowjacket/commit/955cd68be2dbf7a9071ef1c93084d687b59b6bd7))
|
|
||||||
|
|
||||||
## [1.2.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.1...v1.2.2) (2026-03-06)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* recover from go-mp3 seek panic on startup ([#86](https://github.com/onion-4-dinner/yellowjacket/issues/86)) ([2f9d9f8](https://github.com/onion-4-dinner/yellowjacket/commit/2f9d9f8508b90b6188fe894c282c5b8e330e8046))
|
|
||||||
|
|
||||||
## [1.2.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.0...v1.2.1) (2026-03-06)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **deps:** pin go-webview2 to v1.0.21 for Wails v2 compat ([25f0fe8](https://github.com/onion-4-dinner/yellowjacket/commit/25f0fe81560eeff36a0b2beb52ce1bdf13d5e122))
|
|
||||||
|
|
||||||
## [1.2.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.3...v1.2.0) (2026-03-06)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **02-02:** add ScanWarning type and reclassify scan errors as warnings ([e6866de](https://github.com/onion-4-dinner/yellowjacket/commit/e6866ded9dc0ea30ff942cd31b6c5ea3269e9584))
|
|
||||||
* **03-01:** create NewTestDB helper for in-memory SQLite test databases ([bae9d70](https://github.com/onion-4-dinner/yellowjacket/commit/bae9d70d23157ef4e79e60dd713d9a02ab63790b))
|
|
||||||
* **03-01:** extract shared applyPRAGMAs and add production PRAGMAs to NewDB ([d348815](https://github.com/onion-4-dinner/yellowjacket/commit/d34881530adda7fb75be84737798da46d17bfa8c))
|
|
||||||
* **06-01:** create track_metadata VIEW schema and migration 4 ([9c7e5a9](https://github.com/onion-4-dinner/yellowjacket/commit/9c7e5a96344a81bf132de487b4763f1dc3ff6df9))
|
|
||||||
* **06-02:** create Go→TypeScript event constant codegen tool ([3e9edd0](https://github.com/onion-4-dinner/yellowjacket/commit/3e9edd05e87395499ac24e456640d1f6d9b97f04))
|
|
||||||
* **06-03:** migrate lookupChunk to sqlc-generated LookupTrackMetaByPaths query ([2221a68](https://github.com/onion-4-dinner/yellowjacket/commit/2221a68459850a837c996c6e6d2bc95d41b20fb3))
|
|
||||||
* **08-01:** define design token CSS custom properties for icon sizes and type scale ([1444a66](https://github.com/onion-4-dinner/yellowjacket/commit/1444a66bb201ce5fdf16552a32bcd281089c64ed))
|
|
||||||
* **08-04:** apply design tokens to cover-grid, track-list, queue-panel, and detail components ([1303422](https://github.com/onion-4-dinner/yellowjacket/commit/1303422e69c27d528363900b3ca5287a48cc9f8e))
|
|
||||||
* **08-04:** convert sidebar em-based spacing to px and apply icon/type tokens ([aed90d7](https://github.com/onion-4-dinner/yellowjacket/commit/aed90d7b1710d0c5cece2e4956c0a6ce77b9a999))
|
|
||||||
* add scan progress bar with phase indicator ([a28b4d1](https://github.com/onion-4-dinner/yellowjacket/commit/a28b4d1e0673658824750d4c702359321dc9a78e))
|
|
||||||
* **quick-001:** add multi-file picker and batch import support ([c34e4ad](https://github.com/onion-4-dinner/yellowjacket/commit/c34e4ad029c119bff8f70a07ccc6bca58b11ea3c))
|
|
||||||
* **quick-001:** regenerate bindings and update frontend for multi-import ([2a542bf](https://github.com/onion-4-dinner/yellowjacket/commit/2a542bf3bcdc7772edb1aceb41f488774494f656))
|
|
||||||
* **quick-002:** add CountPlaylistsByName SQL query and regenerate sqlc ([04b2088](https://github.com/onion-4-dinner/yellowjacket/commit/04b2088b28b84a4d4df25b23d97112c5a955dff1))
|
|
||||||
* **quick-002:** add uniquePlaylistName helper and wire into ImportPlaylist ([8ba8bbe](https://github.com/onion-4-dinner/yellowjacket/commit/8ba8bbe7bed2ecff97613ebaa42a49a662050353))
|
|
||||||
* **quick-006:** remove list icon from playlists, add favorites icon to default ([3c19766](https://github.com/onion-4-dinner/yellowjacket/commit/3c19766fd0885d4171cf9929db6d69a3d5c1a3ff))
|
|
||||||
* **quick-11:** add configurable log level via YJ_LOG_LEVEL env var ([55b4902](https://github.com/onion-4-dinner/yellowjacket/commit/55b4902fac7b7f2c04ad5efac398ecedc5fedc2f))
|
|
||||||
* **quick-11:** add make dev-debug target for verbose logging ([c45bca4](https://github.com/onion-4-dinner/yellowjacket/commit/c45bca411ba1d4f32deea6027acf91237173dd15))
|
|
||||||
* **quick-12:** add favorite icon to album dropdown track rows ([12a0bbc](https://github.com/onion-4-dinner/yellowjacket/commit/12a0bbc89c19128485d597a61bd16bd0786450ad))
|
|
||||||
* **quick-15:** add BufferedStreamer with goroutine read-ahead ([85b23ac](https://github.com/onion-4-dinner/yellowjacket/commit/85b23acb24a048d2f7b85808e477bb991ae124e6))
|
|
||||||
* **quick-15:** insert BufferedStreamer into player pipeline and increase speaker buffer ([8a0b16a](https://github.com/onion-4-dinner/yellowjacket/commit/8a0b16a4ec08a95bfd3834c8216e21dce854432d))
|
|
||||||
* **quick-3:** add playlist-level multi-select state and selection handling ([e13151f](https://github.com/onion-4-dinner/yellowjacket/commit/e13151ffa5dc86e41ce242421679d65a740c3af0))
|
|
||||||
* **quick-3:** wire playlist context menu for batch delete of selected playlists ([c92ced2](https://github.com/onion-4-dinner/yellowjacket/commit/c92ced2c74e72bfc123c880c047462dc969cde34))
|
|
||||||
* **quick-4:** add 'Set as Default Playlist' context menu option ([9971b63](https://github.com/onion-4-dinner/yellowjacket/commit/9971b635b81fe3f8621c80a6664eccb3e1fc4bb8))
|
|
||||||
* **quick-5:** add CreatedAt/UpdatedAt to playlist Summary struct ([bdaff47](https://github.com/onion-4-dinner/yellowjacket/commit/bdaff478e802ee5c0745327c52dd9b190fcfef7d))
|
|
||||||
* **quick-5:** add sort dropdown UI and client-side sorting to playlist view ([5c07485](https://github.com/onion-4-dinner/yellowjacket/commit/5c074855351f1363cc7918837a78bbd3c0b7ebf5))
|
|
||||||
* **quick-7:** add PinDefault config field with backend getter/setter ([6e123bd](https://github.com/onion-4-dinner/yellowjacket/commit/6e123bd47f55e6d565f20bf7f19950e65f80787f))
|
|
||||||
* **quick-7:** wire frontend pin-default-playlist feature end-to-end ([e6378e1](https://github.com/onion-4-dinner/yellowjacket/commit/e6378e1f0d3b0f2a7604b8ef6097dba9050cdd16))
|
|
||||||
* **quick-8:** add FindDuplicateTracksInPlaylist backend method ([83de934](https://github.com/onion-4-dinner/yellowjacket/commit/83de934c39ca7d850a8b5925c90e6d0b3fe0a487))
|
|
||||||
* **quick-8:** create duplicate-tracks-dialog component ([9f3ba2b](https://github.com/onion-4-dinner/yellowjacket/commit/9f3ba2b9d474fa30dcb4934b01d4650e0d0d3cba))
|
|
||||||
* **quick-8:** wire duplicate detection into playlist-picker and playlist-view ([917a79a](https://github.com/onion-4-dinner/yellowjacket/commit/917a79a8d6e30dddd2170323bb26692386794872))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **01-01:** add mutex protection to Queue, Library, and Playlist SetContext methods ([daaa6b7](https://github.com/onion-4-dinner/yellowjacket/commit/daaa6b7f9779385979fe9dddae4e7bb388b3e5fb))
|
|
||||||
* **01-01:** collapse Player.SetContext double-lock into single acquisition ([3abaeba](https://github.com/onion-4-dinner/yellowjacket/commit/3abaeba3afb0f4d0edb81e26ca55b31bf59990ac))
|
|
||||||
* **02-01:** eliminate package-level startupErr and fix config file permissions ([2a86408](https://github.com/onion-4-dinner/yellowjacket/commit/2a864082017e489ffa086c136f1002277a77a7c4))
|
|
||||||
* **02-01:** log MPRIS callback errors instead of discarding them ([0860b2f](https://github.com/onion-4-dinner/yellowjacket/commit/0860b2fd4b2250da1eeb80c21f14fdf341697501))
|
|
||||||
* **08-02:** revert repeat() inside lit-virtualizer, restore .renderItem + .keyFunction ([72ef719](https://github.com/onion-4-dinner/yellowjacket/commit/72ef719ba70eeca0fa4bae47df092706f6fbaeed))
|
|
||||||
* drop+recreate contentless FTS5 index instead of DELETE ([8e9a616](https://github.com/onion-4-dinner/yellowjacket/commit/8e9a61603779eacbee7013b9bc760b315baf782a))
|
|
||||||
* **frontend:** reposition search indicator into toolbar and fix album cover art lookup ([a29137b](https://github.com/onion-4-dinner/yellowjacket/commit/a29137b2ba4c6b33ce9a5f868cbd6013e0e3b116))
|
|
||||||
* include full track metadata in GetAudioFilesByReleaseGroup query ([97f256d](https://github.com/onion-4-dinner/yellowjacket/commit/97f256d67f463d752f7adc5b400c4bf34eae1df1))
|
|
||||||
* **quick-10:** add migration 5 and fix entity cache for composite album key ([d43ba7b](https://github.com/onion-4-dinner/yellowjacket/commit/d43ba7bd0c7ace2a9ed71990a19498f8e9f90751))
|
|
||||||
* **quick-10:** update release_groups schema and queries for composite uniqueness ([999ab96](https://github.com/onion-4-dinner/yellowjacket/commit/999ab967beb9107a3f30ba287acbffad22f0b0de))
|
|
||||||
* **quick-13:** resolve lint issues in main source files ([e1a95e6](https://github.com/onion-4-dinner/yellowjacket/commit/e1a95e65a9f0f436b2e2d92befa9c881b6e8e430))
|
|
||||||
* **quick-14:** add roll-back-on-failure to queue index advancement ([2820de2](https://github.com/onion-4-dinner/yellowjacket/commit/2820de2510560fcd6d1015c18542d5ac30468247))
|
|
||||||
* **quick-9:** set fixed height on queue track items for stable virtualizer scroll ([ebde5e5](https://github.com/onion-4-dinner/yellowjacket/commit/ebde5e5a8bc4da8f40bef8f171c7ed86c213a336))
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
* **07-01:** add incremental persistence helpers for queue mutations ([cdd17db](https://github.com/onion-4-dinner/yellowjacket/commit/cdd17db27509908514c21517631306655a2b3bd7))
|
|
||||||
* **07-01:** eliminate redundant lookups in SetQueue Phase 2 ([ced58fe](https://github.com/onion-4-dinner/yellowjacket/commit/ced58fe6a93d6f220137562b8ff09ffc33c69266))
|
|
||||||
* **07-02:** defer eagerFetch to after DOM ready for instant app shell ([cd98ad6](https://github.com/onion-4-dinner/yellowjacket/commit/cd98ad6dc8c2e4e6e0f01a48099b0c0511bf5a98))
|
|
||||||
* **08-01:** add queueMicrotask coalescing to library store and debounce search input ([3bf66ed](https://github.com/onion-4-dinner/yellowjacket/commit/3bf66ed125ed55bfbde95b0bc973710c2f2243b8))
|
|
||||||
* **08-02:** migrate cover-grid, artists-view, and genres-view virtualizers to repeat() directive ([1c3514d](https://github.com/onion-4-dinner/yellowjacket/commit/1c3514da1d0491b9758d7a6f9f72d59ef78fc8ed))
|
|
||||||
* **08-02:** migrate track-list and queue-panel virtualizers to repeat() directive ([d2d7d8c](https://github.com/onion-4-dinner/yellowjacket/commit/d2d7d8c6ce22923772cae4858b02804d15f74bb7))
|
|
||||||
* **08-03:** optimize column rendering and apply classMap to queue-panel renderTrackItem ([62f41c2](https://github.com/onion-4-dinner/yellowjacket/commit/62f41c24910632b270f9f5765e20e48db4b95ec9))
|
|
||||||
* **08-03:** replace class string construction with classMap directive in renderTrackRow ([ad21027](https://github.com/onion-4-dinner/yellowjacket/commit/ad210278fc20729dc76390e6bba9bff050549046))
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
* **06-01:** consolidate search queries to use track_metadata VIEW ([9159b40](https://github.com/onion-4-dinner/yellowjacket/commit/9159b409dcd2afaa7dcc97bf5b0694edf85f06a4))
|
|
||||||
* **quick-14:** make playOrLoadCurrentTrack and playCurrentTrack return bool ([6eeddda](https://github.com/onion-4-dinner/yellowjacket/commit/6eeddda97669258cc5b7ba175a3c98d598a2871f))
|
|
||||||
|
|
||||||
## [1.1.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.2...v1.1.3) (2026-02-21)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* add typescript as explicit devDependency and auto-install frontend deps in setup ([#70](https://github.com/onion-4-dinner/yellowjacket/issues/70)) ([7316587](https://github.com/onion-4-dinner/yellowjacket/commit/73165877fa79656ab9bc6f60bd8e9e52d6be206c))
|
|
||||||
* use local tsc binary in pre-commit hook to avoid PATH issues ([#71](https://github.com/onion-4-dinner/yellowjacket/issues/71)) ([6079e55](https://github.com/onion-4-dinner/yellowjacket/commit/6079e558ff913d38c7f1c4aeb52cc09474c4ed20))
|
|
||||||
|
|
||||||
## [1.1.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.1...v1.1.2) (2026-02-15)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* r2 upload ([#69](https://github.com/onion-4-dinner/yellowjacket/issues/69)) ([0252466](https://github.com/onion-4-dinner/yellowjacket/commit/0252466f615b4e2fd9694790c6d311a9eac1ccf2))
|
|
||||||
|
|
||||||
## [1.1.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.0...v1.1.1) (2026-02-15)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **ci:** remove build-check job from CI workflow ([#66](https://github.com/onion-4-dinner/yellowjacket/issues/66)) ([42d3f45](https://github.com/onion-4-dinner/yellowjacket/commit/42d3f45d85afa694e9545997af3ff4ac814ad021))
|
|
||||||
|
|
||||||
## [1.1.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.3...v1.1.0) (2026-02-15)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **ci:** upload release artifacts to Cloudflare R2 ([#65](https://github.com/onion-4-dinner/yellowjacket/issues/65)) ([8985084](https://github.com/onion-4-dinner/yellowjacket/commit/89850848cbf7783e5c85348ff18f7cd11d60231a))
|
|
||||||
|
|
||||||
## [1.0.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.2...v1.0.3) (2026-02-15)
|
|
||||||
|
|
||||||
### ⚠ BREAKING CHANGES
|
|
||||||
|
|
||||||
* **deps:** update module github.com/evilmartians/lefthook to v2 (#61)
|
|
||||||
* **deps:** update actions/checkout action to v6 (#45)
|
|
||||||
* **deps:** update dependency vite to v7 (#53)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* resolve all lint errors and make linting a required CI check ([#62](https://github.com/onion-4-dinner/yellowjacket/issues/62)) ([30b2480](https://github.com/onion-4-dinner/yellowjacket/commit/30b2480df49f57878b0e8c923da6ad8d6fe99416))
|
|
||||||
* virtual list and cover grid ([#63](https://github.com/onion-4-dinner/yellowjacket/issues/63)) ([7579a76](https://github.com/onion-4-dinner/yellowjacket/commit/7579a768be84225ed46db4e7a90781f3e30e2953))
|
|
||||||
|
|
||||||
### Miscellaneous
|
|
||||||
|
|
||||||
* **deps:** update actions/checkout action to v6 ([#45](https://github.com/onion-4-dinner/yellowjacket/issues/45)) ([2d6e221](https://github.com/onion-4-dinner/yellowjacket/commit/2d6e22105d2daed1dc5b586c0442e2941949a165))
|
|
||||||
* **deps:** update dependency vite to v7 ([#53](https://github.com/onion-4-dinner/yellowjacket/issues/53)) ([f0006c4](https://github.com/onion-4-dinner/yellowjacket/commit/f0006c4c4335b60b58cccdd29de4792965e39694))
|
|
||||||
* **deps:** update module github.com/evilmartians/lefthook to v2 ([#61](https://github.com/onion-4-dinner/yellowjacket/issues/61)) ([e32b217](https://github.com/onion-4-dinner/yellowjacket/commit/e32b2179129ae7f26037697a125710ff7587566d))
|
|
||||||
|
|
||||||
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
|
|
||||||
|
|
||||||
### ⚠ BREAKING CHANGES
|
|
||||||
|
|
||||||
* **deps:** update actions/setup-node action to v6 (#48)
|
|
||||||
* **deps:** update dependency stylelint-config-standard to v40 (#52)
|
|
||||||
* **deps:** update dependency node to v24 (#51)
|
|
||||||
* **deps:** update dependency vite-plugin-static-copy to v3 (#54)
|
|
||||||
* **deps:** update golangci/golangci-lint-action action to v9 (#55)
|
|
||||||
* **deps:** update amannn/action-semantic-pull-request action to v6 (#50)
|
|
||||||
* **deps:** update actions/upload-artifact action to v6 (#49)
|
|
||||||
* **deps:** update actions/setup-go action to v6 (#47)
|
|
||||||
* **deps:** update actions/download-artifact action to v7 (#46)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **ci:** use allowedPostUpgradeCommands for Renovate post-upgrade tasks ([#60](https://github.com/onion-4-dinner/yellowjacket/issues/60)) ([0aef483](https://github.com/onion-4-dinner/yellowjacket/commit/0aef483b3cccd0616fd5be2d06d0856b46851d09))
|
|
||||||
|
|
||||||
### Miscellaneous
|
|
||||||
|
|
||||||
* **deps:** update actions/download-artifact action to v7 ([#46](https://github.com/onion-4-dinner/yellowjacket/issues/46)) ([1910f99](https://github.com/onion-4-dinner/yellowjacket/commit/1910f99cf64e9bdc5ce91e89cab254ecca15d030))
|
|
||||||
* **deps:** update actions/setup-go action to v6 ([#47](https://github.com/onion-4-dinner/yellowjacket/issues/47)) ([8911fb2](https://github.com/onion-4-dinner/yellowjacket/commit/8911fb2400047cf2f3dfa719edc1d1bf474cdaa5))
|
|
||||||
* **deps:** update actions/setup-node action to v6 ([#48](https://github.com/onion-4-dinner/yellowjacket/issues/48)) ([d7382fd](https://github.com/onion-4-dinner/yellowjacket/commit/d7382fd8444b6618dbfe991f5f97231528a07f13))
|
|
||||||
* **deps:** update actions/upload-artifact action to v6 ([#49](https://github.com/onion-4-dinner/yellowjacket/issues/49)) ([a2c644b](https://github.com/onion-4-dinner/yellowjacket/commit/a2c644b00eed83acc0ed38a2eb8c73868b7b79af))
|
|
||||||
* **deps:** update amannn/action-semantic-pull-request action to v6 ([#50](https://github.com/onion-4-dinner/yellowjacket/issues/50)) ([643ba27](https://github.com/onion-4-dinner/yellowjacket/commit/643ba27f066164aeb47e8d9aaf20fe98b9b69d30))
|
|
||||||
* **deps:** update dependency node to v24 ([#51](https://github.com/onion-4-dinner/yellowjacket/issues/51)) ([e7d3971](https://github.com/onion-4-dinner/yellowjacket/commit/e7d39711078ce86b0c029f0d03ff81162c5dc28a))
|
|
||||||
* **deps:** update dependency stylelint-config-standard to v40 ([#52](https://github.com/onion-4-dinner/yellowjacket/issues/52)) ([422aabc](https://github.com/onion-4-dinner/yellowjacket/commit/422aabcc07e9700ff189302b363e13d87c69163a))
|
|
||||||
* **deps:** update dependency vite-plugin-static-copy to v3 ([#54](https://github.com/onion-4-dinner/yellowjacket/issues/54)) ([77fa643](https://github.com/onion-4-dinner/yellowjacket/commit/77fa6435a5298f58ef83607d99c59b876132c66c))
|
|
||||||
* **deps:** update golangci/golangci-lint-action action to v9 ([#55](https://github.com/onion-4-dinner/yellowjacket/issues/55)) ([aedb7d1](https://github.com/onion-4-dinner/yellowjacket/commit/aedb7d1e6d204c56c468dd26b340752fd6bfeaeb))
|
|
||||||
|
|
||||||
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* resolve Renovate repo detection and pre-push hook hang ([#36](https://github.com/onion-4-dinner/yellowjacket/issues/36)) ([b205889](https://github.com/onion-4-dinner/yellowjacket/commit/b205889128f01e9eb75b607cf7c4034887cda3f4))
|
|
||||||
|
|
||||||
## 1.0.0 (2026-02-14)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
|
|
||||||
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
|
|
||||||
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
|
|
||||||
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
|
|
||||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
|
||||||
* rename downloaded artifacts to platform-specific names for release ([e3bda0e](https://github.com/onion-4-dinner/yellowjacket/commit/e3bda0e2fc7700fad382cabe00aeb46f91fbb0a0))
|
|
||||||
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
|
|
||||||
* trigger build workflow from release event instead of tag push ([47772f7](https://github.com/onion-4-dinner/yellowjacket/commit/47772f73cc04093c55414bf20ebe2ef442418d19))
|
|
||||||
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
|
|
||||||
|
|
||||||
## 1.0.0 (2026-02-14)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
|
|
||||||
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
|
|
||||||
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
|
|
||||||
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
|
|
||||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
|
||||||
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
|
|
||||||
* trigger build workflow from release event instead of tag push ([47772f7](https://github.com/onion-4-dinner/yellowjacket/commit/47772f73cc04093c55414bf20ebe2ef442418d19))
|
|
||||||
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
|
|
||||||
|
|
||||||
## [1.0.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.2...v1.0.3) (2026-02-14)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
|
|
||||||
|
|
||||||
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
|
|
||||||
|
|
||||||
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
|
|
||||||
|
|
||||||
## 1.0.0 (2026-02-14)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
|
|
||||||
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
|
|
||||||
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
|
|
||||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
|
||||||
|
|
||||||
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
|
|
||||||
|
|
||||||
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
|
|
||||||
|
|
||||||
## 1.0.0 (2026-02-14)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
|
|
||||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
|
||||||
|
|
||||||
## 1.0.0 (2026-02-14)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
|
|
||||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
|
||||||
|
|
||||||
## 1.0.0 (2026-02-14)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
|
||||||
|
|
||||||
## 1.0.0 (2026-02-14)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
|
||||||
|
|||||||
@@ -233,6 +233,87 @@ rather than renaming them.
|
|||||||
the drift it caused before — `sql/schemas/` and the migrations
|
the drift it caused before — `sql/schemas/` and the migrations
|
||||||
disagreed, and sqlc generated against the stale one.
|
disagreed, and sqlc generated against the stale one.
|
||||||
|
|
||||||
|
**What that costs an existing database is repaired once, at open.**
|
||||||
|
`CREATE ... IF NOT EXISTS` reaches an existing table only if its shape
|
||||||
|
already matches and otherwise silently no-ops, so a *changed* table
|
||||||
|
never migrates. Plan 014 added `total_tracks` to `explore_index` and
|
||||||
|
to `indexRowFields` — the projection every explore read uses — and no
|
||||||
|
database that already existed grew the column: **every** Explore
|
||||||
|
search, browse, artist and album page on such an install failed with
|
||||||
|
`no such column: total_tracks`, while a fresh install was perfectly
|
||||||
|
healthy, which is exactly why no test saw it. Plan 013 was worse on
|
||||||
|
the same install: `applySchema` could not be applied at all over a
|
||||||
|
pre-013 `audio_files`, so the app did not open.
|
||||||
|
|
||||||
|
`backend/database/staleshape.go` runs before `applySchema` and
|
||||||
|
retires what is stale, so the create is a create. Five things about
|
||||||
|
it are load-bearing:
|
||||||
|
- **It parses `sql/schemas/` for the expectation** rather than
|
||||||
|
writing the column list down a second time, because a second list
|
||||||
|
is a second thing to forget — the fault it exists to repair.
|
||||||
|
- **It notices a changed *type*, not just a missing column.** 013
|
||||||
|
moved `mbid` from TEXT to BLOB, and SQLite does not coerce between
|
||||||
|
them: a comparison against 16 raw bytes returns no rows rather than
|
||||||
|
an error. `ALTER TABLE ADD COLUMN` would have handled
|
||||||
|
`total_tracks` alone and cannot express this at all, which is why
|
||||||
|
the repair drops rather than migrates.
|
||||||
|
- **`Authored` is never retired**, and that boundary is a test
|
||||||
|
(`TestAuthoredTablesAreNeverRetired`), not a comment. Everything
|
||||||
|
else is rebuildable: `Cache` by definition, `Owned` by a rescan —
|
||||||
|
plan 013's stated "delete and rescan" — and `Derived` from Owned.
|
||||||
|
A table the schema no longer describes at all goes too; 013 left
|
||||||
|
seven behind plus `schema_migrations`.
|
||||||
|
- **Whether a stale `Cache` table may be rebuilt is a build tag**, and
|
||||||
|
it is the most expensive thing in this file to get wrong. In the app
|
||||||
|
the catalog is *downloaded*, so a wrong shape costs a minute of
|
||||||
|
re-fetching the artifact and keeping it costs every Explore read. In
|
||||||
|
`cmd/indexbuild` the catalog is *derived*, and the only way back is
|
||||||
|
the ~205 GB dump stream the `/cache` volume exists to avoid — so
|
||||||
|
`retireStaleCache` is false there (`staleshape_policy_indexbuild.go`)
|
||||||
|
and `TestTheCatalogSurvivesAStaleShape` fails the moment it is not.
|
||||||
|
`TestNoCacheTableIsRetiredHere` is the same assertion made of *every*
|
||||||
|
`datamap` Cache table rather than one, because the risk is not that
|
||||||
|
shape recurring — it is the next destructive repair added to
|
||||||
|
`database.NewDB`, the chokepoint every binary here shares, without
|
||||||
|
asking which binary it is in.
|
||||||
|
This is written down because it already happened: the repair shipped
|
||||||
|
without the distinction and dropped the real CI catalog on its first
|
||||||
|
run, with `reason="column entity_type is TEXT, schema declares
|
||||||
|
INTEGER"`. The mismatch was genuine — that database is deliberately
|
||||||
|
kept in the older encoding, which `fix(indexexport): read an index
|
||||||
|
older than the binary` exists to tolerate — so it would have been
|
||||||
|
dropped on *every* run. The consequence is that a future
|
||||||
|
`explore_index` column fails the index job loudly on `applySchema`
|
||||||
|
rather than silently costing it a rebuild, which is the trade a
|
||||||
|
human should get to make.
|
||||||
|
- **The drops are one transaction with `defer_foreign_keys`.** Those
|
||||||
|
legacy tables reference each other, so dropping them in any order
|
||||||
|
fails on whichever goes first, and turning foreign keys *off*
|
||||||
|
instead would silently take `playlist_tracks.audio_file_id`'s
|
||||||
|
ON DELETE SET NULL with it — leaving playlist entries pointing at
|
||||||
|
ids a rescan reissues to *different songs*. Nulled entries are
|
||||||
|
empty; stale ones are wrong, and wrong quietly.
|
||||||
|
- **The order is sorted, so a failure reproduces.** Map order is
|
||||||
|
random, and the foreign-key bug above passed its own regression
|
||||||
|
test on two runs in three until the order was fixed.
|
||||||
|
|
||||||
|
Retiring `explore_index` takes its FTS and its meta with it, because
|
||||||
|
the `dump_import_done` marker is what would otherwise stop the
|
||||||
|
artifact ever being fetched again.
|
||||||
|
|
||||||
|
**What that costs an existing database is that it does not open**, and
|
||||||
|
"delete and rescan" is the answer (plan 013, open question 1) — free
|
||||||
|
for everyone except one machine. The index job's `/cache` volume is a
|
||||||
|
real `YJ_HOME` that survives between runs, and half of it is the
|
||||||
|
catalog: deleting it means re-downloading ~205 GB. So `cmd/indexbuild`
|
||||||
|
repairs it instead (`staleschema.go`), dropping every table `datamap`
|
||||||
|
does not classify as `Cache` **before** the schema is applied. Nothing
|
||||||
|
scans, plays or authors in that database, so its non-catalog half is
|
||||||
|
empty by construction and a shape left over from an older schema is
|
||||||
|
pure liability. 013's reshaped `audio_files` failed every run of that
|
||||||
|
job on `CREATE INDEX ... album_id` against the old table until this;
|
||||||
|
`TestRetireLibraryTables` reproduces exactly that, symptom first.
|
||||||
|
|
||||||
**The local library is shaped like files, not like MusicBrainz.**
|
**The local library is shaped like files, not like MusicBrainz.**
|
||||||
`audio_files` carries its own tags — title, artist credit, track and
|
`audio_files` carries its own tags — title, artist credit, track and
|
||||||
disc numbers, year, composer, the recording MBID — and points at two
|
disc numbers, year, composer, the recording MBID — and points at two
|
||||||
@@ -330,7 +411,26 @@ rather than renaming them.
|
|||||||
came about.
|
came about.
|
||||||
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
|
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
|
||||||
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
|
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
|
||||||
- `mediacontrols` — MPRIS integration on Linux via D-Bus.
|
- `mediacontrols` — OS media controls behind one `Handler`: MPRIS over
|
||||||
|
D-Bus on desktop Linux, a MediaSession on Android, a no-op stub
|
||||||
|
elsewhere. The split is by build tag and `android` implies `linux`,
|
||||||
|
so the three files read `linux && !android`, `android` and `!linux`.
|
||||||
|
Its Android half needs no JNI beyond what Wails exports — a JSON
|
||||||
|
payload out through `application.Android.StartForegroundService`, a
|
||||||
|
command event back through `WailsBridge.emitEvent` — and the Java it
|
||||||
|
talks to is `build/android/.../WailsForegroundService.java`. That
|
||||||
|
contract (payload keys, state words, command names) is in
|
||||||
|
`androidpayload.go` **without** the build tag, because a tagged file
|
||||||
|
is compiled by nothing `make lint` or `make test` runs and is
|
||||||
|
untestable off a phone.
|
||||||
|
|
||||||
|
`OnDuck` is the one callback MPRIS does not use: Android asks for
|
||||||
|
attenuation rather than a pause when something short needs the
|
||||||
|
output. `Player.SetDuck` keeps it as an offset on top of the user's
|
||||||
|
level rather than writing through to the volume, so it cannot
|
||||||
|
accumulate and nothing persists or emits a level the user did not
|
||||||
|
choose — and it only ever fires below API 26, where the framework
|
||||||
|
does not already duck the app itself.
|
||||||
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
|
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
|
||||||
- `explore` — Catalog search and browse over `explore_index`. See below.
|
- `explore` — Catalog search and browse over `explore_index`. See below.
|
||||||
Its **shelves** (`shelves.go`) are the page Explore shows before
|
Its **shelves** (`shelves.go`) are the page Explore shows before
|
||||||
@@ -476,6 +576,81 @@ selected as a literal `0`. Adding the column to the importer's SELECT
|
|||||||
list without that is how a published artifact — which nobody can re-cut
|
list without that is how a published artifact — which nobody can re-cut
|
||||||
retroactively — starts failing with `no such column`.
|
retroactively — starts failing with `no such column`.
|
||||||
|
|
||||||
|
**A credit is ordered parts, and the string is derived from them.** A
|
||||||
|
track credited to several artists had exactly one navigable artist and
|
||||||
|
the rest were punctuation: `primaryArtist()` string-parses the credit,
|
||||||
|
strips a " feat. " clause and discards the guest, and deliberately does
|
||||||
|
not split on `&`, `with` or `,` because those live inside real artist
|
||||||
|
names ("Simon & Garfunkel"). Measured on a real 26,069-file library,
|
||||||
|
**13%** of recordings are multi-artist upstream while only **0.86%** of
|
||||||
|
files carry a structured multi-artist tag — mp3 carries *zero* files
|
||||||
|
with multiple `MUSICBRAINZ_ARTISTID` across 19,840 — so this cannot be
|
||||||
|
a tag-parsing feature. (The "3 credits of 2,823" figure that justified
|
||||||
|
plan 013's removal of the credit tables measured our own *writer*:
|
||||||
|
`cachedLinkArtist` ran once per credit, so a collaboration could never
|
||||||
|
have been recorded. Dropping the join table was still right on cost.)
|
||||||
|
|
||||||
|
`artist_credit_part` / `artist_credit_ref` carry the decomposition for
|
||||||
|
multi-artist credits only — a single-artist credit is already
|
||||||
|
`explore_index`'s own `artist_name`, and storing those would triple the
|
||||||
|
table to say nothing. Five things about it are load-bearing:
|
||||||
|
|
||||||
|
- **Join phrases are assembly instructions, not disassembly ones.**
|
||||||
|
`creditLink` concatenates parts, so link boundaries are known by
|
||||||
|
construction. Locating a `credited_name` *inside* the stored credit
|
||||||
|
string would reintroduce the fault this exists to fix: that string may
|
||||||
|
come from the file's tags while the parts come from the catalog, and
|
||||||
|
the two disagree for ~1 in 3 multi-artist credits (`'Skrillex feat.
|
||||||
|
Swae Lee'` tagged against `'Skrillex & Swae Lee'` upstream).
|
||||||
|
- **`credited_name` is stored per row**, never joined from `artists`:
|
||||||
|
MusicBrainz credits "Snoop Dogg" on a track by the artist called
|
||||||
|
"Snoop Doggy Dogg". Display follows the credit, navigation the MBID.
|
||||||
|
- **The lookup is keyed on the recording MBID**, which the catalog and a
|
||||||
|
local file both carry (`library.Track.RecordingMBID`), so one binding
|
||||||
|
serves Explore and the library's own lists — which is why this needed
|
||||||
|
no local table. `file_artists` remains the offline-resilience step and
|
||||||
|
is deliberately *not* declared until something writes it.
|
||||||
|
- **Absence is cached as an answer.** `credit-store.ts` stores `[]` for
|
||||||
|
a single-artist credit — *asked*, not *answered* — or the ~87% that
|
||||||
|
have nothing to decompose are re-requested on every render forever.
|
||||||
|
`request()` is per-row and coalesces into one call per frame, because
|
||||||
|
a virtualized list cannot hand over "the whole list": 50,000 rows is
|
||||||
|
100 queries for the ~30 on screen.
|
||||||
|
- **The dump is a third source, and it had to be.** The canonical dump
|
||||||
|
CI already streams has no join phrases and no as-credited names, and
|
||||||
|
the JSON dumps cover 153,691 recordings of ~35M with *zero* overlap
|
||||||
|
against a real library. So `mbdump.tar.bz2` — 7.1 GB, ~13.7 min in
|
||||||
|
pure-Go bzip2, whose members are alphabetical, which is what lets one
|
||||||
|
pass resolve an entity's credit without buffering 35M recordings. The
|
||||||
|
pass runs on **every** mode, because a complete import means
|
||||||
|
`refresh`, which never enters the importer at all, and it reports
|
||||||
|
whether it populated anything so `changed` republishes the artifact.
|
||||||
|
|
||||||
|
**A 0.6 GB download asks about the connection first.** `explore`'s
|
||||||
|
catalog artifact had no network awareness at all, which on a phone is a
|
||||||
|
month's data allowance spent without being asked (plan 016 B4).
|
||||||
|
`netpolicy.go` is the gate, and its shape is dictated by one constraint:
|
||||||
|
`explore` is imported by `cmd/indexbuild`, which is built with
|
||||||
|
`CGO_ENABLED=0` and must not link Wails — so the *policy* and the
|
||||||
|
*parsing* live here and are tested on every platform, while the platform
|
||||||
|
call is a closure injected from `app.go`. It is
|
||||||
|
`application.Mobile.NetworkJSON()`, not `application.Android`'s: the
|
||||||
|
latter exists only under the `android` build tag, and `Mobile`'s desktop
|
||||||
|
implementation is a stub returning `""`.
|
||||||
|
|
||||||
|
Three rules in it are load-bearing. **An unknown answer is not a metered
|
||||||
|
one** — only mobile answers at all, so treating silence as metered would
|
||||||
|
refuse the download on every desktop. **Cellular is the only signal
|
||||||
|
available**: the runtime reports `wifi|cellular|ethernet|none` and no
|
||||||
|
metered flag, so a metered *Wi-Fi* (a hotspot, a hotel) cannot be
|
||||||
|
detected and is not refused, which is a documented gap rather than an
|
||||||
|
oversight. And **the gate runs before anything is staged**, so declining
|
||||||
|
is a no-op rather than a job in the indicator and a status the user has
|
||||||
|
to dismiss. The permission (`AllowMeteredCatalogDownload`, default
|
||||||
|
false, so an existing config is careful without a migration) is read at
|
||||||
|
the moment a download would start, so turning it on takes effect on the
|
||||||
|
next attempt rather than the next launch.
|
||||||
|
|
||||||
**Background work yields, and says so in the context.** The post-scan
|
**Background work yields, and says so in the context.** The post-scan
|
||||||
backfills share MusicBrainz's rate limiters with every page the user
|
backfills share MusicBrainz's rate limiters with every page the user
|
||||||
can open, and both were FIFO — so a thousand-artist enrichment put an
|
can open, and both were FIFO — so a thousand-artist enrichment put an
|
||||||
@@ -651,6 +826,27 @@ moment it is most needed is the likeliest moment loading one fails.
|
|||||||
`first-run-wizard` and the startup chrome are eager for the ordinary
|
`first-run-wizard` and the startup chrome are eager for the ordinary
|
||||||
reason — they are the first paint.
|
reason — they are the first paint.
|
||||||
|
|
||||||
|
**A navigation is a history entry, and that is the whole back stack.**
|
||||||
|
`index.ts` records each navigation with `pushState` (same URL — the app
|
||||||
|
has no routes, and a path a reload cannot resolve is worse than none)
|
||||||
|
and replays `popstate` with `_isBack`. It exists for Android, whose back
|
||||||
|
button is not a key the page can bind: the scaffold's
|
||||||
|
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
|
||||||
|
activity otherwise, so an app that never touched `history` quit from any
|
||||||
|
depth — which is what a device reported. Hooking the platform's own
|
||||||
|
mechanism rather than adding a JNI callback is also what makes it
|
||||||
|
testable in a browser (`page.goBack()`), and the Java half needed no
|
||||||
|
change at all.
|
||||||
|
|
||||||
|
Two rules hold it up. The **first** navigation *replaces* the launch
|
||||||
|
entry rather than pushing one, or every launch costs a back press before
|
||||||
|
the app will close. And the in-app back buttons (`navigate-back`, fired
|
||||||
|
by the detail views and `now-playing-view`) go through `history.back()`
|
||||||
|
rather than a stack of their own: the old `navStack` is **deleted**, not
|
||||||
|
kept beside it, because two stacks is precisely how a view's own back
|
||||||
|
button and the phone's gesture come to disagree about what one press
|
||||||
|
means.
|
||||||
|
|
||||||
**A primary view is cached, not unmounted.** `index.ts` keeps every
|
**A primary view is cached, not unmounted.** `index.ts` keeps every
|
||||||
primary view in the DOM and toggles a `.view-hidden` class, because that
|
primary view in the DOM and toggles a `.view-hidden` class, because that
|
||||||
is what preserves `scrollTop` across navigation — so
|
is what preserves `scrollTop` across navigation — so
|
||||||
@@ -806,6 +1002,20 @@ against the real components:
|
|||||||
moving focus without setting it leaves the highlight on whichever
|
moving focus without setting it leaves the highlight on whichever
|
||||||
item the mouse last touched.
|
item the mouse last touched.
|
||||||
|
|
||||||
|
**And a menu opens from a finger, through the event it already has.**
|
||||||
|
`utils/long-press.ts` is one document-capture listener installed once
|
||||||
|
from `index.ts`: a touch that holds still for 500 ms dispatches a
|
||||||
|
synthetic `contextmenu` at the touch point, so all six components that
|
||||||
|
bind one — delegated on a virtualizer, per row, per card — gained the
|
||||||
|
gesture without changing. The target is `composedPath()[0]` rather than
|
||||||
|
`elementFromPoint`, which stops at the outermost shadow host and so
|
||||||
|
reaches a delegated listener and no per-row one; a browser that fires
|
||||||
|
its own long-press `contextmenu` (Chromium does, WebKit and the WebView
|
||||||
|
vary) wins, ours being told from theirs by **identity** rather than
|
||||||
|
`isTrusted`, since no test can dispatch a trusted event; and the click
|
||||||
|
that ends the gesture is swallowed, keyed on the gesture rather than on
|
||||||
|
a time window so the first tap on the menu it opened is not eaten too.
|
||||||
|
|
||||||
Three lists had no focused row to open a menu *from* — the queue panel
|
Three lists had no focused row to open a menu *from* — the queue panel
|
||||||
and both playlist detail views — and gained a roving tab stop through
|
and both playlist detail views — and gained a roving tab stop through
|
||||||
`utils/roving-rows.ts`. **`track-list` deliberately does not use it**:
|
`utils/roving-rows.ts`. **`track-list` deliberately does not use it**:
|
||||||
@@ -976,6 +1186,65 @@ this app promises, no scrollbar appears. Note that `overflow: hidden`
|
|||||||
still permits *programmatic* scrolling, so a probe that sets
|
still permits *programmatic* scrolling, so a probe that sets
|
||||||
`scrollLeft` passes on the broken build; the spec uses a wheel gesture.
|
`scrollLeft` passes on the broken build; the spec uses a wheel gesture.
|
||||||
|
|
||||||
|
**Below 600px it reflows instead, and that is the phone.** The sideways
|
||||||
|
scroll above was the concession available while the shell had one
|
||||||
|
layout; plan 016 B2 gives it a second. Under 600px the grid drops its
|
||||||
|
sidebar column, `<bottom-nav>` takes over as the primary navigation,
|
||||||
|
the header's controls shrink or stand down, and the shell measures
|
||||||
|
exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now
|
||||||
|
asserts *nothing needs scrolling to*, which is what WCAG 1.4.10 wanted
|
||||||
|
all along. 600 rather than the sidebar's 900 because 900 is a laptop:
|
||||||
|
the answer there is a narrower sidebar, which is still a sidebar.
|
||||||
|
|
||||||
|
Three rules in it are load-bearing, and the second cost 30 specs.
|
||||||
|
|
||||||
|
**A grid item's implicit minimum is its content**, so one child that
|
||||||
|
insists on 580px makes the *body* 580px wide inside a 360px viewport
|
||||||
|
and `overflow-x: hidden` then hides a third of the app rather than
|
||||||
|
fitting it. Every box between the viewport and the content that must
|
||||||
|
shrink carries `min-width: 0`, and the things that cannot shrink say so
|
||||||
|
in their own stylesheet — `search-bar`'s 200px floor, `job-indicator`'s
|
||||||
|
label, `audio-player`'s seek bar and volume. A media query inside a
|
||||||
|
shadow root is answered by the viewport, so a component states what it
|
||||||
|
drops at phone width itself rather than the shell reaching in.
|
||||||
|
|
||||||
|
**A duplicated component duplicates its handles.** `bottom-nav`'s
|
||||||
|
"More" opens the *same* `<app-sidebar>` in a `wa-drawer` rather than
|
||||||
|
listing the destinations again — but rendering it unconditionally put a
|
||||||
|
second copy of every `data-testid="nav-*"` in the DOM, and 30 existing
|
||||||
|
specs failed with "strict mode violation: resolved to 2 elements" on a
|
||||||
|
desktop viewport where the element is not even visible. It renders only
|
||||||
|
while the drawer is open, and `bottom-nav.test.ts` asserts its absence
|
||||||
|
before that.
|
||||||
|
|
||||||
|
**The tab bar is four destinations and a way to the rest.** Three to
|
||||||
|
five is where touch targets stop being thumb-sized; eleven over 360px
|
||||||
|
is 32px each. Which four is plan 016's committed subset, and everything
|
||||||
|
else — Settings included, because a phone still needs it — is behind
|
||||||
|
"More".
|
||||||
|
|
||||||
|
**The phone section of `index.css` is last on purpose.** A media query
|
||||||
|
adds no specificity, so a `@media (max-width: 599px)` block placed
|
||||||
|
above the plain rules it overrides loses to them — which is how phase 1
|
||||||
|
shipped a header that kept its 2em gutters and 24px title on a 390px
|
||||||
|
phone with every declaration dead and nothing failing. The shell fitted
|
||||||
|
anyway, because the fitting is done by `min-width: 0` and by each
|
||||||
|
component's own media query, which live in their own stylesheets and
|
||||||
|
have no later rule to lose to. Cosmetic declarations are exactly what
|
||||||
|
no assertion sees; a screenshot found it.
|
||||||
|
|
||||||
|
**`<now-playing-view>` is where the seek bar and volume went.** It is a
|
||||||
|
*detail* view (`DETAIL_LOADERS`, so the nav stack carries the way out —
|
||||||
|
a tab you cannot leave by pressing again is not a tab), reached from a
|
||||||
|
phone-only button over the mini player's art, and it **composes the
|
||||||
|
real `<seek-bar>`, `<player-controls>` and `<volume-control>`** rather
|
||||||
|
than reimplementing them. While it is up, `index.css` hides the bottom
|
||||||
|
bar through `body:has(#main-content[data-active-view="now-playing"])` —
|
||||||
|
the active view is already published as an attribute, and a class
|
||||||
|
toggled from `index.ts` would be a second expression of the same fact.
|
||||||
|
The view therefore carries its own queue button, because that button
|
||||||
|
lives in the bar it hides.
|
||||||
|
|
||||||
**The playing row is a shape, not a hue.** `track-list` and
|
**The playing row is a shape, not a hue.** `track-list` and
|
||||||
`queue-panel` draw a `::before` triangle in each row's own left
|
`queue-panel` draw a `::before` triangle in each row's own left
|
||||||
padding, plus `aria-current` — before, both rows were a background tint
|
padding, plus `aria-current` — before, both rows were a background tint
|
||||||
@@ -1410,6 +1679,28 @@ by the three places that need them (the default widths, the
|
|||||||
normaliser, and the resize handles' positions), because they were
|
normaliser, and the resize handles' positions), because they were
|
||||||
written out separately and that is how they came to disagree.
|
written out separately and that is how they came to disagree.
|
||||||
|
|
||||||
|
**A phone draws one column of two lines, and that is a column set
|
||||||
|
rather than a second row template.** Measured on the device: at 424 px
|
||||||
|
the four configured columns fit the row *exactly* (`--grid-cols` came
|
||||||
|
out `24px 102px 101px 101px 80px`) and not one of them fit its content
|
||||||
|
— "Duration" did not fit its own header. The columns were never too
|
||||||
|
wide; there were too many of them. `PHONE_COLUMN_IDS` is `titleArtist`
|
||||||
|
(title over artist, sharing the row's whole width) plus the duration, so
|
||||||
|
the row, the delegated events, the selection semantics, the playing
|
||||||
|
marker and the virtualizer are all untouched: from their side only the
|
||||||
|
number of columns changed. Three rules come with it. **The row height
|
||||||
|
lives in two places and they must agree** — `PHONE_ROW_HEIGHT` and the
|
||||||
|
CSS rule — because the virtualizer positions rows from that number, so a
|
||||||
|
taller row overlaps its neighbour. **What is drawn and what can be
|
||||||
|
sorted are different questions**: the page header's sort list is built
|
||||||
|
from `configuredColumns`, or a phone (which has no column headers
|
||||||
|
either) could sort by nothing but title and duration. And **a phone's
|
||||||
|
widths are neither loaded nor saved**: `loadColumnWidths` is keyed by
|
||||||
|
column *id* and fills a gap with the minimum, so the stacked column —
|
||||||
|
which nothing can ever have saved a width for — came out at 148 px
|
||||||
|
beside a duration column of 236, and saving would have replaced the
|
||||||
|
width the user dragged on a desktop for the same id.
|
||||||
|
|
||||||
**The default columns are declared twice and must agree.**
|
**The default columns are declared twice and must agree.**
|
||||||
`tracklist.DefaultColumns` is what a fresh install persists;
|
`tracklist.DefaultColumns` is what a fresh install persists;
|
||||||
`DEFAULT_COLUMN_IDS` in `track-list/columns.ts` is what the list draws
|
`DEFAULT_COLUMN_IDS` in `track-list/columns.ts` is what the list draws
|
||||||
@@ -1701,6 +1992,21 @@ frontend would receive (`backend/queue/emit_test.go` is the model).
|
|||||||
`events.Deliver` is the same call returning an error instead of
|
`events.Deliver` is the same call returning an error instead of
|
||||||
dropping, and has one legitimate caller — `/__test/emit`.
|
dropping, and has one legitimate caller — `/__test/emit`.
|
||||||
|
|
||||||
|
**Naming the Wails application costs cgo, so exactly two files may.**
|
||||||
|
v3's `application` package is GTK/WebKit bindings on Linux, and
|
||||||
|
`cmd/indexbuild` / `cmd/indexexport` are built in a plain `golang`
|
||||||
|
container with `CGO_ENABLED=0` — the index workflow says so and it is
|
||||||
|
the one job that must not fail, since it owns the ~205 GB checkpoint.
|
||||||
|
So the single `app.Event.Emit` lives in `backend/events/runtime_wails.go`
|
||||||
|
under `//go:build !indexbuild` (with `runtime_indexbuild.go` returning
|
||||||
|
`ErrNoRuntime`, which is what the app itself returns before Run), and
|
||||||
|
`explore`'s `ServiceStartup` — the only other thing in that dependency
|
||||||
|
tree naming `application` — sits in `backend/explore/servicestartup.go`
|
||||||
|
under the same tag. `TestIndexToolsDoNotImportWails` walks the dependency
|
||||||
|
graph with `go list -deps -tags indexbuild` and is what keeps it that
|
||||||
|
way; a `ServiceStartup` hook added to a package the index tools import
|
||||||
|
is the way this comes back.
|
||||||
|
|
||||||
## Code Generation
|
## Code Generation
|
||||||
|
|
||||||
Two generators run via `go generate ./...` (or `make generate`):
|
Two generators run via `go generate ./...` (or `make generate`):
|
||||||
@@ -1721,13 +2027,31 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
|
|||||||
two in step or semantic-release will decline to release something the
|
two in step or semantic-release will decline to release something the
|
||||||
check accepted.
|
check accepted.
|
||||||
|
|
||||||
`.releaserc.yml` is a complete semantic-release config that **nothing
|
`.releaserc.yml` **is** what runs now, from `release.yml`, and it is why
|
||||||
currently runs** — no workflow invokes it, and `CHANGELOG.md` is not
|
the commit grammar is load-bearing rather than decorative: a merge to
|
||||||
being written by it. That is deliberate for now (wiring it means pushing
|
`main` whose commits are all `chore`/`ci`/`docs` releases nothing, and a
|
||||||
tags, committing a changelog back, and interacting with the three
|
mistyped `feat` ships a minor version. `make release-dry` answers "what
|
||||||
publish workflows); it is recorded here rather than implied, because
|
would this merge release" without pushing.
|
||||||
this file claimed for five phases that commitlint gated CI and that
|
|
||||||
semantic release ran, and neither was true.
|
**`@semantic-release/github` is not in that config and must not be.**
|
||||||
|
Gitea's API is `/api/v1` and is not GitHub's surface, so
|
||||||
|
`@semantic-release/exec` calls `scripts/gitea-release.sh` instead — one
|
||||||
|
`POST`, which is the whole of the Gitea-shaped work. The community
|
||||||
|
plugin (`@saithodev/semantic-release-gitea`) was considered and
|
||||||
|
rejected: last published 2022, on `got@10`, declaring no peer
|
||||||
|
dependency on semantic-release at all.
|
||||||
|
|
||||||
|
Two things in it fail *silently* and are therefore pinned with their
|
||||||
|
reasons. **The notes come from `CHANGELOG.md`, not from an argument**:
|
||||||
|
release notes are rendered commit messages — arbitrary text carrying
|
||||||
|
backticks, quotes and `$` — so templating `${nextRelease.notes}` into
|
||||||
|
`publishCmd` would be a shell injection whose input is the commit log.
|
||||||
|
And **`conventional-changelog-conventionalcommits` is held at 9**,
|
||||||
|
because at 10 it is quietly incompatible with the writer
|
||||||
|
`release-notes-generator@14` pulls in: every release note renders as a
|
||||||
|
bare `## 0.0.1 (date)` heading with no sections and no commits beneath
|
||||||
|
it, no step fails, and the release ships with an empty body. Check the
|
||||||
|
rendered notes, never the exit code.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -1736,14 +2060,97 @@ Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same
|
|||||||
|
|
||||||
## Git Workflow
|
## Git Workflow
|
||||||
|
|
||||||
Feature branches and PRs are the norm, but direct pushes to `main` are allowed. Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite.
|
Feature branches and PRs are the only way in: **`main` is a protected
|
||||||
|
branch** (`enable_push: false`, an empty push whitelist, and `CI / check*`
|
||||||
|
+ `CI / e2e*` as required status checks), so a direct push is rejected by
|
||||||
|
the pre-receive hook. This file said otherwise for a long time. Tags are
|
||||||
|
*not* protected, which is what lets `release.yml` push one.
|
||||||
|
|
||||||
|
Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite.
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
Four workflows in `.gitea/workflows/`. Three of them package and
|
Seven workflows in `.gitea/workflows/`. Five of them package and
|
||||||
publish (`arch-package`, `homebrew-formula`, `index-artifact`); only
|
publish (`arch-package`, `homebrew-formula`, `index-artifact`,
|
||||||
`ci.yml` gates, and it is the one to look at when deciding whether a
|
`android-apk`, `desktop-assets`); `release.yml` decides *whether* four of
|
||||||
push was healthy.
|
those run at all; only `ci.yml` gates, and it is the one to look at when
|
||||||
|
deciding whether a push was healthy.
|
||||||
|
|
||||||
|
**`release.yml` is the entry point for all of it.** On every push to
|
||||||
|
`main` it reads the Conventional Commits since the last tag and, if any
|
||||||
|
is releasable, writes the changelog, pushes the tag and creates the Gitea
|
||||||
|
release whose body is that changelog section. `arch-package`,
|
||||||
|
`homebrew-formula`, `android-apk` and `desktop-assets` are all keyed on
|
||||||
|
`v*`, so **the tag push is what starts them** — nothing is released by
|
||||||
|
hand any more.
|
||||||
|
|
||||||
|
Four things about it are load-bearing:
|
||||||
|
|
||||||
|
- **The tag is pushed with a user PAT, not the Actions token.** Gitea,
|
||||||
|
like GitHub, does not start a workflow from a ref pushed by a
|
||||||
|
workflow's own token (go-gitea#33123). The token is what decides this,
|
||||||
|
so `PACKAGE_TOKEN` is handed to semantic-release as the
|
||||||
|
`repositoryUrl` credential and the push is attributed to a person.
|
||||||
|
- **That same limitation is used deliberately, once.** semantic-release
|
||||||
|
calls the first release of a tagless repo `1.0.0` and offers no way to
|
||||||
|
say otherwise, so a `v0.0.0` floor tag is what makes the first release
|
||||||
|
`0.0.1` — and it is pushed with the *Actions* token precisely so it
|
||||||
|
triggers nothing. All four publishers additionally skip `v0.0.0`
|
||||||
|
explicitly, cleanly rather than by failing, because a floor is not a
|
||||||
|
shipment.
|
||||||
|
- **The release page is the changelog, and that follows from the branch
|
||||||
|
protection.** `@semantic-release/git` would push a `chore(release):`
|
||||||
|
commit back to `main`, which the pre-receive hook rejects — *after* the
|
||||||
|
tag had been pushed, leaving a tagged release the run then reports as
|
||||||
|
failed. Whitelisting the CI user was the alternative and was declined:
|
||||||
|
it weakens a protection someone set on purpose and lets a bot push to
|
||||||
|
`main` without the checks every human PR passes. So the plugin is
|
||||||
|
absent, `@semantic-release/changelog` writes to a gitignored
|
||||||
|
`.release-notes.md` purely to carry the notes into
|
||||||
|
`scripts/gitea-release.sh`, and `CHANGELOG.md` is a signpost to the
|
||||||
|
releases page rather than a file that would silently stop updating.
|
||||||
|
The workflow keeps its `chore(release):` guard anyway, for the day
|
||||||
|
someone adds the plugin back.
|
||||||
|
- **An asset upload waits for the release to exist.** semantic-release
|
||||||
|
pushes the tag in `prepare` and creates the release in `publish`, so
|
||||||
|
the tag push that starts these workflows happens *before* there is a
|
||||||
|
release id to attach to. `scripts/release-asset.sh` polls for it. The
|
||||||
|
capacity-1 runner serialises things enough that this would usually work
|
||||||
|
by accident, which is the worst kind of bug.
|
||||||
|
|
||||||
|
**Releases restarted at `0.0.1`, which is a downgrade on every channel.**
|
||||||
|
pacman and Homebrew both silently offer no upgrade from the old `1.x`,
|
||||||
|
and Android refuses the install outright — its remedy is an uninstall
|
||||||
|
that takes the user's library. This was chosen over pacman's `epoch` and
|
||||||
|
over offsetting `versionCode`, on the grounds that both are permanent and
|
||||||
|
a reinstall is once. `packaging/homebrew/README.md` and
|
||||||
|
`docs/android-release.md` say so where a user would look.
|
||||||
|
|
||||||
|
**`desktop-assets.yml` publishes Linux and nothing else, and macOS is not
|
||||||
|
an oversight.** `GOOS=darwin CGO_ENABLED=0` fails at
|
||||||
|
`wails/v3/pkg/mac: build constraints exclude all Go files` — the darwin
|
||||||
|
backend is Objective-C behind cgo, so a `.app` needs a macOS host and the
|
||||||
|
runner is a Linux container. That is exactly why the Homebrew formula
|
||||||
|
builds from source on the user's own Mac. Windows *does* cross-compile
|
||||||
|
cleanly (`GOOS=windows CGO_ENABLED=0`, a couple of seconds — oto uses
|
||||||
|
WinMM through `x/sys`, sqlite is modernc's pure-Go driver, WebView2 is
|
||||||
|
COM syscalls, MPRIS is `linux && !android`-tagged) and is deliberately
|
||||||
|
not published: no Windows build of this app has ever been *run*, and no
|
||||||
|
tier here can exercise one.
|
||||||
|
|
||||||
|
**`android-apk.yml` is the one that can lose something irrecoverable.** It builds the signed
|
||||||
|
`arm64-v8a` APK (the only ABI Android can run this app on — see
|
||||||
|
`app/build.gradle`) on every `v*` tag and publishes it to the *generic* registry, which is
|
||||||
|
readable without credentials — the reason Obtainium can poll a plain
|
||||||
|
URL. Android refuses to update an app whose signing certificate
|
||||||
|
changed, and the only remedy is an uninstall that takes the user's
|
||||||
|
library with it, so the job **refuses to build** without the keystore
|
||||||
|
secret rather than falling through to Gradle's debug-key default, and
|
||||||
|
**refuses to publish** an artifact whose certificate says `CN=Android
|
||||||
|
Debug`. It is deliberately not a job in `ci.yml`: that workflow runs on
|
||||||
|
every branch push, this one takes tens of minutes on a cold cache, and
|
||||||
|
the runner has capacity 1. `docs/android-release.md` is the operating
|
||||||
|
document.
|
||||||
|
|
||||||
Two jobs, both in an `ubuntu:24.04` container:
|
Two jobs, both in an `ubuntu:24.04` container:
|
||||||
|
|
||||||
@@ -1825,11 +2232,47 @@ four bit the packaging recipes:
|
|||||||
**`build/`'s platform metadata is generated from `build/config.yml`.**
|
**`build/`'s platform metadata is generated from `build/config.yml`.**
|
||||||
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
|
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
|
||||||
`.desktop` template, `nfpm.yaml` and the Windows manifest from that
|
`.desktop` template, `nfpm.yaml` and the Windows manifest from that
|
||||||
one file — so a hand edit to any of them is lost on the next refresh,
|
one file — so a hand edit to any of them is lost on the next refresh.
|
||||||
and the two fields it does *not* own (nfpm's `homepage` and `license`)
|
nfpm's `homepage` and `license` say in place that the refresh does not
|
||||||
say so in place. That refresh also regenerates `build/ios/` and
|
own them, and **that comment is wrong**: a refresh reset them to
|
||||||
`build/android/`, which this repo does not carry: they are gitignored
|
`https://wails.io` and `MIT`. Re-check those two after any refresh.
|
||||||
rather than deleted-and-rediscovered, and their `includes:` entries
|
|
||||||
are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the
|
**That refresh does not touch the mobile trees**, contrary to what this
|
||||||
|
file said for five phases. `update build-assets` extracts only
|
||||||
|
`updatable_build_assets` (darwin/ios/linux/windows); `build/android/`
|
||||||
|
and `build/ios/` come from `generate build-assets`, which rewrites the
|
||||||
|
whole of `build/`. So `build/android/` is **committed and hand-edited
|
||||||
|
like source** — it was generated once into a scratch directory and
|
||||||
|
copied across (plan 015), it carries one deliberate edit to its
|
||||||
|
`Taskfile.yml`, and only its output is gitignored. `build/ios/` is
|
||||||
|
still not carried and its `includes:` entry is still dropped.
|
||||||
|
**Its `MainActivity` owns the safe area, because `targetSdk 35` does
|
||||||
|
not leave that to the theme.** Android 15 lays every app out
|
||||||
|
edge-to-edge and ignores the `statusBarColor`/`navigationBarColor` the
|
||||||
|
scaffold's theme sets, and the WebView is `match_parent`, so the page's
|
||||||
|
bottom band — the transport and, on a phone, the tab bar — would be
|
||||||
|
drawn under the gesture bar. `applyWindowInsets()` pads the container by
|
||||||
|
`systemBars | displayCutout | ime` and returns the insets rather than
|
||||||
|
consuming them; the window background is black to match the app's own
|
||||||
|
ramp, since that padding is what shows through. It is **pre-emptive**:
|
||||||
|
the phone this was checked against is Android 14, where the system still
|
||||||
|
insets the window, and the enforcement applies to an app *running on*
|
||||||
|
15. No browser tier can see this class of fault either way — a viewport
|
||||||
|
has no system bars.
|
||||||
|
|
||||||
|
**And a device is an engine, not just a screen.** The phone this app was
|
||||||
|
first run on renders in **Chrome 113** — two years behind every browser
|
||||||
|
any other tier uses — at a 424x439 CSS px viewport. It has `:has()`,
|
||||||
|
`color-mix()` and `dialog.showModal()`; it does **not** have relaxed CSS
|
||||||
|
nesting (Chrome 120, so a nested rule beginning with a bare element
|
||||||
|
selector is silently dropped), the Popover API (114, which Web Awesome's
|
||||||
|
popups set `popover="manual"` for), `light-dark()` or relative colour
|
||||||
|
syntax. So "it renders at that size in Chromium" is not evidence about
|
||||||
|
the phone, and resizing a spec cannot recover the missing signal. `make
|
||||||
|
android-inspect` forwards the WebView's devtools socket and `make
|
||||||
|
android-eval` asks the real page — raw CDP, because `connectOverCDP`
|
||||||
|
calls `Browser.setDownloadBehavior` and a WebView refuses it.
|
||||||
|
|
||||||
|
`build/config.yml`'s `version` is the
|
||||||
*metadata* version and is not what the app reports — `main.version` is
|
*metadata* version and is not what the app reports — `main.version` is
|
||||||
stamped at link time from the packaging recipe's git-derived version.
|
stamped at link time from the packaging recipe's git-derived version.
|
||||||
|
|||||||
@@ -38,6 +38,64 @@ dev-stop: ## Stop the headless app (SIGTERM, so shutdown hooks run)
|
|||||||
dev-logs: ## Tail the headless app log
|
dev-logs: ## Tail the headless app log
|
||||||
@tail -f .dev/app.log
|
@tail -f .dev/app.log
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
# The Android tier. See .pi/skills/yellowjacket-dev/references/ #
|
||||||
|
# android-tier.md for which of these to reach for and why a failure #
|
||||||
|
# here looks like nothing at all. #
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
|
||||||
|
# The NDK is pinned: r26d is what the pipeline is built and checked
|
||||||
|
# against, and newer NDKs have broken Wails' Android build before.
|
||||||
|
# ANDROID_HOME must carry a *platform*, which Arch's /opt/android-sdk
|
||||||
|
# does not — hence the separate default.
|
||||||
|
ANDROID_SDK ?= $(HOME)/Android/Sdk
|
||||||
|
ANDROID_NDK ?= /opt/android-ndk
|
||||||
|
ANDROID_ENV := ANDROID_HOME=$(ANDROID_SDK) ANDROID_SDK_ROOT=$(ANDROID_SDK) ANDROID_NDK_HOME=$(ANDROID_NDK)
|
||||||
|
|
||||||
|
# `package`, not `package:fat`: x86_64 Android cannot run this app at
|
||||||
|
# all (modernc's raw lstat vs Android's seccomp -- see
|
||||||
|
# android-tier.md), so the second ABI was ~31 MB that could not run
|
||||||
|
# anywhere. app/build.gradle's abiFilters says the same thing to
|
||||||
|
# Gradle; both have to agree or the .so is built and then dropped.
|
||||||
|
android: build-frontend ## Build the arm64 APK into bin/
|
||||||
|
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package
|
||||||
|
|
||||||
|
android-setup: ## Install the SDK pieces and create the AVD (once, ~3.5GB)
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh setup
|
||||||
|
|
||||||
|
android-emulator: ## Boot the emulator headless in the background and wait for it
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh start
|
||||||
|
|
||||||
|
android-emulator-stop: ## Shut the emulator down (console kill, then saved PID)
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh stop
|
||||||
|
|
||||||
|
android-install: ## Install bin/yellowjacket.apk onto the running emulator
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh install
|
||||||
|
|
||||||
|
android-launch: ## Force-stop, clear logcat, and start the app
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh launch
|
||||||
|
|
||||||
|
android-logs: ## Tail logcat, filtered to the app's own tags
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh logs
|
||||||
|
|
||||||
|
# The only tier that can see the platform is the one you can look at.
|
||||||
|
android-screenshot: ## Grab the device screen (OUT=<path>)
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh screenshot $(OUT)
|
||||||
|
|
||||||
|
# The page's own answer, from the engine that is really rendering it.
|
||||||
|
# Needs the debug build installed (it is a sibling id, so it does not
|
||||||
|
# disturb the release app): see scripts/android-eval.mjs.
|
||||||
|
android-inspect: ## Forward the device WebView's devtools socket
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh inspect
|
||||||
|
|
||||||
|
android-eval: ## Evaluate JS in the device WebView (EXPR='...')
|
||||||
|
@node ./scripts/android-eval.mjs $(if $(EXPR),'$(EXPR)',)
|
||||||
|
|
||||||
|
# "Did it start" is the wrong question — a crash-looping app starts
|
||||||
|
# several times a second. This asserts the *same pid* is still there.
|
||||||
|
android-smoke: ## Launch and assert the app is still alive (SECONDS=<n>)
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh smoke $(if $(SECONDS),$(SECONDS),10)
|
||||||
|
|
||||||
# Seeds are produced by *running the app* — driving the real AddLibrary
|
# Seeds are produced by *running the app* — driving the real AddLibrary
|
||||||
# binding and waiting for the real scan — never by hand-writing a
|
# binding and waiting for the real scan — never by hand-writing a
|
||||||
# config.toml and DB rows. A hand-built seed is a second description
|
# config.toml and DB rows. A hand-built seed is a second description
|
||||||
@@ -121,10 +179,12 @@ bindings-check: ## Fail if the generated bindings are stale
|
|||||||
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
|
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
|
||||||
@cd frontend && node scripts/check-css-literals.mjs
|
@cd frontend && node scripts/check-css-literals.mjs
|
||||||
|
|
||||||
# .pi/ documents commands, and a skill that documents a command wrongly
|
# .pi/ and CLAUDE.md document commands, and a doc that documents a
|
||||||
# is worse than no skill: an agent runs it confidently. Every command
|
# command wrongly is worse than no doc: an agent runs it confidently.
|
||||||
# in there is a make target on purpose, so this is checkable.
|
# Every command in them is a make target on purpose, so this is
|
||||||
skill-check: ## Fail if .pi/ documents a make target that does not exist
|
# checkable. It also asserts AGENTS.md is a symlink to CLAUDE.md, so the
|
||||||
|
# two harnesses cannot drift onto two descriptions of one project.
|
||||||
|
skill-check: ## Fail if the agent docs name a missing make target, or AGENTS.md is not a symlink
|
||||||
@./scripts/skill-check.sh
|
@./scripts/skill-check.sh
|
||||||
|
|
||||||
# Conventional Commits, which CLAUDE.md claimed CI enforced for a long
|
# Conventional Commits, which CLAUDE.md claimed CI enforced for a long
|
||||||
@@ -132,6 +192,24 @@ skill-check: ## Fail if .pi/ documents a make target that does not exist
|
|||||||
commit-check: ## Fail if a commit subject is not a Conventional Commit
|
commit-check: ## Fail if a commit subject is not a Conventional Commit
|
||||||
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
|
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
|
||||||
|
|
||||||
|
# What a merge to main would release, without releasing it. Reads the
|
||||||
|
# same .releaserc.yml CI does, so "why did that not cut a version" is
|
||||||
|
# answerable locally instead of by pushing and watching. Needs no
|
||||||
|
# credentials: --dry-run neither tags nor publishes.
|
||||||
|
#
|
||||||
|
# The pins must stay identical to release.yml's, which is where the note
|
||||||
|
# on holding the conventionalcommits preset at 9 lives -- at 10 the
|
||||||
|
# release notes come out empty with everything green.
|
||||||
|
release-dry: ## Print the version a merge to main would release
|
||||||
|
@npx --yes \
|
||||||
|
-p semantic-release@25 \
|
||||||
|
-p @semantic-release/commit-analyzer@13 \
|
||||||
|
-p @semantic-release/release-notes-generator@14 \
|
||||||
|
-p @semantic-release/changelog@7 \
|
||||||
|
-p @semantic-release/exec@7 \
|
||||||
|
-p conventional-changelog-conventionalcommits@9 \
|
||||||
|
semantic-release --dry-run --no-ci
|
||||||
|
|
||||||
# v3 generates TypeScript into frontend/bindings/, nested by Go import
|
# v3 generates TypeScript into frontend/bindings/, nested by Go import
|
||||||
# path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the
|
# path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the
|
||||||
# constant prefix, so a call site imports '@go/library/library.js'.
|
# constant prefix, so a call site imports '@go/library/library.js'.
|
||||||
@@ -147,7 +225,7 @@ bindings: ## Regenerate frontend/bindings from the bound Go services
|
|||||||
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
|
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
|
||||||
perf perf-compare \
|
perf perf-compare \
|
||||||
ui-test ui-watch ui-visual ui-visual-update ui-setup \
|
ui-test ui-watch ui-visual ui-visual-update ui-setup \
|
||||||
bindings bindings-check skill-check commit-check
|
bindings bindings-check skill-check commit-check release-dry
|
||||||
|
|
||||||
# Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR:
|
# Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR:
|
||||||
# on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so
|
# on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ includes:
|
|||||||
windows: ./build/windows/Taskfile.yml
|
windows: ./build/windows/Taskfile.yml
|
||||||
darwin: ./build/darwin/Taskfile.yml
|
darwin: ./build/darwin/Taskfile.yml
|
||||||
linux: ./build/linux/Taskfile.yml
|
linux: ./build/linux/Taskfile.yml
|
||||||
|
android: ./build/android/Taskfile.yml
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -191,6 +191,24 @@ func NewYellowJacketApp(
|
|||||||
yjApp.library.SetJobRegistry(yjApp.jobs)
|
yjApp.library.SetJobRegistry(yjApp.jobs)
|
||||||
yjApp.explore.SetJobRegistry(yjApp.jobs)
|
yjApp.explore.SetJobRegistry(yjApp.jobs)
|
||||||
|
|
||||||
|
// Whether this connection is one to spend ~0.6 GB of catalog on
|
||||||
|
// (plan 016 B4). The probe is injected from here because `explore` is
|
||||||
|
// imported by `cmd/indexbuild`, which must not link Wails: naming
|
||||||
|
// `application` there is what `TestIndexToolsDoNotImportWails`
|
||||||
|
// forbids.
|
||||||
|
//
|
||||||
|
// `application.Mobile`, not `application.Android`: the latter exists
|
||||||
|
// only under the `android` build tag, while `Mobile` is the portable
|
||||||
|
// name whose desktop implementation is a stub returning "" — which
|
||||||
|
// parses to "unknown" and refuses nothing. Plan 016 named the tagged
|
||||||
|
// one; this is the same call by the name every build has.
|
||||||
|
yjApp.explore.SetNetworkPolicy(
|
||||||
|
func() explore.Network {
|
||||||
|
return explore.ParseNetworkJSON(application.Mobile.NetworkJSON())
|
||||||
|
},
|
||||||
|
yjApp.appConfig.GetAllowMeteredCatalogDownload,
|
||||||
|
)
|
||||||
|
|
||||||
// Let the release prefetch skip albums the user already owns in
|
// Let the release prefetch skip albums the user already owns in
|
||||||
// full — those open with no catalog call at all, so warming their
|
// full — those open with no catalog call at all, so warming their
|
||||||
// tracklists spends the most expensive request in the app on
|
// tracklists spends the most expensive request in the app on
|
||||||
@@ -484,20 +502,24 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
// Register playback finished handler to drive queue auto-advance.
|
// Register playback finished handler to drive queue auto-advance.
|
||||||
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
||||||
|
|
||||||
// Initialize OS media controls (MPRIS on Linux, no-op elsewhere).
|
// Initialize OS media controls (MPRIS on desktop Linux, a
|
||||||
|
// MediaSession on Android, no-op elsewhere). The callbacks are the
|
||||||
|
// same on every platform; only what delivers them differs.
|
||||||
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
|
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
|
||||||
|
|
||||||
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
|
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
|
||||||
OnPlay: yj.queue.Play,
|
OnPlay: yj.queue.Play,
|
||||||
OnPause: func() {
|
OnPause: func() {
|
||||||
if err := yj.player.Pause(); err != nil {
|
if err := yj.player.Pause(); err != nil {
|
||||||
yj.logger.Warn("MPRIS Pause failed", "err", err)
|
yj.logger.Warn("Media controls Pause failed", "err", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
OnPlayPause: func() {
|
OnPlayPause: func() {
|
||||||
if yj.player.IsPlaying() {
|
if yj.player.IsPlaying() {
|
||||||
if err := yj.player.Pause(); err != nil {
|
if err := yj.player.Pause(); err != nil {
|
||||||
yj.logger.Warn("MPRIS PlayPause(pause) failed", "err", err)
|
yj.logger.Warn(
|
||||||
|
"Media controls PlayPause(pause) failed", "err", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
yj.queue.Play()
|
yj.queue.Play()
|
||||||
@@ -505,14 +527,14 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
},
|
},
|
||||||
OnStop: func() {
|
OnStop: func() {
|
||||||
if err := yj.player.Pause(); err != nil {
|
if err := yj.player.Pause(); err != nil {
|
||||||
yj.logger.Warn("MPRIS Stop failed", "err", err)
|
yj.logger.Warn("Media controls Stop failed", "err", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
OnNext: yj.queue.Next,
|
OnNext: yj.queue.Next,
|
||||||
OnPrevious: yj.queue.Previous,
|
OnPrevious: yj.queue.Previous,
|
||||||
OnSeek: func(positionSec int) {
|
OnSeek: func(positionSec int) {
|
||||||
if err := yj.player.Seek(positionSec); err != nil {
|
if err := yj.player.Seek(positionSec); err != nil {
|
||||||
yj.logger.Warn("MPRIS Seek failed", "err", err)
|
yj.logger.Warn("Media controls Seek failed", "err", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
OnVolume: func(vol float64) {
|
OnVolume: func(vol float64) {
|
||||||
@@ -522,6 +544,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
OnDuck: yj.player.SetDuck,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
yj.logger.Error(
|
yj.logger.Error(
|
||||||
"Failed to initialize media controls",
|
"Failed to initialize media controls",
|
||||||
|
|||||||
@@ -620,6 +620,51 @@ func (c *Config) SetQueueFallback(mode string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllowMeteredCatalogDownload reports whether the ~0.6 GB Explore
|
||||||
|
// catalog may be fetched on a metered connection.
|
||||||
|
func (c *Config) GetAllowMeteredCatalogDownload() bool {
|
||||||
|
if c.General == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.General.AllowMeteredCatalogDownload
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAllowMeteredCatalogDownload saves the metered-download permission.
|
||||||
|
//
|
||||||
|
// There is nothing to validate and nothing to restart: the policy is
|
||||||
|
// read at the moment a download would start, so turning it on takes
|
||||||
|
// effect on the next attempt rather than needing this launch to be over.
|
||||||
|
func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
|
||||||
|
if c.General == nil {
|
||||||
|
c.General = &GeneralConfig{}
|
||||||
|
c.General.ApplyDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.General.AllowMeteredCatalogDownload = allow
|
||||||
|
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not save config: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
events.Emit(
|
||||||
|
c.ctx,
|
||||||
|
events.GeneralConfigChanged,
|
||||||
|
map[string]any{
|
||||||
|
"AllowMeteredCatalogDownload": allow,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
c.logger.Info(
|
||||||
|
"metered catalog download permission updated",
|
||||||
|
"allow", allow,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetTrackListColumns returns the configured track-list columns.
|
// GetTrackListColumns returns the configured track-list columns.
|
||||||
func (c *Config) GetTrackListColumns() []tracklist.Column {
|
func (c *Config) GetTrackListColumns() []tracklist.Column {
|
||||||
if c.TrackList == nil {
|
if c.TrackList == nil {
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ var errUnknownQueueFallback = errors.New("unknown queue fallback")
|
|||||||
type GeneralConfig struct {
|
type GeneralConfig struct {
|
||||||
DefaultPage DefaultPage `toml:"DefaultPage"`
|
DefaultPage DefaultPage `toml:"DefaultPage"`
|
||||||
QueueFallback QueueFallback `toml:"QueueFallback"`
|
QueueFallback QueueFallback `toml:"QueueFallback"`
|
||||||
|
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
|
||||||
|
// be fetched on a connection the platform calls cellular. It defaults
|
||||||
|
// to false, which is the whole point: the zero value is the safe one,
|
||||||
|
// so an existing config with no such key refuses by default rather
|
||||||
|
// than needing a migration to become careful.
|
||||||
|
AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyDefaults fills zero-value fields with sensible defaults.
|
// ApplyDefaults fills zero-value fields with sensible defaults.
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ func NewDB(logger *slog.Logger) (*DB, error) {
|
|||||||
return nil, fmt.Errorf("could not apply PRAGMAs: %w", err)
|
return nil, fmt.Errorf("could not apply PRAGMAs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before the schema is applied, not after: applySchema is
|
||||||
|
// CREATE ... IF NOT EXISTS, which no-ops against a table that
|
||||||
|
// already exists in an older shape. Retiring the stale one first is
|
||||||
|
// what turns that no-op into a create.
|
||||||
|
if err := retireStaleTables(dbCtx, db, logger); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if err := applySchema(dbCtx, db); err != nil {
|
if err := applySchema(dbCtx, db); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- The decomposition of a multi-artist credit, from the MusicBrainz
|
||||||
|
-- dump. One row per credited artist, in credit order.
|
||||||
|
--
|
||||||
|
-- A credit is ordered parts, and the credit *string* is derived from
|
||||||
|
-- them -- MusicBrainz's own `artist_credit.name` is a cached render and
|
||||||
|
-- nothing more. Rendering is a concatenation:
|
||||||
|
--
|
||||||
|
-- for each part in position order:
|
||||||
|
-- emit link(credited_name -> artist_mbid)
|
||||||
|
-- emit text(join_phrase)
|
||||||
|
--
|
||||||
|
-- so the link boundaries are known by construction. That is the whole
|
||||||
|
-- reason this table exists, and it is why nothing may reconstruct a
|
||||||
|
-- credit by *searching* for a name inside a credit string: the stored
|
||||||
|
-- string may have come from a file's tags while the parts come from the
|
||||||
|
-- catalog, and measured on a real library those disagree for about one
|
||||||
|
-- in three multi-artist credits ("Skrillex feat. Swae Lee" tagged
|
||||||
|
-- against "Skrillex & Swae Lee" upstream). A search would miss, or
|
||||||
|
-- match the wrong span.
|
||||||
|
--
|
||||||
|
-- `credited_name` is the name *as credited*, which is not the artist's
|
||||||
|
-- canonical name: MusicBrainz credits "Snoop Dogg" on a track by the
|
||||||
|
-- artist whose name is "Snoop Doggy Dogg". It is stored per row rather
|
||||||
|
-- than joined from an artist table for exactly that reason.
|
||||||
|
--
|
||||||
|
-- Only *multi-artist* credits are stored. A single-artist credit is
|
||||||
|
-- (name, "") and is already fully described by explore_index's
|
||||||
|
-- artist_name and artist_mbid; storing those would roughly triple the
|
||||||
|
-- table to say nothing new.
|
||||||
|
--
|
||||||
|
-- Credits are shared: an album's twelve tracks by one artist reference
|
||||||
|
-- one credit_id. That is the opposite of the local library's verdict
|
||||||
|
-- in plan 013, and correctly so -- credit sharing is 1:1 in one
|
||||||
|
-- person's files and genuinely many-to-one across a 2M-row catalog.
|
||||||
|
--
|
||||||
|
-- MBIDs are the same 16 raw bytes explore_index stores, for the same
|
||||||
|
-- size reason and with the same CHECK, so a stringly write fails at the
|
||||||
|
-- insert that made it rather than reading back as no rows at all. See
|
||||||
|
-- backend/explore/mbid.go.
|
||||||
|
CREATE TABLE IF NOT EXISTS artist_credit_part (
|
||||||
|
credit_id INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
artist_mbid BLOB NOT NULL CHECK(length(artist_mbid) = 16),
|
||||||
|
|
||||||
|
-- The name as credited on this release, which may differ from the
|
||||||
|
-- artist's canonical name. Display uses this; navigation uses the
|
||||||
|
-- MBID above.
|
||||||
|
credited_name TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- The literal connector that follows this part -- " feat. ", " & ",
|
||||||
|
-- ", ", or "" on the last part. Rendered as plain text between two
|
||||||
|
-- links.
|
||||||
|
join_phrase TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
PRIMARY KEY (credit_id, position)
|
||||||
|
) WITHOUT ROWID;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Which credit a catalog entity is credited to. One row per recording
|
||||||
|
-- or release group whose credit names more than one artist.
|
||||||
|
--
|
||||||
|
-- This is a table rather than an `explore_index.artist_credit_id`
|
||||||
|
-- column, and that is a deliberate consequence of how this app applies
|
||||||
|
-- its schema. `applySchema` is CREATE ... IF NOT EXISTS and there is
|
||||||
|
-- no migration chain (plan 013), so a *column* added to an existing
|
||||||
|
-- table never reaches a database that already has it -- while a new
|
||||||
|
-- *table* is created on every install, old or new, for free.
|
||||||
|
-- explore_index is the one table nobody can afford to drop and rebuild
|
||||||
|
-- on a schema change: it is the artifact users download rather than
|
||||||
|
-- derive.
|
||||||
|
--
|
||||||
|
-- Only multi-artist credits are referenced here, matching
|
||||||
|
-- artist_credit_part. An entity with no row is credited to exactly one
|
||||||
|
-- artist, which explore_index's own artist_name and artist_mbid already
|
||||||
|
-- describe -- so absence is the common case and means "nothing to
|
||||||
|
-- decompose", not "unknown".
|
||||||
|
--
|
||||||
|
-- `credit_id` is opaque and is only meaningful against the
|
||||||
|
-- artist_credit_part rows built or imported alongside it. The two are
|
||||||
|
-- always written together; nothing persists a credit_id anywhere else.
|
||||||
|
-- The local library stores resolved parts, never this id.
|
||||||
|
CREATE TABLE IF NOT EXISTS artist_credit_ref (
|
||||||
|
mbid BLOB NOT NULL PRIMARY KEY CHECK(length(mbid) = 16),
|
||||||
|
credit_id INTEGER NOT NULL
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artist_credit_ref_credit
|
||||||
|
ON artist_credit_ref(credit_id);
|
||||||
@@ -27,6 +27,19 @@ type Artist struct {
|
|||||||
Mbid sql.NullString
|
Mbid sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ArtistCreditPart struct {
|
||||||
|
CreditID int64
|
||||||
|
Position int64
|
||||||
|
ArtistMbid []byte
|
||||||
|
CreditedName string
|
||||||
|
JoinPhrase string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistCreditRef struct {
|
||||||
|
Mbid []byte
|
||||||
|
CreditID int64
|
||||||
|
}
|
||||||
|
|
||||||
type ArtistEnrichment struct {
|
type ArtistEnrichment struct {
|
||||||
ArtistMbid string
|
ArtistMbid string
|
||||||
BrowsedAt sql.NullTime
|
BrowsedAt sql.NullTime
|
||||||
|
|||||||
@@ -0,0 +1,560 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"maps"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"yellowjacket/backend/datamap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file repairs the one thing `CREATE TABLE IF NOT EXISTS` cannot.
|
||||||
|
//
|
||||||
|
// `sql/schemas/` is the single description of the schema and there is no
|
||||||
|
// migration chain (plan 013): a schema change is one edit to one file.
|
||||||
|
// That works perfectly for a *new* table, which every install then
|
||||||
|
// creates, and not at all for a changed one -- `IF NOT EXISTS` reaches
|
||||||
|
// an existing table only if its shape already matches, and otherwise
|
||||||
|
// silently no-ops. The user's answer to that is "delete and rescan"
|
||||||
|
// (plan 013, open question 1), which is free for everything a rescan
|
||||||
|
// rebuilds.
|
||||||
|
//
|
||||||
|
// It is not free for the catalog. explore_index is a *downloaded
|
||||||
|
// artifact*, not something derived from the user's files, and it is the
|
||||||
|
// largest thing this app stores. So it went stale instead: plan 014
|
||||||
|
// added `total_tracks` to the schema and to `indexRowFields` -- the one
|
||||||
|
// projection every explore read uses -- and no database that already
|
||||||
|
// existed ever grew the column. Every Explore search, browse, artist
|
||||||
|
// page and album page on such an install fails with
|
||||||
|
// "no such column: total_tracks", while a fresh install is perfectly
|
||||||
|
// healthy, which is why the tests did not see it. The same databases
|
||||||
|
// are stale a second way, from the same plan: their `mbid` columns are
|
||||||
|
// still TEXT where the schema now declares BLOB, and SQLite does not
|
||||||
|
// coerce between the two -- a comparison against 16 raw bytes simply
|
||||||
|
// returns no rows.
|
||||||
|
//
|
||||||
|
// The repair is to notice and drop, not to migrate. A dropped catalog
|
||||||
|
// costs one artifact download (about a minute); the alternative --
|
||||||
|
// ALTER TABLE ADD COLUMN, which would handle `total_tracks` alone
|
||||||
|
// cheaply -- cannot express the TEXT-to-BLOB half at all, and would
|
||||||
|
// leave those installs quietly broken while reporting success.
|
||||||
|
//
|
||||||
|
// Everything except `Authored` is eligible. `Cache` is rebuildable by
|
||||||
|
// definition; `Owned` is a projection of the user's files and a rescan
|
||||||
|
// rebuilds it, which is plan 013's stated answer to exactly this
|
||||||
|
// situation ("delete and rescan", open question 1); `Derived` is
|
||||||
|
// computed from Owned. No `Authored` table is ever dropped here --
|
||||||
|
// that is the whole point of the datamap, and it is asserted by
|
||||||
|
// TestAuthoredTablesAreNeverRetired rather than only stated.
|
||||||
|
//
|
||||||
|
// What that does *not* buy is immunity for authored rows that reference
|
||||||
|
// a retired table. `audio_files` is MIXED KIND: `play_count`,
|
||||||
|
// `last_played` and `tag_status` are authored columns on an Owned
|
||||||
|
// table, and they go with it. Playlists survive as playlists, and
|
||||||
|
// their entries survive pointing at nothing. That cost was weighed and
|
||||||
|
// accepted rather than overlooked -- the alternative is to carry the
|
||||||
|
// authored columns across the rebuild keyed on file_path, which stays a
|
||||||
|
// real option if this ever bites harder than it is worth.
|
||||||
|
//
|
||||||
|
// **This relies on foreign_keys being ON**, which applyPRAGMAs has
|
||||||
|
// already done by the time NewDB calls it, and the dependency is not
|
||||||
|
// cosmetic. SQLite performs an implicit DELETE before dropping a table
|
||||||
|
// when foreign keys are enabled, so `playlist_tracks.audio_file_id` --
|
||||||
|
// declared ON DELETE SET NULL -- is nulled. With foreign keys off, no
|
||||||
|
// action fires and those rows keep the ids they had, which a rescan
|
||||||
|
// then reissues starting from 1: every playlist would silently fill
|
||||||
|
// with *different songs*. Nulled entries are merely empty; stale ones
|
||||||
|
// are wrong, and wrong quietly. TestRetiringOwnedTablesDoesNotDangle
|
||||||
|
// is what stops a future reordering turning one into the other.
|
||||||
|
|
||||||
|
// retireGroups are tables that must be retired together. A catalog
|
||||||
|
// whose rows are gone must not keep the full-text index built over
|
||||||
|
// them, nor the metadata claiming the import that produced them
|
||||||
|
// finished -- that marker is exactly what stops the artifact being
|
||||||
|
// fetched again. applySchema recreates all three empty immediately
|
||||||
|
// afterwards, and the ordinary "no index yet" path takes over.
|
||||||
|
var retireGroups = [][]string{
|
||||||
|
{
|
||||||
|
"explore_index",
|
||||||
|
"explore_index_fts",
|
||||||
|
"explore_index_meta",
|
||||||
|
"explore_champion_fts",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaColumn is one column as the schema file declares it.
|
||||||
|
type schemaColumn struct {
|
||||||
|
name string
|
||||||
|
typ string
|
||||||
|
}
|
||||||
|
|
||||||
|
// retireStaleTables drops every non-authored table whose live shape no
|
||||||
|
// longer matches what sql/schemas/ declares, plus any table the schema
|
||||||
|
// no longer describes at all, so applySchema can create the current
|
||||||
|
// shape afresh. It runs before applySchema and is a no-op on a new
|
||||||
|
// database, where the tables do not exist yet.
|
||||||
|
func retireStaleTables(
|
||||||
|
ctx context.Context, db *sql.DB, logger *slog.Logger,
|
||||||
|
) error {
|
||||||
|
declared, err := declaredTables()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stale := make(map[string]string)
|
||||||
|
|
||||||
|
for table, columns := range declared {
|
||||||
|
entry, ok := datamap.Lookup(table)
|
||||||
|
if !ok || entry.Kind == datamap.Authored || entry.FTS {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a stale Cache table may be rebuilt is decided per
|
||||||
|
// binary, at compile time: the app re-downloads its catalog in
|
||||||
|
// about a minute, cmd/indexbuild would re-derive it from ~205 GB
|
||||||
|
// of dumps. See staleshape_policy.go.
|
||||||
|
if entry.Kind == datamap.Cache && !retireStaleCache {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
reason, err := staleReason(ctx, db, table, columns)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if reason != "" {
|
||||||
|
stale[table] = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
obsolete, err := obsoleteTables(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
maps.Copy(stale, obsolete)
|
||||||
|
|
||||||
|
if len(stale) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return retireGroupsFor(ctx, db, logger, stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// obsoleteTables are live tables the schema no longer describes at all.
|
||||||
|
// TestCatalogCoversSchema makes the datamap a complete description of
|
||||||
|
// the current schema, so a table it does not know is one a past version
|
||||||
|
// created and this one does not -- plan 013 alone left seven behind
|
||||||
|
// (recordings, release_groups, artist_credit, artist_credit_artist,
|
||||||
|
// release_group_recordings, recording_genres) plus the
|
||||||
|
// schema_migrations table that squashing the chain retired. They are
|
||||||
|
// dead weight, and one of them holding a foreign key into a table being
|
||||||
|
// rebuilt is worse than dead weight.
|
||||||
|
//
|
||||||
|
// SQLite's own bookkeeping and FTS shadow tables are not obsolete:
|
||||||
|
// datamap.Lookup resolves a shadow table to its parent, and IsInternal
|
||||||
|
// covers the rest.
|
||||||
|
func obsoleteTables(ctx context.Context, db *sql.DB) (map[string]string, error) {
|
||||||
|
rows, err := db.QueryContext(
|
||||||
|
ctx, "SELECT name FROM sqlite_master WHERE type = 'table'",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not list tables: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
out := make(map[string]string)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, fmt.Errorf("could not scan table name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if datamap.IsInternal(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, known := datamap.Lookup(name); !known {
|
||||||
|
out[name] = "the schema no longer describes this table"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("could not read table list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// retireGroupsFor drops each stale table along with everything its
|
||||||
|
// retire group says must go with it.
|
||||||
|
func retireGroupsFor(
|
||||||
|
ctx context.Context, db *sql.DB, logger *slog.Logger,
|
||||||
|
stale map[string]string,
|
||||||
|
) error {
|
||||||
|
drop := make(map[string]string)
|
||||||
|
|
||||||
|
for table, reason := range stale {
|
||||||
|
drop[table] = reason
|
||||||
|
|
||||||
|
for _, group := range retireGroups {
|
||||||
|
if !slices.Contains(group, table) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, member := range group {
|
||||||
|
if _, already := drop[member]; !already {
|
||||||
|
drop[member] = "retired with " + table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dropDeferred(ctx, db, logger, drop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropDeferred drops every named table in one transaction with foreign
|
||||||
|
// key enforcement deferred to the commit.
|
||||||
|
//
|
||||||
|
// The deferral is required and the two obvious alternatives are both
|
||||||
|
// wrong. These tables reference each other -- pre-013 `audio_files`
|
||||||
|
// has a foreign key into `recordings`, which is itself being retired --
|
||||||
|
// so dropping them one at a time in an arbitrary order fails with
|
||||||
|
// "FOREIGN KEY constraint failed" on whichever is unlucky enough to go
|
||||||
|
// first, and there is no order that is safe in general. Turning
|
||||||
|
// foreign keys *off* for the duration would fix that and silently take
|
||||||
|
// the ON DELETE SET NULL on `playlist_tracks.audio_file_id` with it,
|
||||||
|
// leaving playlist entries pointing at ids a rescan reissues to
|
||||||
|
// different songs -- the exact failure
|
||||||
|
// TestRetiringOwnedTablesDoesNotDangle exists to prevent.
|
||||||
|
//
|
||||||
|
// Deferring keeps the actions firing while tolerating the inconsistency
|
||||||
|
// in the middle, and the commit then checks that the end state is
|
||||||
|
// sound. It is set inside the transaction because SQLite resets it at
|
||||||
|
// every commit.
|
||||||
|
func dropDeferred(
|
||||||
|
ctx context.Context, db *sql.DB, logger *slog.Logger,
|
||||||
|
drop map[string]string,
|
||||||
|
) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not begin the retire transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, "PRAGMA defer_foreign_keys = ON"); err != nil {
|
||||||
|
return fmt.Errorf("could not defer foreign keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted, so a failure is reproducible. Map order is random, and a
|
||||||
|
// bug that depends on which table happens to go first reproduces on
|
||||||
|
// one run in three and passes review on the other two -- which is
|
||||||
|
// exactly how the foreign-key ordering above reached a real
|
||||||
|
// database. Sorting does not make any order *safe*; the deferral
|
||||||
|
// does that.
|
||||||
|
for _, table := range slices.Sorted(maps.Keys(drop)) {
|
||||||
|
logger.Warn(
|
||||||
|
"retiring a table the schema no longer describes",
|
||||||
|
"table", table,
|
||||||
|
"reason", drop[table],
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(
|
||||||
|
ctx, "DROP TABLE IF EXISTS "+quoteIdent(table),
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("could not retire stale table %s: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("could not commit the retire: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// staleReason reports why a live table disagrees with its declaration,
|
||||||
|
// or "" when it agrees. A column the live table does not have is the
|
||||||
|
// additive case; a column whose declared type changed is the one an
|
||||||
|
// ALTER could not fix anyway. Columns the live table has and the
|
||||||
|
// schema no longer declares are ignored: they cost nothing and dropping
|
||||||
|
// the table over one would retire a healthy catalog.
|
||||||
|
func staleReason(
|
||||||
|
ctx context.Context, db *sql.DB, table string, columns []schemaColumn,
|
||||||
|
) (string, error) {
|
||||||
|
live, err := liveColumns(ctx, db, table)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(live) == 0 {
|
||||||
|
// Not present at all: applySchema is about to create it.
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, col := range columns {
|
||||||
|
liveType, present := live[col.name]
|
||||||
|
if !present {
|
||||||
|
return "missing column " + col.name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sameDeclaredType(col.typ, liveType) {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"column %s is %s, schema declares %s",
|
||||||
|
col.name, liveType, col.typ,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveColumns returns the live table's columns and their declared types,
|
||||||
|
// empty when the table does not exist.
|
||||||
|
func liveColumns(
|
||||||
|
ctx context.Context, db *sql.DB, table string,
|
||||||
|
) (map[string]string, error) {
|
||||||
|
rows, err := db.QueryContext(
|
||||||
|
ctx, "SELECT name, type FROM pragma_table_info(?)", table,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not inspect table %s: %w", table, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
out := make(map[string]string)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var name, typ string
|
||||||
|
|
||||||
|
if err := rows.Scan(&name, &typ); err != nil {
|
||||||
|
return nil, fmt.Errorf("could not scan column of %s: %w", table, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out[name] = typ
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("could not read columns of %s: %w", table, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameDeclaredType compares two SQLite type names. They are compared
|
||||||
|
// case-insensitively and only on the leading word, so INTEGER matches
|
||||||
|
// INTEGER and VARCHAR(20) matches VARCHAR -- SQLite's affinity rules
|
||||||
|
// make finer distinctions meaningless, and a difference that fine is
|
||||||
|
// not worth retiring a catalog over. An empty declared type matches
|
||||||
|
// anything, which is what a column declared with only constraints has.
|
||||||
|
func sameDeclaredType(declared, live string) bool {
|
||||||
|
d := strings.ToUpper(strings.Fields(declared + " ")[0])
|
||||||
|
l := strings.ToUpper(strings.Fields(live + " ")[0])
|
||||||
|
|
||||||
|
if d == "" || l == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if i := strings.IndexByte(d, '('); i >= 0 {
|
||||||
|
d = d[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if i := strings.IndexByte(l, '('); i >= 0 {
|
||||||
|
l = l[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return d == l
|
||||||
|
}
|
||||||
|
|
||||||
|
// declaredTables parses every CREATE TABLE in sql/schemas/ into its
|
||||||
|
// column list. Parsing the schema rather than writing the expectation
|
||||||
|
// down a second time is the point: a second list is a second thing to
|
||||||
|
// forget, which is the fault this whole file exists to repair.
|
||||||
|
func declaredTables() (map[string][]schemaColumn, error) {
|
||||||
|
dirEntries, err := schemas.ReadDir("sql/schemas")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not read schemas directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string][]schemaColumn)
|
||||||
|
|
||||||
|
for _, dirEntry := range dirEntries {
|
||||||
|
if dirEntry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := fs.ReadFile(schemas, path.Join("sql/schemas", dirEntry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not read %s: %w", dirEntry.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maps.Copy(out, parseCreateTables(string(content)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// constraintKeywords begin a table constraint rather than a column.
|
||||||
|
var constraintKeywords = map[string]bool{
|
||||||
|
"PRIMARY": true, "FOREIGN": true, "UNIQUE": true,
|
||||||
|
"CHECK": true, "CONSTRAINT": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCreateTables extracts the column names and declared types of
|
||||||
|
// every non-virtual CREATE TABLE in one schema file.
|
||||||
|
func parseCreateTables(content string) map[string][]schemaColumn {
|
||||||
|
out := make(map[string][]schemaColumn)
|
||||||
|
rest := stripLineComments(content)
|
||||||
|
|
||||||
|
for {
|
||||||
|
idx := indexFold(rest, "CREATE TABLE ")
|
||||||
|
if idx < 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
rest = rest[idx+len("CREATE TABLE "):]
|
||||||
|
|
||||||
|
head, body, ok := splitTableBody(rest)
|
||||||
|
if !ok {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
if name := tableName(head); name != "" {
|
||||||
|
out[name] = parseColumns(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableName pulls the table name out of the text between "CREATE TABLE"
|
||||||
|
// and its opening parenthesis, dropping an IF NOT EXISTS and any
|
||||||
|
// quoting.
|
||||||
|
func tableName(head string) string {
|
||||||
|
head = strings.TrimSpace(head)
|
||||||
|
head = strings.TrimPrefix(head, "IF NOT EXISTS ")
|
||||||
|
head = strings.TrimPrefix(head, "if not exists ")
|
||||||
|
|
||||||
|
fields := strings.Fields(head)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Trim(fields[len(fields)-1], `"'`+"`")
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitTableBody returns the text before the table's opening paren and
|
||||||
|
// the balanced text inside it.
|
||||||
|
func splitTableBody(s string) (head, body string, ok bool) {
|
||||||
|
open := strings.IndexByte(s, '(')
|
||||||
|
if open < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
depth := 0
|
||||||
|
|
||||||
|
for i := open; i < len(s); i++ {
|
||||||
|
switch s[i] {
|
||||||
|
case '(':
|
||||||
|
depth++
|
||||||
|
case ')':
|
||||||
|
depth--
|
||||||
|
|
||||||
|
if depth == 0 {
|
||||||
|
return s[:open], s[open+1 : i], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseColumns splits a table body on its top-level commas and keeps
|
||||||
|
// the parts that are columns rather than table constraints.
|
||||||
|
func parseColumns(body string) []schemaColumn {
|
||||||
|
var (
|
||||||
|
out []schemaColumn
|
||||||
|
depth int
|
||||||
|
start int
|
||||||
|
)
|
||||||
|
|
||||||
|
parts := make([]string, 0, 8)
|
||||||
|
|
||||||
|
for i := range len(body) {
|
||||||
|
switch body[i] {
|
||||||
|
case '(':
|
||||||
|
depth++
|
||||||
|
case ')':
|
||||||
|
depth--
|
||||||
|
case ',':
|
||||||
|
if depth == 0 {
|
||||||
|
parts = append(parts, body[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, body[start:])
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
fields := strings.Fields(part)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// A table constraint need not be followed by a space --
|
||||||
|
// "UNIQUE(mbid)" is one field, and reading it as a column name
|
||||||
|
// makes an entirely healthy table look stale, which retires a
|
||||||
|
// catalog nobody asked to lose.
|
||||||
|
head := fields[0]
|
||||||
|
if i := strings.IndexByte(head, '('); i >= 0 {
|
||||||
|
head = head[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if constraintKeywords[strings.ToUpper(head)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
col := schemaColumn{name: strings.Trim(head, `"'`+"`")}
|
||||||
|
if len(fields) > 1 {
|
||||||
|
col.typ = fields[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripLineComments removes -- comments, which otherwise contribute
|
||||||
|
// stray parentheses and commas to the parse.
|
||||||
|
func stripLineComments(s string) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
if idx := strings.Index(line, "--"); idx >= 0 {
|
||||||
|
lines[i] = line[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexFold is a case-insensitive strings.Index.
|
||||||
|
func indexFold(s, substr string) int {
|
||||||
|
return strings.Index(strings.ToUpper(s), strings.ToUpper(substr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoteIdent quotes a table name for interpolation into DDL, which
|
||||||
|
// cannot take a bound parameter.
|
||||||
|
func quoteIdent(name string) string {
|
||||||
|
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build !indexbuild
|
||||||
|
|
||||||
|
package database
|
||||||
|
|
||||||
|
// retireStaleCache reports whether a Cache table whose shape no longer
|
||||||
|
// matches the schema may be dropped and rebuilt.
|
||||||
|
//
|
||||||
|
// In the app: yes. The only Cache table large enough to care about is
|
||||||
|
// the catalog, and the app does not derive it — it downloads it. A
|
||||||
|
// stale one costs about a minute of re-fetching the artifact, and
|
||||||
|
// keeping it costs every Explore read on the install, because a
|
||||||
|
// projection naming a column the table does not have fails outright.
|
||||||
|
//
|
||||||
|
// In cmd/indexbuild: no, and the file next to this one says why.
|
||||||
|
const retireStaleCache = true
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//go:build indexbuild
|
||||||
|
|
||||||
|
package database
|
||||||
|
|
||||||
|
// retireStaleCache is false here, and this is the whole reason the
|
||||||
|
// policy is a build tag rather than a rule inside retireStaleTables.
|
||||||
|
//
|
||||||
|
// The index database is the one place in this project where the catalog
|
||||||
|
// is *derived* rather than downloaded. Rebuilding it is a ~205 GB dump
|
||||||
|
// stream over hours, resumed across runs from a checkpoint on a
|
||||||
|
// persistent volume; that volume exists for no other purpose. The app's
|
||||||
|
// answer to a stale catalog — drop it, fetch the artifact again — is
|
||||||
|
// not available here, because this database *is* what the artifact is
|
||||||
|
// cut from.
|
||||||
|
//
|
||||||
|
// This was not hypothetical. The repair shipped without it and dropped
|
||||||
|
// the CI catalog on its first run:
|
||||||
|
//
|
||||||
|
// retiring a table ... table=explore_index
|
||||||
|
// reason="column entity_type is TEXT, schema declares INTEGER"
|
||||||
|
// index maintenance mode=build reason="no completed import yet"
|
||||||
|
//
|
||||||
|
// The shape mismatch was real and the drop was correct by the app's
|
||||||
|
// rule. It was still wrong here: that database is deliberately kept in
|
||||||
|
// the older encoding, which is what `fix(indexexport): read an index
|
||||||
|
// older than the binary` exists to tolerate. A rule that is right for
|
||||||
|
// every install and catastrophic for one database has to be told which
|
||||||
|
// one it is in, and a build tag is how this project already tells the
|
||||||
|
// index tools apart (backend/events/runtime_indexbuild.go,
|
||||||
|
// backend/explore/servicestartup.go, dumpbuild_stub.go).
|
||||||
|
//
|
||||||
|
// cmd/indexbuild has its own repair for the half it *can* safely
|
||||||
|
// discard: retireLibraryTables drops every table the datamap does not
|
||||||
|
// classify as Cache, which is empty by construction in that database.
|
||||||
|
// Between the two, the library half is repaired and the catalog is
|
||||||
|
// never touched.
|
||||||
|
const retireStaleCache = false
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"path"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testLogger discards the repair's warnings; the tests assert on the
|
||||||
|
// database, not on the log.
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.DiscardHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openRaw opens a scratch database file with no schema applied, so a
|
||||||
|
// test can build an *old* shape and then let NewDB's repair meet it.
|
||||||
|
func openRaw(t *testing.T, dir string) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", path.Join(dir, "yj.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetiresIndexMissingAColumn is plan 014's bug, symptom first: an
|
||||||
|
// explore_index created before `total_tracks` existed, met by the
|
||||||
|
// projection every explore read uses. Before the repair this failed
|
||||||
|
// with "no such column: total_tracks" on every install that already had
|
||||||
|
// a catalog, while a fresh one was perfectly healthy.
|
||||||
|
func TestRetiresIndexMissingAColumn(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openRaw(t, dir)
|
||||||
|
|
||||||
|
// The pre-014 shape: the columns the projection needs, minus the
|
||||||
|
// one the plan added.
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
CREATE TABLE explore_index (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_type INTEGER NOT NULL,
|
||||||
|
mbid BLOB NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist_name TEXT NOT NULL,
|
||||||
|
artist_mbid BLOB NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
|
||||||
|
VALUES (1, x'00112233445566778899aabbccddeeff', 'x', 'y', x'');
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retireStaleTables(ctx, db, testLogger()); err != nil {
|
||||||
|
t.Fatalf("retire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The column the projection needs is there now.
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM pragma_table_info('explore_index')
|
||||||
|
WHERE name = 'total_tracks'`,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("inspect: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("explore_index still has no total_tracks column")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the catalog really was retired rather than patched, so the
|
||||||
|
// artifact is fetched again instead of half a catalog being served.
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM explore_index",
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("stale rows survived the retire: %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetiresIndexWithTextMBIDs is the half an ALTER could not have
|
||||||
|
// repaired: plan 013 changed mbid from TEXT to BLOB, and SQLite does not
|
||||||
|
// coerce between them, so a query against 16 raw bytes returns no rows
|
||||||
|
// rather than an error.
|
||||||
|
func TestRetiresIndexWithTextMBIDs(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openRaw(t, dir)
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
CREATE TABLE explore_index (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
mbid TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist_name TEXT NOT NULL,
|
||||||
|
artist_mbid TEXT NOT NULL,
|
||||||
|
total_tracks INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retireStaleTables(ctx, db, testLogger()); err != nil {
|
||||||
|
t.Fatalf("retire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var typ string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT type FROM pragma_table_info('explore_index') WHERE name = 'mbid'`,
|
||||||
|
).Scan(&typ); err != nil {
|
||||||
|
t.Fatalf("inspect: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if typ != "BLOB" {
|
||||||
|
t.Fatalf("mbid is still %s, want BLOB", typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetiringTheIndexTakesItsMetaWithIt guards the thing that makes the
|
||||||
|
// repair actually repair: the marker saying the import finished is what
|
||||||
|
// stops the artifact being fetched again, so a catalog dropped without
|
||||||
|
// it would stay empty forever.
|
||||||
|
func TestRetiringTheIndexTakesItsMetaWithIt(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openRaw(t, dir)
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
CREATE TABLE explore_index (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_type INTEGER NOT NULL,
|
||||||
|
mbid BLOB NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE explore_index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
INSERT INTO explore_index_meta VALUES ('dump_import_done', '1');
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retireStaleTables(ctx, db, testLogger()); err != nil {
|
||||||
|
t.Fatalf("retire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM explore_index_meta WHERE key = 'dump_import_done'",
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("meta: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("the import-done marker survived a retired catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHealthyDatabaseIsUntouched is the other half, and the one that
|
||||||
|
// would make this dangerous if it failed: a current schema must survive
|
||||||
|
// a launch with its catalog intact. A repair that retires a healthy
|
||||||
|
// catalog costs every user an artifact download on every start.
|
||||||
|
func TestHealthyDatabaseIsUntouched(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openRaw(t, dir)
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
|
||||||
|
VALUES (1, x'00112233445566778899aabbccddeeff', 'x', 'y', x'')
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retireStaleTables(ctx, db, testLogger()); err != nil {
|
||||||
|
t.Fatalf("retire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM explore_index",
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("a healthy catalog was retired: %d rows left", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthoredTablesAreNeverRetired states the boundary in a test rather
|
||||||
|
// than only in a comment: this mechanism deletes data, and the only
|
||||||
|
// thing standing between it and a user's playlists is the Kind filter.
|
||||||
|
func TestAuthoredTablesAreNeverRetired(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openRaw(t, dir)
|
||||||
|
|
||||||
|
// A playlists table missing most of its current columns.
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
CREATE TABLE playlists (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
|
||||||
|
INSERT INTO playlists (name) VALUES ('irreplaceable');
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retireStaleTables(ctx, db, testLogger()); err != nil {
|
||||||
|
t.Fatalf("retire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM playlists",
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("an authored table was retired; rows left: %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetiresTablesTheSchemaNoLongerDescribes covers what plan 013 left
|
||||||
|
// behind on every database that predates it: seven tables the schema
|
||||||
|
// stopped describing, plus the schema_migrations table that squashing
|
||||||
|
// the chain retired. They are not stale in shape — they are simply not
|
||||||
|
// ours any more.
|
||||||
|
func TestRetiresTablesTheSchemaNoLongerDescribes(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db := openRaw(t, t.TempDir())
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
CREATE TABLE recordings (id INTEGER PRIMARY KEY, name TEXT);
|
||||||
|
CREATE TABLE artist_credit (id INTEGER PRIMARY KEY, text TEXT);
|
||||||
|
CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retireStaleTables(ctx, db, testLogger()); err != nil {
|
||||||
|
t.Fatalf("retire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range []string{"recordings", "artist_credit", "schema_migrations"} {
|
||||||
|
var n int
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = ?",
|
||||||
|
table,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("inspect %s: %v", table, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("%s survived; the schema no longer describes it", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFTSShadowTablesAreNotObsolete is the sweep's sharp edge: an FTS5
|
||||||
|
// virtual table is backed by four shadow tables that appear in
|
||||||
|
// sqlite_master under their own names and are in no schema file.
|
||||||
|
// Dropping one destroys the index it belongs to.
|
||||||
|
func TestFTSShadowTablesAreNotObsolete(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db := openRaw(t, t.TempDir())
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
obsolete, err := obsoleteTables(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("obsoleteTables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(obsolete) != 0 {
|
||||||
|
t.Fatalf("a freshly created schema reported obsolete tables: %v", obsolete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetiringOwnedTablesDoesNotDangle pins the one behaviour that is
|
||||||
|
// silently wrong rather than loudly broken.
|
||||||
|
//
|
||||||
|
// Retiring audio_files leaves playlist entries behind. With
|
||||||
|
// foreign_keys ON — which applyPRAGMAs has done before NewDB gets here —
|
||||||
|
// SET NULL fires and they point at nothing. With it OFF they keep ids
|
||||||
|
// that the rescan reissues from 1, so every playlist quietly fills with
|
||||||
|
// different songs. Nothing about the schema makes that ordering
|
||||||
|
// obvious, so it is asserted rather than assumed.
|
||||||
|
func TestRetiringOwnedTablesDoesNotDangle(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db := openRaw(t, t.TempDir())
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
t.Fatalf("pragma: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Break audio_files' shape so it is retired, keeping a playlist
|
||||||
|
// entry that references it.
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO playlists (id, name) VALUES (1, 'keepme');
|
||||||
|
INSERT INTO libraries (id, name, path) VALUES (0, 'test', '/music');
|
||||||
|
INSERT INTO audio_files (id, file_path, file_type_id, length_milliseconds)
|
||||||
|
VALUES (7, '/music/a.flac', 1, 1000);
|
||||||
|
INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
||||||
|
VALUES (1, 7, 0);
|
||||||
|
DROP VIEW IF EXISTS track_metadata;
|
||||||
|
ALTER TABLE audio_files DROP COLUMN artist_credit;
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retireStaleTables(ctx, db, testLogger()); err != nil {
|
||||||
|
t.Fatalf("retire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dangling int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM playlist_tracks WHERE audio_file_id IS NOT NULL",
|
||||||
|
).Scan(&dangling); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dangling != 0 {
|
||||||
|
t.Fatalf(
|
||||||
|
"%d playlist entries still point at retired audio_files ids; "+
|
||||||
|
"a rescan will reissue those ids to different tracks",
|
||||||
|
dangling,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The playlist itself is authored and must be untouched.
|
||||||
|
var playlists int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM playlists",
|
||||||
|
).Scan(&playlists); err != nil {
|
||||||
|
t.Fatalf("playlists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if playlists != 1 {
|
||||||
|
t.Fatalf("authored playlist lost: %d", playlists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetiringInterlinkedLegacyTables is the bug the unit tests missed
|
||||||
|
// and a real database found.
|
||||||
|
//
|
||||||
|
// The tables plan 013 retired reference each other -- pre-013
|
||||||
|
// audio_files has a foreign key into recordings -- so with foreign keys
|
||||||
|
// ON, dropping them one at a time fails with "FOREIGN KEY constraint
|
||||||
|
// failed" on whichever goes first, and map iteration order decides
|
||||||
|
// which that is. Every other test in this file ran with foreign keys
|
||||||
|
// off and passed happily; the app enables them in applyPRAGMAs before
|
||||||
|
// the repair runs, so only the real launch path showed it.
|
||||||
|
func TestRetiringInterlinkedLegacyTables(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db := openRaw(t, t.TempDir())
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
t.Fatalf("pragma: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pre-013 shape, with the reference that makes ordering matter.
|
||||||
|
// release_group_recordings sorts *after* recordings and references
|
||||||
|
// it, so the deterministic order retires the parent while the child
|
||||||
|
// still holds rows pointing at it -- which is the case that fails
|
||||||
|
// without the deferral, rather than one that fails on some runs.
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
CREATE TABLE recordings (id INTEGER PRIMARY KEY, name TEXT);
|
||||||
|
CREATE TABLE artist_credit (id INTEGER PRIMARY KEY, text TEXT);
|
||||||
|
CREATE TABLE release_group_recordings (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
recording_id INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(recording_id) REFERENCES recordings(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE audio_files (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
|
recording_id INTEGER,
|
||||||
|
FOREIGN KEY(recording_id) REFERENCES recordings(id)
|
||||||
|
);
|
||||||
|
INSERT INTO recordings (id, name) VALUES (1, 'x');
|
||||||
|
INSERT INTO release_group_recordings (id, recording_id) VALUES (1, 1);
|
||||||
|
INSERT INTO audio_files (id, file_path, recording_id)
|
||||||
|
VALUES (1, '/music/a.flac', 1);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retireStaleTables(ctx, db, testLogger()); err != nil {
|
||||||
|
t.Fatalf("retire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range []string{"recordings", "artist_credit"} {
|
||||||
|
var n int
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = ?",
|
||||||
|
table,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("inspect %s: %v", table, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("%s survived the retire", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the rebuilt audio_files is the current shape, which is the
|
||||||
|
// whole reason the old one had to go.
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM pragma_table_info('audio_files')
|
||||||
|
WHERE name = 'artist_credit'`,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("inspect audio_files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatal("audio_files was not rebuilt in the current shape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseCreateTablesReadsTheRealSchema keeps the parser honest
|
||||||
|
// against the files it actually runs on: a parser that silently found
|
||||||
|
// no columns would report every table healthy and repair nothing.
|
||||||
|
func TestParseCreateTablesReadsTheRealSchema(t *testing.T) {
|
||||||
|
declared, err := declaredTables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("declaredTables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cols, ok := declared["explore_index"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("explore_index was not parsed out of the schema files")
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]string{
|
||||||
|
"mbid": "BLOB",
|
||||||
|
"total_tracks": "INTEGER",
|
||||||
|
"artist_name": "TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make(map[string]string, len(cols))
|
||||||
|
for _, c := range cols {
|
||||||
|
got[c.name] = c.typ
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, typ := range want {
|
||||||
|
if got[name] != typ {
|
||||||
|
t.Errorf("explore_index.%s parsed as %q, want %q", name, got[name], typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A table constraint must not be mistaken for a column.
|
||||||
|
for _, c := range cols {
|
||||||
|
switch c.name {
|
||||||
|
case "PRIMARY", "FOREIGN", "UNIQUE", "CHECK", "CONSTRAINT":
|
||||||
|
t.Errorf("parsed table constraint %q as a column", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,6 +185,21 @@ var tables = []Table{
|
|||||||
Note: "Full-text index over the champion entities of the " +
|
Note: "Full-text index over the champion entities of the " +
|
||||||
"MusicBrainz dump. Rebuilt only by a full index build.",
|
"MusicBrainz dump. Rebuilt only by a full index build.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "artist_credit_part", Kind: Cache, Lifetime: Retained,
|
||||||
|
Note: "The decomposition of a multi-artist credit, from the " +
|
||||||
|
"MusicBrainz dump: one row per credited artist, with the " +
|
||||||
|
"name as credited and the join phrase that follows it. " +
|
||||||
|
"Arrives with the downloaded artifact, so rebuilding it " +
|
||||||
|
"costs a dump stream and it is never swept.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "artist_credit_ref", Kind: Cache, Lifetime: Retained,
|
||||||
|
Note: "Which credit a catalog recording or release group is " +
|
||||||
|
"credited to. Present only for multi-artist credits; " +
|
||||||
|
"absence means one artist, which explore_index already " +
|
||||||
|
"describes. Ships and dies with artist_credit_part.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "explore_index", Kind: Cache, Lifetime: Retained,
|
Name: "explore_index", Kind: Cache, Lifetime: Retained,
|
||||||
Note: "The offline MusicBrainz search index. Rebuilding costs a " +
|
Note: "The offline MusicBrainz search index. Rebuilding costs a " +
|
||||||
|
|||||||
@@ -199,24 +199,23 @@ func TestManagerEndToEndAutoPick(t *testing.T) {
|
|||||||
t.Errorf("expected imported file at %s: %v", want, err)
|
t.Errorf("expected imported file at %s: %v", want, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Staging was released only after a successful import.
|
// Staging release and the rescan happen *after* the state is
|
||||||
entries, err := os.ReadDir(f.staging.Root())
|
// recorded (manager.go sets StateComplete, then releases, then
|
||||||
if err != nil {
|
// scans), so waiting on the state is not waiting on these. Under
|
||||||
t.Fatalf("read staging root: %v", err)
|
// load the worker is descheduled in between and asserting straight
|
||||||
}
|
// away reads the world one step too early -- which is exactly how
|
||||||
|
// this test failed on a busy machine while passing alone.
|
||||||
|
waitFor(t, func() bool {
|
||||||
|
entries, err := os.ReadDir(f.staging.Root())
|
||||||
|
if err != nil || len(entries) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if len(entries) != 0 {
|
f.lib.mu.Lock()
|
||||||
t.Errorf("staging not released: %d dirs remain", len(entries))
|
defer f.lib.mu.Unlock()
|
||||||
}
|
|
||||||
|
|
||||||
// The library was told to rescan.
|
return len(f.lib.scanned) == 1
|
||||||
f.lib.mu.Lock()
|
}, "staging was never released, or the library was never rescanned")
|
||||||
scanned := len(f.lib.scanned)
|
|
||||||
f.lib.mu.Unlock()
|
|
||||||
|
|
||||||
if scanned != 1 {
|
|
||||||
t.Errorf("library scans = %d, want 1", scanned)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// An ambiguous result set must park for the user rather than guess.
|
// An ambiguous result set must park for the user rather than guess.
|
||||||
|
|||||||
@@ -20,6 +20,30 @@ func newServiceFixture(t *testing.T) serviceFixture {
|
|||||||
mf := newManagerFixture(t)
|
mf := newManagerFixture(t)
|
||||||
svc := NewService(slogDiscard(), mf.manager, mf.store, NewMemSecretStore())
|
svc := NewService(slogDiscard(), mf.manager, mf.store, NewMemSecretStore())
|
||||||
|
|
||||||
|
// Every test here is about the durable Request that `StartDownload`
|
||||||
|
// leaves behind, and none of them is about the download itself -- but
|
||||||
|
// their fixture is an anchored four-track request with a healthy
|
||||||
|
// provider, which is exactly what `AutoPickable` says yes to. So
|
||||||
|
// `Manager.Start` was firing `go m.grab(...)`, detached and with
|
||||||
|
// `context.WithoutCancel`, and the test then raced it.
|
||||||
|
//
|
||||||
|
// It lost, twice, in CI (`check` on c03c0b8, and nowhere locally):
|
||||||
|
//
|
||||||
|
// service_test.go:66: state = "satisfied", want wanted
|
||||||
|
// testing.go:1369: TempDir RemoveAll cleanup: ... directory not empty
|
||||||
|
//
|
||||||
|
// The first is the request reaching its *next* state before the
|
||||||
|
// assertion read it; the second is that same goroutine still writing
|
||||||
|
// into `t.TempDir()` after the test returned. One cause, two shapes.
|
||||||
|
//
|
||||||
|
// Putting the candidate outside the auto-pick size window stops the
|
||||||
|
// grab from ever starting, which is better than waiting for it: there
|
||||||
|
// is no goroutine to be slow, so the tests state what they mean
|
||||||
|
// ("the request exists, in this state") without a timing assumption
|
||||||
|
// underneath. A test that does want the download has `managerFixture`
|
||||||
|
// and sets its own preferences.
|
||||||
|
mf.manager.SetPreferences(AutoDownloadPrefs{MaxSizeMB: 1})
|
||||||
|
|
||||||
return serviceFixture{managerFixture: mf, svc: svc}
|
return serviceFixture{managerFixture: mf, svc: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNoRuntime is returned by Deliver when there is neither a test Sink
|
// ErrNoRuntime is returned by Deliver when there is neither a test Sink
|
||||||
@@ -82,16 +80,7 @@ func Deliver(ctx context.Context, name string, data ...any) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// application.Get() returns nil when no app is running — under test,
|
// emitRuntime is the only place the Wails application is touched,
|
||||||
// before Run, and after shutdown. It does not terminate the
|
// and it is behind a build tag: see runtime_wails.go.
|
||||||
// process, which is what the v2 probe of the private "events"
|
return emitRuntime(name, data...)
|
||||||
// context key existed to avoid.
|
|
||||||
app := application.Get()
|
|
||||||
if app == nil {
|
|
||||||
return ErrNoRuntime
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Event.Emit(name, data...)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// allowedEmitters are the only files permitted to call the Wails
|
// allowedEmitters are the only files permitted to call the Wails
|
||||||
// runtime's event emitter directly.
|
// runtime's event emitter directly. There is one call and it lives in
|
||||||
|
// runtime_wails.go rather than emit.go because naming the application
|
||||||
|
// package is what forces cgo, and cmd/indexbuild builds this package
|
||||||
|
// without it.
|
||||||
var allowedEmitters = map[string]bool{
|
var allowedEmitters = map[string]bool{
|
||||||
filepath.Join("backend", "events", "emit.go"): true,
|
filepath.Join("backend", "events", "runtime_wails.go"): true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNoDirectRuntimeEmits fails if anything outside backend/events
|
// TestNoDirectRuntimeEmits fails if anything outside backend/events
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build indexbuild
|
||||||
|
|
||||||
|
package events
|
||||||
|
|
||||||
|
// emitRuntime is the index tools' half of the split described in
|
||||||
|
// runtime_wails.go: under the indexbuild tag there is no Wails
|
||||||
|
// application to emit through, and importing the one that would be
|
||||||
|
// there costs cgo and a GTK/WebKit toolchain the index build container
|
||||||
|
// deliberately does not have.
|
||||||
|
//
|
||||||
|
// Returning ErrNoRuntime is the same answer the app gives before Run
|
||||||
|
// and after shutdown, so Emit's callers need no second code path: an
|
||||||
|
// event emitted by cmd/indexbuild is logged and dropped. A test that
|
||||||
|
// wants to observe one installs a Sink with WithSink, which Deliver
|
||||||
|
// consults first and which works under either tag.
|
||||||
|
func emitRuntime(_ string, _ ...any) error {
|
||||||
|
return ErrNoRuntime
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//go:build !indexbuild
|
||||||
|
|
||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// emitRuntime pushes an event through the running Wails application.
|
||||||
|
//
|
||||||
|
// It is the app's half of a two-file split, and the split exists for a
|
||||||
|
// build constraint rather than a design one: v3's application package
|
||||||
|
// is cgo on Linux, so anything importing it needs GTK and WebKit
|
||||||
|
// headers to compile. cmd/indexbuild and cmd/indexexport reach this
|
||||||
|
// package through backend/explore and are built in a plain golang
|
||||||
|
// container with CGO_ENABLED=0 (.gitea/workflows/index-artifact.yml),
|
||||||
|
// where that is not available and not wanted. See
|
||||||
|
// runtime_indexbuild.go for the other half.
|
||||||
|
func emitRuntime(name string, data ...any) error {
|
||||||
|
// application.Get() returns nil when no app is running — under
|
||||||
|
// test, before Run, and after shutdown. It does not terminate the
|
||||||
|
// process, which is what the v2 probe of the private "events"
|
||||||
|
// context key existed to avoid.
|
||||||
|
app := application.Get()
|
||||||
|
if app == nil {
|
||||||
|
return ErrNoRuntime
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Event.Emit(name, data...)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -27,6 +27,20 @@ var artifactStageNames = [...]string{
|
|||||||
// failure path is non-fatal by design: the caller falls back, and a
|
// failure path is non-fatal by design: the caller falls back, and a
|
||||||
// fresh install with no network still gets its own library in Explore.
|
// fresh install with no network still gets its own library in Explore.
|
||||||
func (si *SearchIndex) tryCoreArtifact(ctx context.Context) error {
|
func (si *SearchIndex) tryCoreArtifact(ctx context.Context) error {
|
||||||
|
// Before anything is staged: ~0.6 GB is not a download to start on
|
||||||
|
// someone's cellular allowance without being asked (plan 016 B4).
|
||||||
|
// This is checked first so no job appears and no status changes --
|
||||||
|
// declining is a no-op, not a failure the user has to dismiss.
|
||||||
|
if si.netPolicy.refuses() {
|
||||||
|
si.logIndexJob(
|
||||||
|
jobs.LevelInfo,
|
||||||
|
"Skipping the catalog download on a metered connection. "+
|
||||||
|
"Enable it in Settings to download anyway.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ErrMeteredNetwork
|
||||||
|
}
|
||||||
|
|
||||||
si.mu.Lock()
|
si.mu.Lock()
|
||||||
si.buildStatus = IndexStatus{
|
si.buildStatus = IndexStatus{
|
||||||
Building: true,
|
Building: true,
|
||||||
|
|||||||
@@ -283,6 +283,9 @@ func (si *SearchIndex) importCoreArtifact(ctx context.Context, path string) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
merged, mergeErr := si.mergeArtifactRows(ctx, info.rows)
|
merged, mergeErr := si.mergeArtifactRows(ctx, info.rows)
|
||||||
|
if mergeErr == nil {
|
||||||
|
si.mergeArtifactCredits(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
if ftsSuspended {
|
if ftsSuspended {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -473,3 +476,75 @@ func (si *SearchIndex) removeArtifactFile(path string) {
|
|||||||
si.logger.Warn("core artifact: cleanup failed", "path", path, "error", err)
|
si.logger.Warn("core artifact: cleanup failed", "path", path, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// artifactHasCredits reports whether the attached artifact carries the
|
||||||
|
// multi-artist credit tables.
|
||||||
|
//
|
||||||
|
// The same shape, and the same handle, as artifactHasTotals above: an
|
||||||
|
// artifact published before credits existed is still a perfectly good
|
||||||
|
// catalog, and there is one already out there. Selecting from a table
|
||||||
|
// that is not in it would fail an import that should have succeeded, so
|
||||||
|
// it is asked rather than assumed -- on the *writer*, because `core` is
|
||||||
|
// attached to that one connection and the read pool cannot see it.
|
||||||
|
func (si *SearchIndex) artifactHasCredits() bool {
|
||||||
|
var n int
|
||||||
|
|
||||||
|
err := si.db.QueryRowWriter(
|
||||||
|
`SELECT COUNT(*) FROM core.sqlite_master
|
||||||
|
WHERE type = 'table' AND name IN ('artist_credit_part', 'artist_credit_ref')`,
|
||||||
|
).Scan(&n)
|
||||||
|
|
||||||
|
return err == nil && n == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeArtifactCredits copies the credit decomposition out of the
|
||||||
|
// attached artifact.
|
||||||
|
//
|
||||||
|
// Credits are replaced wholesale rather than merged: they are derived
|
||||||
|
// entirely from one dump build, they are keyed by ids that are only
|
||||||
|
// meaningful within the artifact that carried them, and a half-updated
|
||||||
|
// credit renders as the wrong artists rather than as missing ones.
|
||||||
|
//
|
||||||
|
// A failure here is logged and not returned. The catalog has already
|
||||||
|
// merged at this point, and a catalog without credits is the catalog
|
||||||
|
// this app had before them -- every credit falls back to its single
|
||||||
|
// artist, which is the same fallback an untagged file already gets.
|
||||||
|
func (si *SearchIndex) mergeArtifactCredits(ctx context.Context) {
|
||||||
|
if !si.artifactHasCredits() {
|
||||||
|
si.logger.Info("core artifact: no credit tables, keeping single-artist credits")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
for _, stmt := range []string{
|
||||||
|
"DELETE FROM artist_credit_part",
|
||||||
|
"DELETE FROM artist_credit_ref",
|
||||||
|
`INSERT OR REPLACE INTO artist_credit_part
|
||||||
|
(credit_id, position, artist_mbid, credited_name, join_phrase)
|
||||||
|
SELECT credit_id, position, artist_mbid, credited_name, join_phrase
|
||||||
|
FROM core.artist_credit_part`,
|
||||||
|
`INSERT OR REPLACE INTO artist_credit_ref (mbid, credit_id)
|
||||||
|
SELECT mbid, credit_id FROM core.artist_credit_ref`,
|
||||||
|
} {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := si.db.ExecContext(stmt); err != nil {
|
||||||
|
si.logger.Warn("core artifact: credit merge failed", "error", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var refs int
|
||||||
|
|
||||||
|
_ = si.db.QueryRowWriter("SELECT COUNT(*) FROM artist_credit_ref").Scan(&refs)
|
||||||
|
|
||||||
|
si.logger.Info("core artifact: credits merged",
|
||||||
|
"entities", refs,
|
||||||
|
"elapsed", time.Since(start).Round(time.Millisecond),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package explore
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -597,3 +598,153 @@ func TestImportCoreArtifactReadsTotalsWhenPresent(t *testing.T) {
|
|||||||
t.Errorf("TotalTracks = %d, want 0 (the catalog does not say)", old.TotalTracks)
|
t.Errorf("TotalTracks = %d, want 0 (the catalog does not say)", old.TotalTracks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addArtifactCredits gives an artifact file the credit tables the
|
||||||
|
// exporter now writes, so the import path can be exercised against one
|
||||||
|
// that has them.
|
||||||
|
func addArtifactCredits(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", "file:"+path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open artifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`CREATE TABLE artist_credit_part (
|
||||||
|
credit_id INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
artist_mbid BLOB NOT NULL,
|
||||||
|
credited_name TEXT NOT NULL,
|
||||||
|
join_phrase TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (credit_id, position)
|
||||||
|
) WITHOUT ROWID`,
|
||||||
|
`CREATE TABLE artist_credit_ref (
|
||||||
|
mbid BLOB NOT NULL PRIMARY KEY,
|
||||||
|
credit_id INTEGER NOT NULL
|
||||||
|
) WITHOUT ROWID`,
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(stmt); err != nil {
|
||||||
|
t.Fatalf("create credit tables: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The packed form the catalog stores. uuid16/parseUUID live behind
|
||||||
|
// the indexbuild tag, so this file decodes for itself.
|
||||||
|
pack := func(mbid string) []byte {
|
||||||
|
raw, err := hex.DecodeString(strings.ReplaceAll(mbid, "-", ""))
|
||||||
|
if err != nil || len(raw) != 16 {
|
||||||
|
t.Fatalf("fixture MBID %q is not a UUID: %v", mbid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
a, b, rec := pack(artA), pack(artB), pack(recA)
|
||||||
|
|
||||||
|
for _, part := range [][]any{
|
||||||
|
{7, 0, a, "Artist A", " feat. "},
|
||||||
|
{7, 1, b, "Artist B", ""},
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(`INSERT INTO artist_credit_part
|
||||||
|
(credit_id, position, artist_mbid, credited_name, join_phrase)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`, part...); err != nil {
|
||||||
|
t.Fatalf("insert part: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec(
|
||||||
|
"INSERT INTO artist_credit_ref (mbid, credit_id) VALUES (?, ?)", rec, 7,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert ref: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportCoreArtifactMergesCredits is the positive half of the
|
||||||
|
// compatibility pair: an artifact that carries credits delivers them,
|
||||||
|
// rendering back to the credit string they decompose.
|
||||||
|
func TestImportCoreArtifactMergesCredits(t *testing.T) {
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||||
|
|
||||||
|
path := writeTestArtifact(t, validMeta(), []artifactRow{
|
||||||
|
{"recording", recA, "Song A", "Artist A feat. Artist B", artA, 2000},
|
||||||
|
})
|
||||||
|
|
||||||
|
addArtifactCredits(t, path)
|
||||||
|
|
||||||
|
if err := si.importCoreArtifact(context.Background(), path); err != nil {
|
||||||
|
t.Fatalf("importCoreArtifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(
|
||||||
|
`SELECT p.credited_name, p.join_phrase
|
||||||
|
FROM artist_credit_ref r
|
||||||
|
JOIN artist_credit_part p ON p.credit_id = r.credit_id
|
||||||
|
ORDER BY p.position`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query credits: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var rendered strings.Builder
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var name, join string
|
||||||
|
|
||||||
|
if err := rows.Scan(&name, &join); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered.WriteString(name)
|
||||||
|
rendered.WriteString(join)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := rendered.String(); got != "Artist A feat. Artist B" {
|
||||||
|
t.Errorf("rendered credit = %q, want %q", got, "Artist A feat. Artist B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportCoreArtifactWithoutCredits is the regression that matters
|
||||||
|
// most here: an artifact published before credits existed cannot be
|
||||||
|
// re-cut retroactively, so it must import as a catalog that declines to
|
||||||
|
// answer rather than failing outright. writeTestArtifact deliberately
|
||||||
|
// builds one without the tables.
|
||||||
|
func TestImportCoreArtifactWithoutCredits(t *testing.T) {
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||||
|
|
||||||
|
path := writeTestArtifact(t, validMeta(), []artifactRow{
|
||||||
|
{"recording", recA, "Song A", "Artist A", artA, 2000},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := si.importCoreArtifact(context.Background(), path); err != nil {
|
||||||
|
t.Fatalf("an artifact without credit tables must still import: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows int
|
||||||
|
if err := db.QueryRowWriter(
|
||||||
|
"SELECT COUNT(*) FROM explore_index",
|
||||||
|
).Scan(&rows); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows != 1 {
|
||||||
|
t.Errorf("catalog rows = %d, want 1", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refs int
|
||||||
|
if err := db.QueryRowWriter(
|
||||||
|
"SELECT COUNT(*) FROM artist_credit_ref",
|
||||||
|
).Scan(&refs); err != nil {
|
||||||
|
t.Fatalf("count refs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if refs != 0 {
|
||||||
|
t.Errorf("credit refs = %d, want 0", refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,23 +46,42 @@ func TestCacheMiss(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCacheTTLExpiry checks both halves of the TTL contract, and uses two
|
||||||
|
// entries to do it.
|
||||||
|
//
|
||||||
|
// **No assertion here may depend on an upper bound of elapsed wall-clock
|
||||||
|
// time**, which is what the single-entry version of this test did: it set
|
||||||
|
// a 1s TTL and immediately asserted a *hit*, so on a loaded runner — one
|
||||||
|
// goroutine descheduled for over a second while the rest of the suite
|
||||||
|
// runs — the entry was correctly gone and the test failed with "expected
|
||||||
|
// cache hit immediately after set". It did exactly that in CI while
|
||||||
|
// passing five times out of five locally.
|
||||||
|
//
|
||||||
|
// Sleeping *past* a TTL is always safe, so the expiry half keeps a short
|
||||||
|
// one; the presence half gets a TTL nothing can outrun.
|
||||||
func TestCacheTTLExpiry(t *testing.T) {
|
func TestCacheTTLExpiry(t *testing.T) {
|
||||||
c := newTestCache(t)
|
c := newTestCache(t)
|
||||||
|
|
||||||
data := []byte(`{"ephemeral":true}`)
|
data := []byte(`{"ephemeral":true}`)
|
||||||
c.Set("ttl-test-key", data, 1*time.Second, "", "")
|
c.Set("ttl-live-key", data, time.Hour, "", "")
|
||||||
|
c.Set("ttl-expiring-key", data, 1*time.Second, "", "")
|
||||||
|
|
||||||
// Verify it's there immediately.
|
if _, ok := c.Get("ttl-live-key"); !ok {
|
||||||
if _, ok := c.Get("ttl-test-key"); !ok {
|
t.Fatal("expected a cache hit on an entry with an hour to live")
|
||||||
t.Fatal("expected cache hit immediately after set")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for expiry.
|
// Wait for the short one to expire.
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
if _, ok := c.Get("ttl-test-key"); ok {
|
if _, ok := c.Get("ttl-expiring-key"); ok {
|
||||||
t.Error("expected cache miss after TTL expiry, got hit")
|
t.Error("expected cache miss after TTL expiry, got hit")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// And the long-lived entry is still there, which is what says the
|
||||||
|
// sweep above expired an entry rather than the cache.
|
||||||
|
if _, ok := c.Get("ttl-live-key"); !ok {
|
||||||
|
t.Error("the hour-long entry expired too")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheMBID(t *testing.T) {
|
func TestCacheMBID(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package explore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reading multi-artist credits back out of the catalog.
|
||||||
|
//
|
||||||
|
// The tables are filled centrally (backend/explore/dumpcredits.go, and
|
||||||
|
// the artifact import) and hold only credits naming more than one
|
||||||
|
// artist: an entity with no rows here is credited to one artist, which
|
||||||
|
// explore_index's own artist_name and artist_mbid already describe.
|
||||||
|
// Absence is the common case and means "nothing to decompose", never
|
||||||
|
// "unknown".
|
||||||
|
//
|
||||||
|
// The lookup is keyed on the *recording* MBID, which both sides of the
|
||||||
|
// app already have -- a catalog row carries it and so does a local
|
||||||
|
// file (library.Track.RecordingMBID) -- so one query serves the Explore
|
||||||
|
// pages and the library's own lists without either needing to know
|
||||||
|
// where the other gets its rows.
|
||||||
|
|
||||||
|
// CreditPart is one credited artist within a credit, in credit order.
|
||||||
|
//
|
||||||
|
// CreditedName is the name *as credited*, which is not the artist's own
|
||||||
|
// name: MusicBrainz credits "Snoop Dogg" on a track by the artist
|
||||||
|
// called "Snoop Doggy Dogg". Display uses it; navigation uses
|
||||||
|
// ArtistMBID. JoinPhrase is the literal connector that follows this
|
||||||
|
// part, so a credit renders by concatenation and never by searching a
|
||||||
|
// name inside a credit string.
|
||||||
|
type CreditPart struct {
|
||||||
|
Position int `json:"position"`
|
||||||
|
ArtistMBID string `json:"artistMbid"`
|
||||||
|
CreditedName string `json:"creditedName"`
|
||||||
|
JoinPhrase string `json:"joinPhrase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// creditLookupBatch bounds how many MBIDs go into one IN clause. A
|
||||||
|
// tracklist is the caller here, so the realistic ceiling is a few
|
||||||
|
// hundred; the bound exists so a 50,000-row selection cannot build a
|
||||||
|
// statement SQLite refuses to parse.
|
||||||
|
const creditLookupBatch = 500
|
||||||
|
|
||||||
|
// GetCredits returns the decomposition of every multi-artist credit
|
||||||
|
// among the given entity MBIDs, keyed by MBID.
|
||||||
|
//
|
||||||
|
// MBIDs with a single-artist credit are simply absent from the result,
|
||||||
|
// which is what the caller wants: it renders its existing single link
|
||||||
|
// for those, and that is the same answer it would have rendered anyway.
|
||||||
|
func (si *SearchIndex) GetCredits(mbids []string) (map[string][]CreditPart, error) {
|
||||||
|
out := make(map[string][]CreditPart)
|
||||||
|
|
||||||
|
for start := 0; start < len(mbids); start += creditLookupBatch {
|
||||||
|
end := min(start+creditLookupBatch, len(mbids))
|
||||||
|
|
||||||
|
if err := si.appendCredits(mbids[start:end], out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendCredits runs one batch into the accumulating result.
|
||||||
|
func (si *SearchIndex) appendCredits(
|
||||||
|
mbids []string, out map[string][]CreditPart,
|
||||||
|
) error {
|
||||||
|
args := make([]any, 0, len(mbids))
|
||||||
|
holders := make([]string, 0, len(mbids))
|
||||||
|
|
||||||
|
for _, mbid := range mbids {
|
||||||
|
if mbid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, dbMBID(mbid))
|
||||||
|
holders = append(holders, "?")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered by position because that ordering *is* the credit's
|
||||||
|
// meaning; the caller concatenates in the order it receives.
|
||||||
|
rows, err := si.db.QueryContext(
|
||||||
|
`SELECT r.mbid, p.position, p.artist_mbid, p.credited_name, p.join_phrase
|
||||||
|
FROM artist_credit_ref r
|
||||||
|
JOIN artist_credit_part p ON p.credit_id = r.credit_id
|
||||||
|
WHERE r.mbid IN (`+strings.Join(holders, ",")+`)
|
||||||
|
ORDER BY r.mbid, p.position`,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read artist credits: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
entity dbMBID
|
||||||
|
artist dbMBID
|
||||||
|
part CreditPart
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&entity, &part.Position, &artist, &part.CreditedName, &part.JoinPhrase,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("scan artist credit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
part.ArtistMBID = string(artist)
|
||||||
|
out[string(entity)] = append(out[string(entity)], part)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("read artist credits: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCredits is the bound form: the frontend asks for a tracklist's
|
||||||
|
// worth of MBIDs at once rather than one per row.
|
||||||
|
//
|
||||||
|
// Batched for the reason every other per-row backend question here is:
|
||||||
|
// asking on hover or on render turns a list into N IPC round trips, and
|
||||||
|
// this one is asked about every row of every list in the app.
|
||||||
|
func (e *Service) GetCredits(mbids []string) (map[string][]CreditPart, error) {
|
||||||
|
return e.index.GetCredits(mbids)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package explore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedCredit writes one multi-artist credit and points an entity at it,
|
||||||
|
// the way the dump import and the artifact import both do.
|
||||||
|
func seedCredit(t *testing.T, db *database.DB, entity string, id int, parts []CreditPart) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
pack := func(mbid string) []byte {
|
||||||
|
raw, err := hex.DecodeString(strings.ReplaceAll(mbid, "-", ""))
|
||||||
|
if err != nil || len(raw) != 16 {
|
||||||
|
t.Fatalf("bad fixture mbid %q: %v", mbid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
"INSERT INTO artist_credit_ref (mbid, credit_id) VALUES (?, ?)",
|
||||||
|
pack(entity), id,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed ref: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range parts {
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`INSERT INTO artist_credit_part
|
||||||
|
(credit_id, position, artist_mbid, credited_name, join_phrase)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
id, p.Position, pack(p.ArtistMBID), p.CreditedName, p.JoinPhrase,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed part: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetCreditsDecomposes: the parts come back in position order and
|
||||||
|
// concatenate to the credit they describe.
|
||||||
|
func TestGetCreditsDecomposes(t *testing.T) {
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||||
|
|
||||||
|
rec := testMBID("rec-1")
|
||||||
|
a, b := testMBID("artist-a"), testMBID("artist-b")
|
||||||
|
|
||||||
|
seedCredit(t, db, rec, 7, []CreditPart{
|
||||||
|
{Position: 0, ArtistMBID: a, CreditedName: "2Pac", JoinPhrase: " feat. "},
|
||||||
|
{Position: 1, ArtistMBID: b, CreditedName: "Snoop Dogg"},
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := si.GetCredits([]string{rec})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCredits: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := got[rec]
|
||||||
|
if len(parts) != 2 {
|
||||||
|
t.Fatalf("parts = %d, want 2", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
var rendered strings.Builder
|
||||||
|
for _, p := range parts {
|
||||||
|
rendered.WriteString(p.CreditedName)
|
||||||
|
rendered.WriteString(p.JoinPhrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rendered.String() != "2Pac feat. Snoop Dogg" {
|
||||||
|
t.Errorf("rendered = %q, want %q", rendered.String(), "2Pac feat. Snoop Dogg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashed on the way out: a blob reaching the frontend is sixteen
|
||||||
|
// bytes of mojibake, and nothing above mbid.go speaks that.
|
||||||
|
if parts[0].ArtistMBID != a {
|
||||||
|
t.Errorf("artist mbid = %q, want %q", parts[0].ArtistMBID, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetCreditsOmitsSingleArtist: absence is the common case and means
|
||||||
|
// "nothing to decompose", so the caller renders its existing one link.
|
||||||
|
func TestGetCreditsOmitsSingleArtist(t *testing.T) {
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||||
|
|
||||||
|
got, err := si.GetCredits([]string{testMBID("untagged"), ""})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCredits: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("got %d credits, want none", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetCreditsBatches: the lookup is asked about whole tracklists, so
|
||||||
|
// it must not build one statement per row or one SQLite refuses to
|
||||||
|
// parse.
|
||||||
|
func TestGetCreditsBatches(t *testing.T) {
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||||
|
|
||||||
|
mbids := make([]string, 0, creditLookupBatch*2+7)
|
||||||
|
for i := range creditLookupBatch*2 + 7 {
|
||||||
|
mbids = append(mbids, testMBID(fmt.Sprintf("batch-%d", i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// One real credit somewhere past the first batch boundary.
|
||||||
|
seedCredit(t, db, mbids[creditLookupBatch+3], 9, []CreditPart{
|
||||||
|
{Position: 0, ArtistMBID: testMBID("a"), CreditedName: "A", JoinPhrase: " & "},
|
||||||
|
{Position: 1, ArtistMBID: testMBID("b"), CreditedName: "B"},
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := si.GetCredits(mbids)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCredits: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got[mbids[creditLookupBatch+3]]) != 2 {
|
||||||
|
t.Errorf("a credit past the first batch boundary was not returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
//go:build indexbuild
|
||||||
|
|
||||||
|
package explore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bufio"
|
||||||
|
"compress/bzip2"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Multi-artist credits, from the core MusicBrainz dump.
|
||||||
|
//
|
||||||
|
// A credit is ordered parts and the credit *string* is derived from
|
||||||
|
// them; MusicBrainz's own artist_credit.name is a cached render. What
|
||||||
|
// this pass extracts is the decomposition: for each catalog recording
|
||||||
|
// and release group whose credit names more than one artist, the
|
||||||
|
// credited artists in order, each with the name *as credited* and the
|
||||||
|
// join phrase that follows it. See artist_credit_part.sql for why that
|
||||||
|
// is stored rather than derived, and why nothing may reconstruct a
|
||||||
|
// credit by searching a name inside a credit string.
|
||||||
|
//
|
||||||
|
// It is a separate dump from everything else here, and it has to be.
|
||||||
|
// The canonical dump this importer already streams gives artist_mbids
|
||||||
|
// (an ordered list) and artist_credit_name (the *rendered* string) --
|
||||||
|
// no join phrases, and no per-artist as-credited names. Splitting the
|
||||||
|
// rendered string using canonical artist names fails on exactly the
|
||||||
|
// credits that matter: measured on a real library, 21% of multi-artist
|
||||||
|
// credits name an artist differently from the artist's own name
|
||||||
|
// ("Snoop Dogg" credited on a track by "Snoop Doggy Dogg"), so the
|
||||||
|
// substring is simply not there. The JSON dumps were checked too and
|
||||||
|
// cover 153,691 recordings of ~35M, with zero overlap against a real
|
||||||
|
// library. This dump is the only source.
|
||||||
|
//
|
||||||
|
// Cost, measured on the 20260815 export: 7.1 GB compressed, decompressed
|
||||||
|
// by pure-Go compress/bzip2 at ~26 MB/s uncompressed (~13.7 min for the
|
||||||
|
// whole file, single-threaded). cmd/indexbuild is built CGO_ENABLED=0,
|
||||||
|
// so the stdlib decompressor is what there is -- and it is fine, because
|
||||||
|
// the 2 MB/s origin throttle dominates, as it does for every other dump
|
||||||
|
// here.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// defaultMBDumpBaseURL is the core MusicBrainz export. Only
|
||||||
|
// mbdump.tar.bz2 is fetched; the other tarballs there hold data this
|
||||||
|
// app has no use for.
|
||||||
|
defaultMBDumpBaseURL = "https://data.metabrainz.org/pub/musicbrainz/data/fullexport/"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mbdumpDirRe = regexp.MustCompile(`^\d{8}-\d+$`)
|
||||||
|
mbdumpFileRe = regexp.MustCompile(`^mbdump\.tar\.bz2$`)
|
||||||
|
|
||||||
|
// ErrDumpShape is returned when a dump member does not have the
|
||||||
|
// columns this code was written against. It is deliberately fatal:
|
||||||
|
// reading the wrong column silently produces a catalog whose credits
|
||||||
|
// are subtly wrong, which is far worse than a failed build.
|
||||||
|
ErrDumpShape = errors.New("musicbrainz dump member has an unexpected shape")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Column positions in the Postgres COPY output, verified against the
|
||||||
|
// 20260815 export. There is no header row to read them from, so they
|
||||||
|
// are asserted instead -- see checkShape.
|
||||||
|
const (
|
||||||
|
artistColID = 0
|
||||||
|
artistColGID = 1
|
||||||
|
artistColMin = 2
|
||||||
|
|
||||||
|
creditColID = 0
|
||||||
|
creditColArtistCount = 2
|
||||||
|
creditColMin = 3
|
||||||
|
|
||||||
|
partColCredit = 0
|
||||||
|
partColPosition = 1
|
||||||
|
partColArtist = 2
|
||||||
|
partColName = 3
|
||||||
|
partColJoin = 4
|
||||||
|
partColMin = 5
|
||||||
|
|
||||||
|
// recording and release_group share a layout in the columns this
|
||||||
|
// pass reads: id, gid, name, artist_credit, ...
|
||||||
|
entityColGID = 1
|
||||||
|
entityColCredit = 3
|
||||||
|
entityColMin = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// creditPart is one credited artist within a credit.
|
||||||
|
type creditPart struct {
|
||||||
|
position int
|
||||||
|
artistID int32
|
||||||
|
name string
|
||||||
|
join string
|
||||||
|
}
|
||||||
|
|
||||||
|
// creditScan is what one pass over the dump collects.
|
||||||
|
type creditScan struct {
|
||||||
|
// artistGIDs maps an artist row id to its MBID. artist_credit_name
|
||||||
|
// references artists by row id, and the tar orders `artist` before
|
||||||
|
// it, so this is complete by the time it is read.
|
||||||
|
artistGIDs map[int32]uuid16
|
||||||
|
|
||||||
|
// multiCredits are the credit ids naming more than one artist, from
|
||||||
|
// artist_credit.artist_count. Taking the count from the dump rather
|
||||||
|
// than counting parts means a credit can be rejected before its
|
||||||
|
// parts are stored.
|
||||||
|
multiCredits map[int32]struct{}
|
||||||
|
|
||||||
|
// parts are the decompositions of multiCredits, keyed by credit id.
|
||||||
|
parts map[int32][]creditPart
|
||||||
|
|
||||||
|
// refs maps a kept catalog entity to its credit. Only entities in
|
||||||
|
// explore_index and only multi-artist credits: everything else is
|
||||||
|
// already described by explore_index's own artist_name/artist_mbid.
|
||||||
|
refs map[uuid16]int32
|
||||||
|
|
||||||
|
// used are the credits some ref actually points at, which is a small
|
||||||
|
// fraction of multiCredits -- the catalog keeps ~1.8M entities of
|
||||||
|
// MusicBrainz's tens of millions.
|
||||||
|
used map[int32]struct{}
|
||||||
|
|
||||||
|
skippedUnknownArtist int
|
||||||
|
}
|
||||||
|
|
||||||
|
// creditsImportDoneKey marks in explore_index_meta that the credit pass
|
||||||
|
// has run against the current catalog.
|
||||||
|
//
|
||||||
|
// It is its own marker rather than part of the import's stage state for
|
||||||
|
// a resume reason: the credit pass runs *after* the catalog is
|
||||||
|
// assembled, and a failure in it must not send the next run back
|
||||||
|
// through the ~205 GB it just finished. Marking separately means a
|
||||||
|
// retry retries only this.
|
||||||
|
const creditsImportDoneKey = "credits_import_done"
|
||||||
|
|
||||||
|
// ensureArtistCredits runs the credit pass unless it has already run
|
||||||
|
// against this catalog, reporting whether it newly populated them.
|
||||||
|
//
|
||||||
|
// Called from both of run's paths -- the full import and the resume
|
||||||
|
// that finds the rows already assembled -- and from the maintenance
|
||||||
|
// entry point below, since a catalog built before credits existed is
|
||||||
|
// otherwise never offered a chance to gain them: the index job picks
|
||||||
|
// its mode from the index's own state, and a complete import means
|
||||||
|
// "refresh", which never enters run() at all.
|
||||||
|
//
|
||||||
|
// The return value is what tells the job there is something new worth
|
||||||
|
// publishing. A refresh otherwise reports "changed" only when the
|
||||||
|
// listens series advanced, so credits would sit in the CI database and
|
||||||
|
// never reach an artifact.
|
||||||
|
func (imp *dumpImporter) ensureArtistCredits(ctx context.Context) bool {
|
||||||
|
if imp.si.hasMeta(creditsImportDoneKey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := discoverDumpFile(
|
||||||
|
ctx, imp.httpClient, imp.mbdumpBaseURL, mbdumpDirRe, mbdumpFileRe,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
imp.logger.Warn("credit import: could not find the dump", "error", err)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := imp.importArtistCredits(ctx, url); err != nil {
|
||||||
|
// A catalog without credits is the catalog this app shipped
|
||||||
|
// before them: every credit falls back to its single artist.
|
||||||
|
// That is worth far less than failing an import that otherwise
|
||||||
|
// succeeded.
|
||||||
|
imp.logger.Warn("credit import: failed", "error", err)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
imp.si.setMeta(creditsImportDoneKey, "1")
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureArtistCredits tops up the credit tables outside a full import.
|
||||||
|
//
|
||||||
|
// It exists because the index job's modes are decided from the index's
|
||||||
|
// own state: a cache holding a completed import chooses `refresh`,
|
||||||
|
// which folds in incremental listens and never enters the dump
|
||||||
|
// importer. Without this, a catalog built before the credit pass
|
||||||
|
// existed could only gain credits from a `rebuild` -- and a rebuild
|
||||||
|
// re-downloads ~205 GB to reproduce rows it already has, to add
|
||||||
|
// something that costs 7 GB on its own.
|
||||||
|
//
|
||||||
|
// Reports whether credits were newly populated, so the caller knows
|
||||||
|
// there is a new artifact worth publishing.
|
||||||
|
func (e *Service) EnsureArtistCredits(ctx context.Context) bool {
|
||||||
|
imp, err := newDumpImporter(e.index, e.lb)
|
||||||
|
if err != nil {
|
||||||
|
e.index.logger.Warn("credit import: could not start", "error", err)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return imp.ensureArtistCredits(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// importArtistCredits streams the core MusicBrainz dump and fills
|
||||||
|
// artist_credit_part and artist_credit_ref for the entities the catalog
|
||||||
|
// kept.
|
||||||
|
//
|
||||||
|
// It runs after assembleIndex because it asks explore_index which
|
||||||
|
// entities those are: the popularity filter decides what is worth
|
||||||
|
// carrying credits for, and asking the table rather than the kept sets
|
||||||
|
// means this stays correct if that filter changes.
|
||||||
|
func (imp *dumpImporter) importArtistCredits(ctx context.Context, url string) error {
|
||||||
|
kept, err := imp.keptEntityMBIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(kept) == 0 {
|
||||||
|
imp.logger.Warn("credit import: no catalog entities, skipping")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
imp.logger.Info("credit import: starting", "url", url, "entities", len(kept))
|
||||||
|
imp.logJob("Streaming MusicBrainz dump for artist credits")
|
||||||
|
|
||||||
|
scan, err := imp.scanCreditDump(ctx, url, kept)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
imp.logger.Info("credit import: scanned",
|
||||||
|
"multiArtistCredits", len(scan.multiCredits),
|
||||||
|
"entitiesWithMultiArtistCredit", len(scan.refs),
|
||||||
|
"creditsUsed", len(scan.used),
|
||||||
|
)
|
||||||
|
|
||||||
|
return imp.writeCredits(ctx, scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keptEntityMBIDs is every recording and release group in the catalog.
|
||||||
|
// Artists are excluded: an artist is not credited to a credit.
|
||||||
|
func (imp *dumpImporter) keptEntityMBIDs(ctx context.Context) (map[uuid16]struct{}, error) {
|
||||||
|
rows, err := imp.si.db.QueryContextWith(ctx,
|
||||||
|
`SELECT mbid FROM explore_index
|
||||||
|
WHERE entity_type IN (2 /* release_group */, 3 /* recording */)`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("credit import: read catalog entities: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
out := make(map[uuid16]struct{})
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var raw []byte
|
||||||
|
|
||||||
|
if err := rows.Scan(&raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("credit import: scan mbid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(raw) != len(uuid16{}) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var id uuid16
|
||||||
|
|
||||||
|
copy(id[:], raw)
|
||||||
|
|
||||||
|
out[id] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("credit import: read catalog entities: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanCreditDump makes one sequential pass over mbdump.tar.bz2.
|
||||||
|
//
|
||||||
|
// The tar's members are alphabetical, which is what makes a single pass
|
||||||
|
// possible without buffering the big ones: `artist` and
|
||||||
|
// `artist_credit_name` both arrive before `recording` and
|
||||||
|
// `release_group`, so by the time an entity names a credit, that
|
||||||
|
// credit's parts and their artists' MBIDs are already known and the
|
||||||
|
// entity can be resolved and dropped. 35M recording rows are never
|
||||||
|
// held.
|
||||||
|
//
|
||||||
|
// The order is not depended on blindly: an entity naming a credit that
|
||||||
|
// has not been seen is counted and reported rather than silently
|
||||||
|
// producing an empty catalog, which is what a reordered export would
|
||||||
|
// otherwise look like.
|
||||||
|
func (imp *dumpImporter) scanCreditDump(
|
||||||
|
ctx context.Context, url string, kept map[uuid16]struct{},
|
||||||
|
) (*creditScan, error) {
|
||||||
|
stream := imp.openDumpStream(ctx, url, 0)
|
||||||
|
|
||||||
|
defer func() { _ = stream.Close() }()
|
||||||
|
|
||||||
|
return imp.scanCreditTar(
|
||||||
|
ctx,
|
||||||
|
tar.NewReader(bzip2.NewReader(bufio.NewReaderSize(stream, 1<<20))),
|
||||||
|
kept,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanCreditTar is the parse, separated from the fetch so it can be
|
||||||
|
// driven by a tar built in a test. compress/bzip2 is decompress-only,
|
||||||
|
// so a test cannot produce the real container.
|
||||||
|
func (imp *dumpImporter) scanCreditTar(
|
||||||
|
ctx context.Context, tr *tar.Reader, kept map[uuid16]struct{},
|
||||||
|
) (*creditScan, error) {
|
||||||
|
scan := &creditScan{
|
||||||
|
artistGIDs: make(map[int32]uuid16),
|
||||||
|
multiCredits: make(map[int32]struct{}),
|
||||||
|
parts: make(map[int32][]creditPart),
|
||||||
|
refs: make(map[uuid16]int32),
|
||||||
|
used: make(map[int32]struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("credit import: tar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hdr.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
done, err := imp.scanCreditMember(ctx, hdr.Name, tr, kept, scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if done {
|
||||||
|
// Everything this pass needs has been read; the rest of the
|
||||||
|
// tarball is other entities' data and decompressing it would
|
||||||
|
// cost minutes for nothing.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if scan.skippedUnknownArtist > 0 {
|
||||||
|
imp.logger.Warn("credit import: credits dropped for unknown artists",
|
||||||
|
"count", scan.skippedUnknownArtist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanCreditMember dispatches one tar member, reporting whether the
|
||||||
|
// pass has everything it needs.
|
||||||
|
func (imp *dumpImporter) scanCreditMember(
|
||||||
|
ctx context.Context, name string, r io.Reader,
|
||||||
|
kept map[uuid16]struct{}, scan *creditScan,
|
||||||
|
) (bool, error) {
|
||||||
|
switch path.Base(name) {
|
||||||
|
case "artist":
|
||||||
|
return false, imp.scanArtists(ctx, r, scan)
|
||||||
|
case "artist_credit":
|
||||||
|
return false, imp.scanCredits(ctx, r, scan)
|
||||||
|
case "artist_credit_name":
|
||||||
|
return false, imp.scanCreditParts(ctx, r, scan)
|
||||||
|
case "recording", "release_group":
|
||||||
|
if err := imp.scanCreditedEntities(ctx, r, kept, scan); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// release_group sorts after recording, so the pass is complete
|
||||||
|
// once it has been read.
|
||||||
|
return path.Base(name) == "release_group", nil
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanArtists records every artist's MBID by row id.
|
||||||
|
func (imp *dumpImporter) scanArtists(
|
||||||
|
ctx context.Context, r io.Reader, scan *creditScan,
|
||||||
|
) error {
|
||||||
|
return scanTSV(ctx, r, artistColMin, "artist", func(fields []string) error {
|
||||||
|
id, ok := parseInt32(fields[artistColID])
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var gid uuid16
|
||||||
|
|
||||||
|
if !parseUUID(fields[artistColGID], gid[:]) {
|
||||||
|
return fmt.Errorf("%w: artist.gid is not a UUID: %q",
|
||||||
|
ErrDumpShape, truncate(fields[artistColGID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
scan.artistGIDs[id] = gid
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanCredits records which credits name more than one artist.
|
||||||
|
func (imp *dumpImporter) scanCredits(
|
||||||
|
ctx context.Context, r io.Reader, scan *creditScan,
|
||||||
|
) error {
|
||||||
|
return scanTSV(ctx, r, creditColMin, "artist_credit", func(fields []string) error {
|
||||||
|
id, ok := parseInt32(fields[creditColID])
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
count, ok := parseInt32(fields[creditColArtistCount])
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: artist_credit.artist_count is not a number: %q",
|
||||||
|
ErrDumpShape, truncate(fields[creditColArtistCount]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 1 {
|
||||||
|
scan.multiCredits[id] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanCreditParts records the decomposition of every multi-artist
|
||||||
|
// credit.
|
||||||
|
func (imp *dumpImporter) scanCreditParts(
|
||||||
|
ctx context.Context, r io.Reader, scan *creditScan,
|
||||||
|
) error {
|
||||||
|
return scanTSV(ctx, r, partColMin, "artist_credit_name", func(fields []string) error {
|
||||||
|
credit, ok := parseInt32(fields[partColCredit])
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, multi := scan.multiCredits[credit]; !multi {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
position, ok := parseInt32(fields[partColPosition])
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, ok := parseInt32(fields[partColArtist])
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
scan.parts[credit] = append(scan.parts[credit], creditPart{
|
||||||
|
position: int(position),
|
||||||
|
artistID: artist,
|
||||||
|
name: fields[partColName],
|
||||||
|
join: fields[partColJoin],
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanCreditedEntities resolves recordings and release groups against
|
||||||
|
// the catalog, keeping only those the catalog holds and whose credit
|
||||||
|
// names more than one artist.
|
||||||
|
func (imp *dumpImporter) scanCreditedEntities(
|
||||||
|
ctx context.Context, r io.Reader, kept map[uuid16]struct{}, scan *creditScan,
|
||||||
|
) error {
|
||||||
|
return scanTSV(ctx, r, entityColMin, "recording/release_group",
|
||||||
|
func(fields []string) error {
|
||||||
|
var gid uuid16
|
||||||
|
|
||||||
|
if !parseUUID(fields[entityColGID], gid[:]) {
|
||||||
|
return fmt.Errorf("%w: entity gid is not a UUID: %q",
|
||||||
|
ErrDumpShape, truncate(fields[entityColGID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, want := kept[gid]; !want {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
credit, ok := parseInt32(fields[entityColCredit])
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: entity artist_credit is not a number: %q",
|
||||||
|
ErrDumpShape, truncate(fields[entityColCredit]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, multi := scan.multiCredits[credit]; !multi {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
scan.refs[gid] = credit
|
||||||
|
scan.used[credit] = struct{}{}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanTSV reads Postgres COPY output a line at a time, unescaping each
|
||||||
|
// field and handing the row to fn.
|
||||||
|
//
|
||||||
|
// The shape is asserted on the first row rather than trusted: this dump
|
||||||
|
// has no header, so a column that moved would otherwise be read as a
|
||||||
|
// neighbouring one and produce a catalog that is quietly wrong.
|
||||||
|
func scanTSV(
|
||||||
|
ctx context.Context, r io.Reader, minCols int, member string,
|
||||||
|
fn func(fields []string) error,
|
||||||
|
) error {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 0, 1<<20), 1<<24)
|
||||||
|
|
||||||
|
checked := false
|
||||||
|
rows := 0
|
||||||
|
|
||||||
|
for sc.Scan() {
|
||||||
|
rows++
|
||||||
|
|
||||||
|
if rows%(1<<20) == 0 {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
line := sc.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
if len(fields) < minCols {
|
||||||
|
if !checked {
|
||||||
|
return fmt.Errorf("%w: %s has %d columns, need at least %d",
|
||||||
|
ErrDumpShape, member, len(fields), minCols)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
checked = true
|
||||||
|
|
||||||
|
for i := range fields {
|
||||||
|
fields[i] = unescapeCopy(fields[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return fmt.Errorf("credit import: read %s: %w", member, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unescapeCopy undoes Postgres COPY's text escaping. A NULL (\N) is
|
||||||
|
// returned as an empty string: every field this pass reads is either a
|
||||||
|
// number it will reject or a name whose absence means the same as
|
||||||
|
// empty.
|
||||||
|
func unescapeCopy(s string) string {
|
||||||
|
if s == `\N` {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.ContainsRune(s, '\\') {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.Grow(len(s))
|
||||||
|
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] != '\\' || i+1 >= len(s) {
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
i++
|
||||||
|
|
||||||
|
switch s[i] {
|
||||||
|
case 'n':
|
||||||
|
b.WriteByte('\n')
|
||||||
|
case 't':
|
||||||
|
b.WriteByte('\t')
|
||||||
|
case 'r':
|
||||||
|
b.WriteByte('\r')
|
||||||
|
case 'b':
|
||||||
|
b.WriteByte('\b')
|
||||||
|
case 'f':
|
||||||
|
b.WriteByte('\f')
|
||||||
|
case 'v':
|
||||||
|
b.WriteByte('\v')
|
||||||
|
case '\\':
|
||||||
|
b.WriteByte('\\')
|
||||||
|
default:
|
||||||
|
b.WriteByte('\\')
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt32(s string) (int32, bool) {
|
||||||
|
n, err := strconv.ParseInt(s, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return int32(n), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate bounds an error message built from dump data, which is
|
||||||
|
// attacker-free but can be long.
|
||||||
|
func truncate(s string) string {
|
||||||
|
const limit = 64
|
||||||
|
|
||||||
|
if len(s) <= limit {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
return s[:limit] + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
//go:build indexbuild
|
||||||
|
|
||||||
|
package explore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tarOf builds an uncompressed tar of the named members, in the order
|
||||||
|
// given. Order is the point of several of these tests: the real dump's
|
||||||
|
// members are alphabetical, which is what lets one pass resolve an
|
||||||
|
// entity's credit without buffering 35M recordings.
|
||||||
|
func tarOf(t *testing.T, members ...[2]string) *tar.Reader {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
tw := tar.NewWriter(&buf)
|
||||||
|
|
||||||
|
for _, m := range members {
|
||||||
|
body := []byte(m[1])
|
||||||
|
|
||||||
|
if err := tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "mbdump/" + m[0],
|
||||||
|
Mode: 0o644,
|
||||||
|
Size: int64(len(body)),
|
||||||
|
Typeflag: tar.TypeReg,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("tar header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tw.Write(body); err != nil {
|
||||||
|
t.Fatalf("tar write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
t.Fatalf("tar close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tar.NewReader(&buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tsv(rows ...[]string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
for _, r := range rows {
|
||||||
|
b.WriteString(strings.Join(r, "\t"))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustMBID is testMBID in the packed form the catalog stores.
|
||||||
|
func mustMBID(label string) uuid16 {
|
||||||
|
var u uuid16
|
||||||
|
|
||||||
|
if !parseUUID(testMBID(label), u[:]) {
|
||||||
|
panic("testMBID did not produce a UUID for " + label)
|
||||||
|
}
|
||||||
|
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two artists of the worked example, and the entities they credit.
|
||||||
|
var (
|
||||||
|
creditRecMBID = mustMBID("recording-1")
|
||||||
|
creditRGMBID = mustMBID("release-group-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
// sampleDump is the shape verified against the 20260815 export:
|
||||||
|
// artist(id, gid, ...), artist_credit(id, name, artist_count, ...),
|
||||||
|
// artist_credit_name(credit, position, artist, name, join_phrase),
|
||||||
|
// recording/release_group(id, gid, name, artist_credit, ...).
|
||||||
|
func sampleDump(t *testing.T) *tar.Reader {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return tarOf(t,
|
||||||
|
[2]string{"artist", tsv(
|
||||||
|
[]string{"11", testMBID("artist-a"), "Snoop Doggy Dogg", "Snoop Doggy Dogg"},
|
||||||
|
[]string{"22", testMBID("artist-b"), "2Pac", "2Pac"},
|
||||||
|
)},
|
||||||
|
[2]string{"artist_credit", tsv(
|
||||||
|
[]string{"900", "2Pac feat. Snoop Dogg", "2", "1", "", "0", ""},
|
||||||
|
[]string{"901", "Solo Artist", "1", "1", "", "0", ""},
|
||||||
|
)},
|
||||||
|
[2]string{"artist_credit_name", tsv(
|
||||||
|
// Deliberately out of position order: the dump is not
|
||||||
|
// obliged to emit them sorted and the credit's meaning is
|
||||||
|
// the order, not the file's.
|
||||||
|
[]string{"900", "1", "11", "Snoop Dogg", ""},
|
||||||
|
[]string{"900", "0", "22", "2Pac", " feat. "},
|
||||||
|
[]string{"901", "0", "11", "Solo Artist", ""},
|
||||||
|
)},
|
||||||
|
[2]string{"recording", tsv(
|
||||||
|
[]string{"1", testMBID("recording-1"), "Some Song", "900", "180000"},
|
||||||
|
[]string{"2", testMBID("not-kept"), "Other", "900", "1"},
|
||||||
|
[]string{"3", testMBID("solo"), "Solo", "901", "1"},
|
||||||
|
)},
|
||||||
|
[2]string{"release_group", tsv(
|
||||||
|
[]string{"5", testMBID("release-group-1"), "Some Album", "900", "1"},
|
||||||
|
)},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func creditTestImporter(t *testing.T) *dumpImporter {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
|
return &dumpImporter{
|
||||||
|
si: NewSearchIndex(db, nil, nil, testLogger()),
|
||||||
|
logger: testLogger(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanCreditDumpDecomposes is the worked example end to end: the
|
||||||
|
// credit's parts come back in position order, with the *credited*
|
||||||
|
// names and the join phrase between them.
|
||||||
|
func TestScanCreditDumpDecomposes(t *testing.T) {
|
||||||
|
imp := creditTestImporter(t)
|
||||||
|
|
||||||
|
kept := map[uuid16]struct{}{
|
||||||
|
creditRecMBID: {},
|
||||||
|
creditRGMBID: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
scan, err := imp.scanCreditTar(context.Background(), sampleDump(t), kept)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := len(scan.refs); got != 2 {
|
||||||
|
t.Fatalf("refs = %d, want 2 (the recording and the release group)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if scan.refs[creditRecMBID] != 900 {
|
||||||
|
t.Errorf("recording credit = %d, want 900", scan.refs[creditRecMBID])
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := scan.parts[900]
|
||||||
|
if len(parts) != 2 {
|
||||||
|
t.Fatalf("parts = %d, want 2", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting happens on write, so assert the pieces are all present
|
||||||
|
// and let the render test below check the order.
|
||||||
|
byPos := map[int]creditPart{}
|
||||||
|
for _, p := range parts {
|
||||||
|
byPos[p.position] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
if byPos[0].name != "2Pac" || byPos[0].join != " feat. " {
|
||||||
|
t.Errorf("position 0 = %q/%q, want \"2Pac\"/\" feat. \"",
|
||||||
|
byPos[0].name, byPos[0].join)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The credited name, not the artist's own name: this is the whole
|
||||||
|
// reason credited_name is stored per row.
|
||||||
|
if byPos[1].name != "Snoop Dogg" {
|
||||||
|
t.Errorf("position 1 credited name = %q, want \"Snoop Dogg\"", byPos[1].name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSingleArtistCreditsAreNotStored: a one-artist credit is already
|
||||||
|
// described by explore_index's artist_name/artist_mbid, and storing it
|
||||||
|
// would roughly triple the table to say nothing new.
|
||||||
|
func TestSingleArtistCreditsAreNotStored(t *testing.T) {
|
||||||
|
imp := creditTestImporter(t)
|
||||||
|
|
||||||
|
solo := mustMBID("solo")
|
||||||
|
kept := map[uuid16]struct{}{solo: {}}
|
||||||
|
|
||||||
|
scan, err := imp.scanCreditTar(context.Background(), sampleDump(t), kept)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scan.refs) != 0 {
|
||||||
|
t.Fatalf("a single-artist credit was referenced: %v", scan.refs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := scan.multiCredits[901]; ok {
|
||||||
|
t.Error("credit 901 has artist_count 1 and should not be multi")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOnlyKeptEntitiesAreReferenced: the catalog's popularity filter
|
||||||
|
// decides what is worth carrying credits for, and an entity outside it
|
||||||
|
// must not produce a row pointing at nothing.
|
||||||
|
func TestOnlyKeptEntitiesAreReferenced(t *testing.T) {
|
||||||
|
imp := creditTestImporter(t)
|
||||||
|
|
||||||
|
kept := map[uuid16]struct{}{creditRecMBID: {}}
|
||||||
|
|
||||||
|
scan, err := imp.scanCreditTar(context.Background(), sampleDump(t), kept)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := scan.refs[mustMBID("not-kept")]; ok {
|
||||||
|
t.Error("an entity outside the catalog was referenced")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scan.used) != 1 {
|
||||||
|
t.Errorf("used credits = %d, want 1", len(scan.used))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteCreditsRoundTrips checks what the frontend will actually
|
||||||
|
// read: parts in position order, dashed MBIDs out of the 16 raw bytes,
|
||||||
|
// and a rendered credit that reassembles to the tagged string.
|
||||||
|
func TestWriteCreditsRoundTrips(t *testing.T) {
|
||||||
|
imp := creditTestImporter(t)
|
||||||
|
|
||||||
|
kept := map[uuid16]struct{}{creditRecMBID: {}, creditRGMBID: {}}
|
||||||
|
|
||||||
|
scan, err := imp.scanCreditTar(context.Background(), sampleDump(t), kept)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := imp.writeCredits(context.Background(), scan); err != nil {
|
||||||
|
t.Fatalf("writeCredits: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := imp.si.db.QueryContext(
|
||||||
|
`SELECT p.position, p.artist_mbid, p.credited_name, p.join_phrase
|
||||||
|
FROM artist_credit_ref r
|
||||||
|
JOIN artist_credit_part p ON p.credit_id = r.credit_id
|
||||||
|
WHERE r.mbid = ?
|
||||||
|
ORDER BY p.position`,
|
||||||
|
creditRecMBID[:],
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var rendered strings.Builder
|
||||||
|
|
||||||
|
names := []string{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
pos int
|
||||||
|
mbid []byte
|
||||||
|
name string
|
||||||
|
join string
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := rows.Scan(&pos, &mbid, &name, &join); err != nil {
|
||||||
|
t.Fatalf("scan row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mbid) != 16 {
|
||||||
|
t.Fatalf("artist_mbid is %d bytes, want 16", len(mbid))
|
||||||
|
}
|
||||||
|
|
||||||
|
names = append(names, name)
|
||||||
|
|
||||||
|
rendered.WriteString(name)
|
||||||
|
rendered.WriteString(join)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenation is the contract: names in order, join phrases
|
||||||
|
// between them, and no searching a name inside a credit string.
|
||||||
|
if got := rendered.String(); got != "2Pac feat. Snoop Dogg" {
|
||||||
|
t.Errorf("rendered credit = %q, want %q", got, "2Pac feat. Snoop Dogg")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(names) != 2 || names[0] != "2Pac" {
|
||||||
|
t.Errorf("parts came back out of position order: %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreditRefsNeverDangle: a ref whose parts were not stored renders
|
||||||
|
// as a credit with no artists at all, which is worse than the
|
||||||
|
// single-artist fallback it replaced.
|
||||||
|
func TestCreditRefsNeverDangle(t *testing.T) {
|
||||||
|
imp := creditTestImporter(t)
|
||||||
|
|
||||||
|
kept := map[uuid16]struct{}{creditRecMBID: {}}
|
||||||
|
|
||||||
|
scan, err := imp.scanCreditTar(context.Background(), sampleDump(t), kept)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An artist the dump never named: the credit cannot be navigated to
|
||||||
|
// and must be dropped whole, taking its ref with it.
|
||||||
|
scan.artistGIDs = map[int32]uuid16{}
|
||||||
|
|
||||||
|
if err := imp.writeCredits(context.Background(), scan); err != nil {
|
||||||
|
t.Fatalf("writeCredits: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refs, parts int
|
||||||
|
|
||||||
|
if err := imp.si.db.QueryRowWriter(
|
||||||
|
"SELECT COUNT(*) FROM artist_credit_ref",
|
||||||
|
).Scan(&refs); err != nil {
|
||||||
|
t.Fatalf("count refs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := imp.si.db.QueryRowWriter(
|
||||||
|
"SELECT COUNT(*) FROM artist_credit_part",
|
||||||
|
).Scan(&parts); err != nil {
|
||||||
|
t.Fatalf("count parts: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if refs != 0 || parts != 0 {
|
||||||
|
t.Fatalf("refs=%d parts=%d, want 0/0 when the artists are unknown", refs, parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreditDumpShapeIsAsserted: the dump has no header row, so a
|
||||||
|
// column that moved would be read as its neighbour and produce a
|
||||||
|
// catalog that is quietly wrong. Loud is the requirement.
|
||||||
|
func TestCreditDumpShapeIsAsserted(t *testing.T) {
|
||||||
|
imp := creditTestImporter(t)
|
||||||
|
|
||||||
|
short := tarOf(t, [2]string{"artist", tsv([]string{"11", "only-two-columns"})})
|
||||||
|
|
||||||
|
_, err := imp.scanCreditTar(context.Background(), short, map[uuid16]struct{}{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a member with a non-UUID gid was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrDumpShape) {
|
||||||
|
t.Errorf("error = %v, want ErrDumpShape", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnescapeCopy covers Postgres COPY's text escaping, which reaches
|
||||||
|
// artist names routinely -- a tab or backslash in a name would
|
||||||
|
// otherwise shift every field after it.
|
||||||
|
func TestUnescapeCopy(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{`plain`, `plain`},
|
||||||
|
{`\N`, ``},
|
||||||
|
{`a\tb`, "a\tb"},
|
||||||
|
{`a\nb`, "a\nb"},
|
||||||
|
{`back\\slash`, `back\slash`},
|
||||||
|
{`AC\/DC`, `AC\/DC`},
|
||||||
|
{`trailing\`, `trailing\`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := unescapeCopy(tt.in); got != tt.want {
|
||||||
|
t.Errorf("unescapeCopy(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureArtistCreditsIsIdempotent pins what the index job depends
|
||||||
|
// on to decide whether to publish.
|
||||||
|
//
|
||||||
|
// The pass runs on every mode, including the `refresh` that a complete
|
||||||
|
// catalog always chooses — so it must be free when there is nothing to
|
||||||
|
// do, and it must say so. A `true` here republishes the artifact; a
|
||||||
|
// `true` on every run would republish an identical one weekly, and a
|
||||||
|
// permanent `false` would mean a catalog that never gains credits at
|
||||||
|
// all.
|
||||||
|
func TestEnsureArtistCreditsIsIdempotent(t *testing.T) {
|
||||||
|
imp := creditTestImporter(t)
|
||||||
|
|
||||||
|
// The marker is what "already done" means; with it set, the pass
|
||||||
|
// must not reach the network or report a change.
|
||||||
|
imp.si.setMeta(creditsImportDoneKey, "1")
|
||||||
|
|
||||||
|
if imp.ensureArtistCredits(context.Background()) {
|
||||||
|
t.Fatal("a second run reported new credits; the artifact would republish forever")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureArtistCreditsReportsFailureAsNoChange: a dump that cannot be
|
||||||
|
// reached leaves the catalog exactly as it was, and must not claim
|
||||||
|
// otherwise — publishing on it would ship an artifact with no credits
|
||||||
|
// and mark the work done.
|
||||||
|
func TestEnsureArtistCreditsReportsFailureAsNoChange(t *testing.T) {
|
||||||
|
imp := creditTestImporter(t)
|
||||||
|
imp.httpClient = newDumpHTTPClient()
|
||||||
|
imp.mbdumpBaseURL = "http://127.0.0.1:1/nonexistent/"
|
||||||
|
|
||||||
|
if imp.ensureArtistCredits(context.Background()) {
|
||||||
|
t.Fatal("an unreachable dump reported new credits")
|
||||||
|
}
|
||||||
|
|
||||||
|
if imp.si.hasMeta(creditsImportDoneKey) {
|
||||||
|
t.Error("a failed pass marked itself done; it would never retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//go:build indexbuild
|
||||||
|
|
||||||
|
package explore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeCredits persists the scanned decompositions.
|
||||||
|
//
|
||||||
|
// Only credits some catalog entity actually points at are written: the
|
||||||
|
// dump has millions of multi-artist credits and the catalog keeps ~1.8M
|
||||||
|
// entities, so storing every credit would be most of a table nothing
|
||||||
|
// can reach.
|
||||||
|
//
|
||||||
|
// The two tables are written in one transaction, because a ref pointing
|
||||||
|
// at parts that are not there renders as a credit with no artists --
|
||||||
|
// worse than the single-artist fallback it replaced.
|
||||||
|
func (imp *dumpImporter) writeCredits(ctx context.Context, scan *creditScan) error {
|
||||||
|
tx, err := imp.si.db.BeginTx()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("credit import: begin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
// A rebuild replaces the previous pass wholesale. These are Cache
|
||||||
|
// tables derived entirely from the dump, so there is nothing to
|
||||||
|
// merge and a stale row is a wrong credit.
|
||||||
|
for _, table := range []string{"artist_credit_part", "artist_credit_ref"} {
|
||||||
|
if _, err := tx.ExecContext(ctx, "DELETE FROM "+table); err != nil {
|
||||||
|
return fmt.Errorf("credit import: clear %s: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
written, err := imp.writeCreditParts(ctx, tx, scan)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
refs, err := imp.writeCreditRefs(ctx, tx, scan, written)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("credit import: commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
imp.logger.Info("credit import: complete",
|
||||||
|
"credits", len(written),
|
||||||
|
"refs", refs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCreditParts inserts the parts of every used credit and returns
|
||||||
|
// the set of credits that were actually stored.
|
||||||
|
//
|
||||||
|
// A credit is stored whole or not at all. If any of its artists has no
|
||||||
|
// MBID -- which should not happen, the dump being self-consistent, but
|
||||||
|
// would leave a part that cannot be navigated to -- the credit is
|
||||||
|
// dropped and the entity falls back to explore_index's single artist,
|
||||||
|
// which is a worse answer rather than a broken one.
|
||||||
|
func (imp *dumpImporter) writeCreditParts(
|
||||||
|
ctx context.Context, tx *sql.Tx, scan *creditScan,
|
||||||
|
) (map[int32]struct{}, error) {
|
||||||
|
stmt, err := tx.PrepareContext(ctx,
|
||||||
|
`INSERT INTO artist_credit_part
|
||||||
|
(credit_id, position, artist_mbid, credited_name, join_phrase)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("credit import: prepare part insert: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = stmt.Close() }()
|
||||||
|
|
||||||
|
written := make(map[int32]struct{}, len(scan.used))
|
||||||
|
|
||||||
|
for credit := range scan.used {
|
||||||
|
parts := scan.parts[credit]
|
||||||
|
if len(parts) < 2 {
|
||||||
|
// artist_credit said more than one artist and
|
||||||
|
// artist_credit_name did not deliver them. Nothing to
|
||||||
|
// decompose, so leave the entity to its single artist.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position order is the credit's meaning, and the dump is not
|
||||||
|
// obliged to emit it sorted.
|
||||||
|
sort.Slice(parts, func(i, j int) bool {
|
||||||
|
return parts[i].position < parts[j].position
|
||||||
|
})
|
||||||
|
|
||||||
|
resolved := make([][]any, 0, len(parts))
|
||||||
|
ok := true
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
gid, found := scan.artistGIDs[part.artistID]
|
||||||
|
if !found {
|
||||||
|
scan.skippedUnknownArtist++
|
||||||
|
ok = false
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved = append(resolved, []any{
|
||||||
|
credit, part.position, gid[:], part.name, part.join,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, args := range resolved {
|
||||||
|
if _, err := stmt.ExecContext(ctx, args...); err != nil {
|
||||||
|
return nil, fmt.Errorf("credit import: insert part: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
written[credit] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCreditRefs points each kept entity at its credit, skipping any
|
||||||
|
// whose credit was not stored so a ref never dangles.
|
||||||
|
func (imp *dumpImporter) writeCreditRefs(
|
||||||
|
ctx context.Context, tx *sql.Tx, scan *creditScan, written map[int32]struct{},
|
||||||
|
) (int, error) {
|
||||||
|
stmt, err := tx.PrepareContext(ctx,
|
||||||
|
"INSERT OR REPLACE INTO artist_credit_ref (mbid, credit_id) VALUES (?, ?)",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("credit import: prepare ref insert: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = stmt.Close() }()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for mbid, credit := range scan.refs {
|
||||||
|
if _, stored := written[credit]; !stored {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
id := mbid
|
||||||
|
|
||||||
|
if _, err := stmt.ExecContext(ctx, id[:], credit); err != nil {
|
||||||
|
return 0, fmt.Errorf("credit import: insert ref: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
@@ -104,6 +104,7 @@ type dumpImporter struct {
|
|||||||
|
|
||||||
canonicalBaseURL string
|
canonicalBaseURL string
|
||||||
listensBaseURL string
|
listensBaseURL string
|
||||||
|
mbdumpBaseURL string
|
||||||
|
|
||||||
// Disk safety floors (fields so tests can relax them).
|
// Disk safety floors (fields so tests can relax them).
|
||||||
minStartFreeBytes uint64
|
minStartFreeBytes uint64
|
||||||
@@ -144,6 +145,7 @@ func newDumpImporter(si *SearchIndex, lb *ListenBrainzClient) (*dumpImporter, er
|
|||||||
stagingDir: stagingDir,
|
stagingDir: stagingDir,
|
||||||
canonicalBaseURL: defaultCanonicalBaseURL,
|
canonicalBaseURL: defaultCanonicalBaseURL,
|
||||||
listensBaseURL: defaultListensBaseURL,
|
listensBaseURL: defaultListensBaseURL,
|
||||||
|
mbdumpBaseURL: defaultMBDumpBaseURL,
|
||||||
minStartFreeBytes: dumpMinStartFreeBytes,
|
minStartFreeBytes: dumpMinStartFreeBytes,
|
||||||
abortFreeBytes: dumpAbortFreeBytes,
|
abortFreeBytes: dumpAbortFreeBytes,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -171,6 +173,7 @@ func (imp *dumpImporter) run(ctx context.Context) error {
|
|||||||
// Fast path: rows already assembled, only patch passes remain.
|
// Fast path: rows already assembled, only patch passes remain.
|
||||||
if state.Stage == dumpStageAssembled {
|
if state.Stage == dumpStageAssembled {
|
||||||
imp.si.MarkReadyIfPopulated()
|
imp.si.MarkReadyIfPopulated()
|
||||||
|
imp.ensureArtistCredits(ctx)
|
||||||
imp.runPatchPasses(ctx)
|
imp.runPatchPasses(ctx)
|
||||||
|
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
@@ -305,6 +308,11 @@ func (imp *dumpImporter) run(ctx context.Context) error {
|
|||||||
imp.si.MarkReadyIfPopulated()
|
imp.si.MarkReadyIfPopulated()
|
||||||
imp.si.refreshStatusCounts()
|
imp.si.refreshStatusCounts()
|
||||||
|
|
||||||
|
// Multi-artist credits, from a different dump. After the catalog,
|
||||||
|
// because it asks explore_index which entities are worth carrying
|
||||||
|
// credits for.
|
||||||
|
imp.ensureArtistCredits(ctx)
|
||||||
|
|
||||||
// Stage 4: API patch passes (idempotent).
|
// Stage 4: API patch passes (idempotent).
|
||||||
imp.runPatchPasses(ctx)
|
imp.runPatchPasses(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
|
||||||
"golang.org/x/sync/singleflight"
|
"golang.org/x/sync/singleflight"
|
||||||
|
|
||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
@@ -144,19 +143,9 @@ func (e *Service) CAALimiter() *RateLimiter {
|
|||||||
return e.caaLimiter
|
return e.caaLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
// The v3 service lifecycle hook that fills e.ctx lives in
|
||||||
// runtime exists, and ctx is cancelled when the app shuts down. It
|
// servicestartup.go, behind a build tag: naming
|
||||||
// replaces v2's SetContext, which had to be called by hand from
|
// application.ServiceOptions is what would drag cgo into cmd/indexbuild.
|
||||||
// OnStartup and was exported, so it was also bound to the frontend.
|
|
||||||
func (e *Service) ServiceStartup(
|
|
||||||
ctx context.Context,
|
|
||||||
_ application.ServiceOptions,
|
|
||||||
) error {
|
|
||||||
e.ctx = ctx
|
|
||||||
e.index.SetContext(ctx)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartIndexBuild kicks off the background search index build.
|
// StartIndexBuild kicks off the background search index build.
|
||||||
// Call this after the library scan completes so the indexer doesn't
|
// Call this after the library scan completes so the indexer doesn't
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package explore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Whether the catalog artifact may be downloaded on this connection
|
||||||
|
// (plan 016 B4).
|
||||||
|
//
|
||||||
|
// The artifact is ~0.6 GB. On a desktop that is a minute of someone
|
||||||
|
// else's bandwidth; on a phone it can be a month's allowance, and the
|
||||||
|
// app had no awareness of the difference at all.
|
||||||
|
//
|
||||||
|
// Three decisions shape this file.
|
||||||
|
//
|
||||||
|
// **The policy lives here and the platform call does not.** `explore` is
|
||||||
|
// imported by `cmd/indexbuild`, which is built with `CGO_ENABLED=0` in a
|
||||||
|
// plain Go container, so naming `application` here would break the one
|
||||||
|
// job that must not fail (see `TestIndexToolsDoNotImportWails`). What is
|
||||||
|
// injected is a closure; what is *tested* is the parsing and the
|
||||||
|
// decision, on every platform.
|
||||||
|
//
|
||||||
|
// **An unknown answer is not a metered one.** Only mobile answers this
|
||||||
|
// question — the desktop stub returns an empty string — so a policy that
|
||||||
|
// treated silence as "metered" would refuse the download on every
|
||||||
|
// desktop in the world. Silence means "no reason to refuse".
|
||||||
|
//
|
||||||
|
// **Cellular is the signal, and it is the only one available.** Wails
|
||||||
|
// reports `{"connected":bool,"type":"wifi|cellular|ethernet|none"}` and
|
||||||
|
// no metered flag, so a metered *wifi* — a phone hotspot, a hotel — is
|
||||||
|
// invisible to us and will not be refused. That is a known gap rather
|
||||||
|
// than an oversight: Android knows (`NET_CAPABILITY_NOT_METERED`) and
|
||||||
|
// the runtime does not pass it on.
|
||||||
|
|
||||||
|
// ErrMeteredNetwork is returned instead of downloading the catalog when
|
||||||
|
// the connection looks metered and the user has not opted in. Every
|
||||||
|
// failure path in `tryCoreArtifact` is already non-fatal, so this
|
||||||
|
// behaves like any other reason the artifact is not available yet.
|
||||||
|
var ErrMeteredNetwork = errors.New(
|
||||||
|
"explore: catalog download declined on a metered connection",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Network is what the platform can say about the connection.
|
||||||
|
type Network struct {
|
||||||
|
// Known is false when nothing answered — every desktop, and any
|
||||||
|
// mobile build whose bridge is not up yet.
|
||||||
|
Known bool
|
||||||
|
// Connected reports a usable connection of any kind.
|
||||||
|
Connected bool
|
||||||
|
// Metered reports a connection the user is plausibly paying for by
|
||||||
|
// the byte. See the note above on what this cannot see.
|
||||||
|
Metered bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkProbe answers "what kind of connection is this", or an unknown
|
||||||
|
// Network when the platform does not say.
|
||||||
|
type NetworkProbe func() Network
|
||||||
|
|
||||||
|
// ParseNetworkJSON reads the runtime's network payload.
|
||||||
|
//
|
||||||
|
// Anything unparseable is `Known: false` rather than an error: this
|
||||||
|
// decides whether to *skip* an optional download, and a malformed
|
||||||
|
// payload is not a reason to refuse one.
|
||||||
|
func ParseNetworkJSON(payload string) Network {
|
||||||
|
var raw struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(payload) == "" {
|
||||||
|
return Network{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(payload), &raw); err != nil {
|
||||||
|
return Network{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Network{
|
||||||
|
Known: true,
|
||||||
|
Connected: raw.Connected,
|
||||||
|
Metered: strings.EqualFold(raw.Type, "cellular"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkPolicy is the injected half: how to ask, and whether the user
|
||||||
|
// has said yes anyway.
|
||||||
|
type networkPolicy struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
probe NetworkProbe
|
||||||
|
allowMetered func() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *networkPolicy) set(probe NetworkProbe, allowMetered func() bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
p.probe = probe
|
||||||
|
p.allowMetered = allowMetered
|
||||||
|
}
|
||||||
|
|
||||||
|
// refuses reports whether a large optional download should be skipped.
|
||||||
|
func (p *networkPolicy) refuses() bool {
|
||||||
|
p.mu.RLock()
|
||||||
|
probe, allow := p.probe, p.allowMetered
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
if probe == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if allow != nil && allow() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
state := probe()
|
||||||
|
|
||||||
|
return state.Known && state.Metered
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNetworkPolicy wires how the catalog download decides whether this
|
||||||
|
// connection is one to spend 0.6 GB on. Both arguments may be nil, which
|
||||||
|
// is the desktop's answer: never refuse.
|
||||||
|
//
|
||||||
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||||
|
func (si *SearchIndex) SetNetworkPolicy(probe NetworkProbe, allowMetered func() bool) {
|
||||||
|
si.netPolicy.set(probe, allowMetered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNetworkPolicy wires the metered-connection policy into the index.
|
||||||
|
//
|
||||||
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||||
|
func (e *Service) SetNetworkPolicy(probe NetworkProbe, allowMetered func() bool) {
|
||||||
|
e.index.SetNetworkPolicy(probe, allowMetered)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package explore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The catalog is ~0.6 GB and the decision not to fetch it is the only
|
||||||
|
// part of plan 016 B4 that can be tested anywhere but on a phone: the
|
||||||
|
// platform call is a one-line closure injected from app.go, and
|
||||||
|
// everything that decides anything is here.
|
||||||
|
|
||||||
|
func TestParseNetworkJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
payload string
|
||||||
|
want Network
|
||||||
|
}{{
|
||||||
|
name: "cellular is metered",
|
||||||
|
payload: `{"connected":true,"type":"cellular"}`,
|
||||||
|
want: Network{Known: true, Connected: true, Metered: true},
|
||||||
|
}, {
|
||||||
|
name: "wifi is not",
|
||||||
|
payload: `{"connected":true,"type":"wifi"}`,
|
||||||
|
want: Network{Known: true, Connected: true},
|
||||||
|
}, {
|
||||||
|
name: "ethernet is not",
|
||||||
|
payload: `{"connected":true,"type":"ethernet"}`,
|
||||||
|
want: Network{Known: true, Connected: true},
|
||||||
|
}, {
|
||||||
|
name: "the case is the platform's business, not ours",
|
||||||
|
payload: `{"connected":true,"type":"Cellular"}`,
|
||||||
|
want: Network{Known: true, Connected: true, Metered: true},
|
||||||
|
}, {
|
||||||
|
name: "offline is known and unmetered",
|
||||||
|
payload: `{"connected":false,"type":"none"}`,
|
||||||
|
want: Network{Known: true},
|
||||||
|
}, {
|
||||||
|
// The desktop stub. This is the case that must not read as
|
||||||
|
// "metered": every desktop in the world answers this way.
|
||||||
|
name: "an empty payload is unknown",
|
||||||
|
payload: "",
|
||||||
|
want: Network{},
|
||||||
|
}, {
|
||||||
|
name: "so is a malformed one",
|
||||||
|
payload: `{"connected":`,
|
||||||
|
want: Network{},
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := ParseNetworkJSON(tt.payload); got != tt.want {
|
||||||
|
t.Errorf("ParseNetworkJSON(%q) = %+v, want %+v", tt.payload, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetworkPolicyRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cellular := func() Network {
|
||||||
|
return Network{Known: true, Connected: true, Metered: true}
|
||||||
|
}
|
||||||
|
wifi := func() Network { return Network{Known: true, Connected: true} }
|
||||||
|
unknown := func() Network { return Network{} }
|
||||||
|
yes := func() bool { return true }
|
||||||
|
no := func() bool { return false }
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
probe NetworkProbe
|
||||||
|
allowMetered func() bool
|
||||||
|
want bool
|
||||||
|
}{{
|
||||||
|
name: "no probe wired refuses nothing",
|
||||||
|
probe: nil,
|
||||||
|
want: false,
|
||||||
|
}, {
|
||||||
|
name: "an unknown connection refuses nothing",
|
||||||
|
probe: unknown,
|
||||||
|
want: false,
|
||||||
|
}, {
|
||||||
|
name: "wifi refuses nothing",
|
||||||
|
probe: wifi,
|
||||||
|
want: false,
|
||||||
|
}, {
|
||||||
|
name: "cellular refuses by default",
|
||||||
|
probe: cellular,
|
||||||
|
want: true,
|
||||||
|
}, {
|
||||||
|
name: "cellular with no permission refuses",
|
||||||
|
probe: cellular,
|
||||||
|
allowMetered: no,
|
||||||
|
want: true,
|
||||||
|
}, {
|
||||||
|
name: "cellular the user opted into does not",
|
||||||
|
probe: cellular,
|
||||||
|
allowMetered: yes,
|
||||||
|
want: false,
|
||||||
|
}, {
|
||||||
|
// The permission is read at decision time rather than captured,
|
||||||
|
// so turning it on takes effect on the next attempt instead of
|
||||||
|
// the next launch.
|
||||||
|
name: "permission is asked, not remembered",
|
||||||
|
probe: cellular,
|
||||||
|
allowMetered: yes,
|
||||||
|
want: false,
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var p networkPolicy
|
||||||
|
|
||||||
|
p.set(tt.probe, tt.allowMetered)
|
||||||
|
|
||||||
|
if got := p.refuses(); got != tt.want {
|
||||||
|
t.Errorf("refuses() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate has to come before anything is staged: a declined download is
|
||||||
|
// a no-op, not a job in the indicator or a status the user must dismiss.
|
||||||
|
func TestTryCoreArtifactDeclinesMeteredWithoutStaging(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
si := &SearchIndex{}
|
||||||
|
|
||||||
|
si.SetNetworkPolicy(
|
||||||
|
func() Network { return Network{Known: true, Connected: true, Metered: true} },
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := si.tryCoreArtifact(t.Context())
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrMeteredNetwork) {
|
||||||
|
t.Fatalf("tryCoreArtifact() error = %v, want ErrMeteredNetwork", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing announced itself: no build status, no tiers, no job. A
|
||||||
|
// SearchIndex with no database would panic on any of the work below
|
||||||
|
// the gate, which is itself part of the assertion.
|
||||||
|
if si.buildStatus.Building {
|
||||||
|
t.Error("declining a metered download still reported a build in progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(si.buildStatus.Tiers) != 0 {
|
||||||
|
t.Errorf("declining staged %d tiers, want none", len(si.buildStatus.Tiers))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,6 +212,11 @@ type SearchIndex struct {
|
|||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
|
||||||
|
// netPolicy decides whether this connection is one to spend ~0.6 GB
|
||||||
|
// of catalog on. Its own lock: it is written once at startup and read
|
||||||
|
// from the build goroutine (netpolicy.go).
|
||||||
|
netPolicy networkPolicy
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ready bool
|
ready bool
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//go:build !indexbuild
|
||||||
|
|
||||||
|
package explore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
||||||
|
// runtime exists, and ctx is cancelled when the app shuts down. It
|
||||||
|
// replaces v2's SetContext, which had to be called by hand from
|
||||||
|
// OnStartup and was exported, so it was also bound to the frontend.
|
||||||
|
//
|
||||||
|
// It is the one thing in this package that names the Wails application,
|
||||||
|
// and it is behind a build tag for the reason
|
||||||
|
// backend/events/runtime_wails.go states: cmd/indexbuild imports this
|
||||||
|
// package and is built without cgo, GTK or WebKit. Nothing under the
|
||||||
|
// indexbuild tag runs a Wails app, so the hook — and the context it
|
||||||
|
// installs — is simply absent there.
|
||||||
|
func (e *Service) ServiceStartup(
|
||||||
|
ctx context.Context,
|
||||||
|
_ application.ServiceOptions,
|
||||||
|
) error {
|
||||||
|
e.ctx = ctx
|
||||||
|
e.index.SetContext(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package frontendutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errNotADirectory is returned when a caller asks to list something
|
||||||
|
// that exists but is not a directory.
|
||||||
|
var errNotADirectory = errors.New("not a directory")
|
||||||
|
|
||||||
|
// DirEntry is one selectable directory in a listing.
|
||||||
|
type DirEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirListing is one level of the filesystem, as a folder picker needs
|
||||||
|
// it: where we are, what is above, and the directories below.
|
||||||
|
//
|
||||||
|
// Parent is empty at a root, which is what tells the UI not to draw an
|
||||||
|
// "up" affordance rather than having it compute that from the path
|
||||||
|
// separator.
|
||||||
|
type DirListing struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Parent string `json:"parent"`
|
||||||
|
Entries []DirEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDirectories returns the directories directly inside path, so the
|
||||||
|
// frontend can draw a folder picker.
|
||||||
|
//
|
||||||
|
// **It exists because Android has no directory picker.** Wails' file
|
||||||
|
// dialog can choose directories on every desktop platform, and on
|
||||||
|
// Android it returns an error: the Storage Access Framework yields tree
|
||||||
|
// URIs rather than filesystem paths, and a path is what this app's
|
||||||
|
// entire library model is keyed on. Rather than teach the backend about
|
||||||
|
// tree URIs, the app browses the filesystem itself — which it can do
|
||||||
|
// because it holds all-files access (see the manifest).
|
||||||
|
//
|
||||||
|
// Three rules, each of which a picker gets wrong if it is not stated:
|
||||||
|
// only directories are returned, because the caller is choosing a
|
||||||
|
// library root and files are noise; unreadable children are skipped
|
||||||
|
// rather than failing the whole listing, since Android's storage root
|
||||||
|
// contains directories no app may enter; and hidden directories are
|
||||||
|
// omitted, because a music library is not in one and `.thumbnails`
|
||||||
|
// alone would swamp the list.
|
||||||
|
func (fe *FrontendUtil) ListDirectories(path string) (DirListing, error) {
|
||||||
|
if path == "" {
|
||||||
|
path = fe.DefaultBrowseRoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return DirListing{}, fmt.Errorf("could not open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.IsDir() {
|
||||||
|
return DirListing{}, fmt.Errorf("%w: %s", errNotADirectory, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return DirListing{}, fmt.Errorf("could not read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs := make([]DirEntry, 0, len(entries))
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if name == "" || name[0] == '.' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// A symlink reports itself, not its target, so ask the
|
||||||
|
// filesystem: a symlinked music directory is ordinary and
|
||||||
|
// skipping it would be a bug the user cannot explain.
|
||||||
|
child := filepath.Join(path, name)
|
||||||
|
|
||||||
|
info, err := os.Stat(child)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs = append(dirs, DirEntry{Name: name, Path: child})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name < dirs[j].Name })
|
||||||
|
|
||||||
|
parent := filepath.Dir(path)
|
||||||
|
if parent == path {
|
||||||
|
parent = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return DirListing{Path: path, Parent: parent, Entries: dirs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// androidSharedStorage is where a user's music lives on Android. It is
|
||||||
|
// not derivable from the environment the way a desktop home directory
|
||||||
|
// is: HOME inside an Android app process is "/", so os.UserHomeDir()
|
||||||
|
// would start the picker at the filesystem root with nothing readable
|
||||||
|
// under it.
|
||||||
|
const androidSharedStorage = "/storage/emulated/0"
|
||||||
|
|
||||||
|
// DefaultBrowseRoot is where a folder picker should open.
|
||||||
|
func (fe *FrontendUtil) DefaultBrowseRoot() string {
|
||||||
|
if runtime.GOOS == "android" {
|
||||||
|
for _, candidate := range []string{androidSharedStorage, "/storage"} {
|
||||||
|
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(filepath.Separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageAccess reports whether the app can actually read the place the
|
||||||
|
// user's music lives.
|
||||||
|
type StorageAccess struct {
|
||||||
|
Root string `json:"root"`
|
||||||
|
Readable bool `json:"readable"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckStorageAccess asks the filesystem rather than the permission
|
||||||
|
// system.
|
||||||
|
//
|
||||||
|
// On Android this app holds MANAGE_EXTERNAL_STORAGE, which the user
|
||||||
|
// grants on a Settings screen rather than in a dialog — so it can be
|
||||||
|
// refused, revoked later, or simply never answered, and the permission
|
||||||
|
// API is one more thing that can disagree with reality. Reading the
|
||||||
|
// directory is the question the library scanner will actually ask, so
|
||||||
|
// it is the one worth answering.
|
||||||
|
//
|
||||||
|
// It is deliberately not an error return: "we cannot read your music
|
||||||
|
// yet" is a state the UI renders, not a failure of the call.
|
||||||
|
func (fe *FrontendUtil) CheckStorageAccess() StorageAccess {
|
||||||
|
root := fe.DefaultBrowseRoot()
|
||||||
|
|
||||||
|
if _, err := os.ReadDir(root); err != nil {
|
||||||
|
return StorageAccess{
|
||||||
|
Root: root,
|
||||||
|
Readable: false,
|
||||||
|
Reason: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return StorageAccess{Root: root, Readable: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasNativeDirectoryPicker reports whether this platform can open a
|
||||||
|
// directory dialog at all.
|
||||||
|
//
|
||||||
|
// It is asked of the backend rather than tested in the frontend with
|
||||||
|
// `System.IsAndroid()`, for three reasons. The dialog *is* backend code
|
||||||
|
// — `DirectoryPicker` above — so this is the same package saying what
|
||||||
|
// it can do. It answers for iOS too without the frontend enumerating
|
||||||
|
// platforms. And it makes the frontend's fallback testable through the
|
||||||
|
// ordinary transport fake instead of a module mock of the Wails
|
||||||
|
// runtime, whose platform helpers read build constants.
|
||||||
|
func (fe *FrontendUtil) HasNativeDirectoryPicker() bool {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "android", "ios":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package frontendutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listing helper: a tree with the three shapes the picker has to get
|
||||||
|
// right — an ordinary directory, a file (never listed), and a hidden
|
||||||
|
// directory (never listed).
|
||||||
|
func browseFixture(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
for _, dir := range []string{"Music", "Podcasts", "aaa", ".thumbnails"} {
|
||||||
|
if err := os.Mkdir(filepath.Join(root, dir), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "track.mp3"), []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDirectories(t *testing.T) {
|
||||||
|
fe := &FrontendUtil{}
|
||||||
|
root := browseFixture(t)
|
||||||
|
|
||||||
|
got, err := fe.ListDirectories(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListDirectories: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted, directories only, no file and no dotted entry.
|
||||||
|
want := []string{"Music", "Podcasts", "aaa"}
|
||||||
|
if len(got.Entries) != len(want) {
|
||||||
|
t.Fatalf("got %d entries %v, want %v", len(got.Entries), got.Entries, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, w := range want {
|
||||||
|
if got.Entries[i].Name != w {
|
||||||
|
t.Errorf("entry %d = %q, want %q", i, got.Entries[i].Name, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Entries[i].Path != filepath.Join(root, w) {
|
||||||
|
t.Errorf("entry %d path = %q, want %q", i, got.Entries[i].Path, filepath.Join(root, w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Path != root {
|
||||||
|
t.Errorf("Path = %q, want %q", got.Path, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Parent != filepath.Dir(root) {
|
||||||
|
t.Errorf("Parent = %q, want %q", got.Parent, filepath.Dir(root))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A symlink reports itself rather than its target, so a listing that
|
||||||
|
// trusts DirEntry.IsDir() silently drops a symlinked music folder --
|
||||||
|
// which is an ordinary thing to have and an unexplainable thing to
|
||||||
|
// lose.
|
||||||
|
func TestListDirectoriesFollowsSymlinks(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("symlinks need elevation on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
target := filepath.Join(root, "real")
|
||||||
|
|
||||||
|
if err := os.Mkdir(target, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := filepath.Join(root, "linked")
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Fatalf("symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fe := &FrontendUtil{}
|
||||||
|
|
||||||
|
got, err := fe.ListDirectories(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListDirectories: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Entries) != 2 {
|
||||||
|
t.Fatalf("got %v, want both 'linked' and 'real'", got.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dangling symlink, and anything else os.Stat refuses, must be
|
||||||
|
// skipped rather than failing the whole listing: Android's storage
|
||||||
|
// root holds directories no app may enter, and one of them must not
|
||||||
|
// cost the user the picker.
|
||||||
|
func TestListDirectoriesSkipsUnreadable(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("symlinks need elevation on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "good"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dangling := filepath.Join(root, "dangling")
|
||||||
|
if err := os.Symlink(filepath.Join(root, "nowhere"), dangling); err != nil {
|
||||||
|
t.Fatalf("symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fe := &FrontendUtil{}
|
||||||
|
|
||||||
|
got, err := fe.ListDirectories(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListDirectories: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Entries) != 1 || got.Entries[0].Name != "good" {
|
||||||
|
t.Errorf("got %v, want only 'good'", got.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDirectoriesRejectsFiles(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
file := filepath.Join(root, "track.mp3")
|
||||||
|
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fe := &FrontendUtil{}
|
||||||
|
|
||||||
|
if _, err := fe.ListDirectories(file); err == nil {
|
||||||
|
t.Error("listing a file should be an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fe.ListDirectories(filepath.Join(root, "missing")); err == nil {
|
||||||
|
t.Error("listing a missing path should be an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty path means "start where the picker should open", so the
|
||||||
|
// frontend never has to know the platform.
|
||||||
|
func TestListDirectoriesDefaultsToBrowseRoot(t *testing.T) {
|
||||||
|
fe := &FrontendUtil{}
|
||||||
|
|
||||||
|
got, err := fe.ListDirectories("")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("default root not listable in this environment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Path != fe.DefaultBrowseRoot() {
|
||||||
|
t.Errorf("Path = %q, want the default root %q", got.Path, fe.DefaultBrowseRoot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent is empty at a root, which is what tells the picker not to draw
|
||||||
|
// an "up" control rather than making it reason about separators.
|
||||||
|
func TestListDirectoriesRootHasNoParent(t *testing.T) {
|
||||||
|
fe := &FrontendUtil{}
|
||||||
|
|
||||||
|
got, err := fe.ListDirectories(string(filepath.Separator))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("filesystem root not listable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Parent != "" {
|
||||||
|
t.Errorf("Parent = %q at the root, want empty", got.Parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckStorageAccess(t *testing.T) {
|
||||||
|
fe := &FrontendUtil{}
|
||||||
|
|
||||||
|
got := fe.CheckStorageAccess()
|
||||||
|
if got.Root == "" {
|
||||||
|
t.Error("Root should never be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The developer machine running this test can read its own home
|
||||||
|
// directory; the assertion is that the two fields agree, not that
|
||||||
|
// access is granted.
|
||||||
|
if got.Readable && got.Reason != "" {
|
||||||
|
t.Errorf("readable but Reason = %q", got.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !got.Readable && got.Reason == "" {
|
||||||
|
t.Error("not readable but no Reason given")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The picker's fallback is chosen from this, so a platform that gains
|
||||||
|
// a working dialog must flip it here rather than in the frontend.
|
||||||
|
func TestHasNativeDirectoryPicker(t *testing.T) {
|
||||||
|
fe := &FrontendUtil{}
|
||||||
|
|
||||||
|
want := runtime.GOOS != "android" && runtime.GOOS != "ios"
|
||||||
|
if got := fe.HasNativeDirectoryPicker(); got != want {
|
||||||
|
t.Errorf("HasNativeDirectoryPicker() on %s = %v, want %v", runtime.GOOS, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
// Android's answer to MPRIS is a MediaSession, and reaching it needs no
|
||||||
|
// new JNI: Wails exports application.Android.StartForegroundService(json)
|
||||||
|
// going out, and Java's WailsBridge.emitEvent lands on the application
|
||||||
|
// event bus coming back. So this handler is one JSON payload pushed to
|
||||||
|
// the foreground service and one command event read from it. The Java
|
||||||
|
// half is
|
||||||
|
// build/android/app/src/main/java/com/wails/app/WailsForegroundService.java
|
||||||
|
// and the payload keys below are its contract.
|
||||||
|
|
||||||
|
package mediacontrols
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// commandEvent is the event name the Java side emits transport
|
||||||
|
// commands on. It is a plain string on both sides; changing it means
|
||||||
|
// changing WailsForegroundService too.
|
||||||
|
const commandEvent = "yj:media:command"
|
||||||
|
|
||||||
|
var errNoApplication = errors.New(
|
||||||
|
"no running application to attach media controls to",
|
||||||
|
)
|
||||||
|
|
||||||
|
// androidHandler drives the media notification, the lock-screen
|
||||||
|
// transport and audio focus through the foreground service.
|
||||||
|
type androidHandler struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
callbacks Callbacks
|
||||||
|
meta Metadata
|
||||||
|
state PlaybackState
|
||||||
|
positionSec int
|
||||||
|
|
||||||
|
// running tracks whether the foreground service has been started.
|
||||||
|
// Android 12+ forbids starting one from the background, so it is
|
||||||
|
// started when playback starts -- a user action, in a visible app
|
||||||
|
// -- and stopped only when playback stops, which is what keeps
|
||||||
|
// queue auto-advance working with the screen off.
|
||||||
|
running bool
|
||||||
|
|
||||||
|
// lastPayload is the last JSON sent. An unchanged payload is not
|
||||||
|
// an event here either: every push crosses JNI and re-delivers an
|
||||||
|
// Intent, and the player pushes state on several paths that can
|
||||||
|
// agree.
|
||||||
|
lastPayload string
|
||||||
|
|
||||||
|
unsubscribe func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler returns the Android media-session handler.
|
||||||
|
func NewHandler(logger *slog.Logger) Handler {
|
||||||
|
return &androidHandler{logger: logger, state: StateStopped}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init subscribes to the transport commands the Java side emits.
|
||||||
|
func (a *androidHandler) Init(callbacks Callbacks) error {
|
||||||
|
app := application.Get()
|
||||||
|
if app == nil {
|
||||||
|
return errNoApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.callbacks = callbacks
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.unsubscribe = app.Event.On(commandEvent, a.onCommand)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onCommand dispatches one transport command from the notification,
|
||||||
|
// the lock screen, a headset button or an audio-focus change.
|
||||||
|
//
|
||||||
|
// Every callback runs on its own goroutine, for the reason the MPRIS
|
||||||
|
// handler does the same: they take the player and queue mutexes, and
|
||||||
|
// this runs on the event processor's dispatch goroutine.
|
||||||
|
func (a *androidHandler) onCommand(event *application.CustomEvent) {
|
||||||
|
data, ok := event.Data.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
command := parseMediaCommand(data)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
cb := a.callbacks
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
switch command.name {
|
||||||
|
case cmdPlay:
|
||||||
|
run(cb.OnPlay)
|
||||||
|
case cmdPause:
|
||||||
|
run(cb.OnPause)
|
||||||
|
case cmdPlayPause:
|
||||||
|
run(cb.OnPlayPause)
|
||||||
|
case cmdStop:
|
||||||
|
run(cb.OnStop)
|
||||||
|
case cmdNext:
|
||||||
|
run(cb.OnNext)
|
||||||
|
case cmdPrevious:
|
||||||
|
run(cb.OnPrevious)
|
||||||
|
case cmdSeek:
|
||||||
|
if cb.OnSeek != nil {
|
||||||
|
go cb.OnSeek(command.positionSec)
|
||||||
|
}
|
||||||
|
case cmdDuck:
|
||||||
|
if cb.OnDuck != nil {
|
||||||
|
go cb.OnDuck(command.duck)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
a.logger.Warn("Unknown media command", "command", command.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run invokes a callback on its own goroutine, tolerating a nil one.
|
||||||
|
func run(fn func()) {
|
||||||
|
if fn != nil {
|
||||||
|
go fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMetadata pushes new track details to the notification.
|
||||||
|
func (a *androidHandler) UpdateMetadata(meta Metadata) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.meta = meta
|
||||||
|
a.push()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePlaybackState pushes the state and a fresh position anchor;
|
||||||
|
// the MediaSession interpolates from there while playing.
|
||||||
|
func (a *androidHandler) UpdatePlaybackState(
|
||||||
|
state PlaybackState,
|
||||||
|
positionSec int,
|
||||||
|
) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.state = state
|
||||||
|
a.positionSec = positionSec
|
||||||
|
a.push()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifySeek re-anchors the position. Unlike MPRIS, a MediaSession has
|
||||||
|
// no separate seeked signal -- a new state with a new position is the
|
||||||
|
// whole mechanism.
|
||||||
|
func (a *androidHandler) NotifySeek(positionSec int) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.positionSec = positionSec
|
||||||
|
a.push()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateVolume is deliberately a no-op. Android's volume keys act on
|
||||||
|
// the media stream, which the OS owns; an app that also moved its own
|
||||||
|
// volume in response would move it twice.
|
||||||
|
func (a *androidHandler) UpdateVolume(_ float64) {}
|
||||||
|
|
||||||
|
// Close stops the service and drops the command subscription.
|
||||||
|
func (a *androidHandler) Close() {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
if a.unsubscribe != nil {
|
||||||
|
a.unsubscribe()
|
||||||
|
a.unsubscribe = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.running {
|
||||||
|
application.Android.StopForegroundService()
|
||||||
|
a.running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// push sends the current state to the Java side, if it has changed.
|
||||||
|
// The caller holds a.mu.
|
||||||
|
func (a *androidHandler) push() {
|
||||||
|
if a.state == StateStopped {
|
||||||
|
// Nothing is playing, so nothing justifies an ongoing
|
||||||
|
// notification or the process staying alive.
|
||||||
|
if a.running {
|
||||||
|
application.Android.StopForegroundService()
|
||||||
|
a.running = false
|
||||||
|
a.lastPayload = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := mediaPayload(a.meta, a.state, a.positionSec)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Failed to encode media payload", "err", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload == a.lastPayload {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.lastPayload = payload
|
||||||
|
a.running = true
|
||||||
|
|
||||||
|
application.Android.StartForegroundService(payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// The contract between the Android handler and the Java
|
||||||
|
// WailsForegroundService is two JSON documents -- one pushed out with
|
||||||
|
// the track and the state, one read back with a transport command --
|
||||||
|
// and neither side can check the other.
|
||||||
|
//
|
||||||
|
// It lives here, *without* the android build tag, so that `go test` on
|
||||||
|
// any platform exercises it. android.go itself can only be compiled by
|
||||||
|
// a cross-compiler and only be run by a phone, so anything left in it
|
||||||
|
// is untested by construction; this is the half worth not leaving
|
||||||
|
// there.
|
||||||
|
|
||||||
|
package mediacontrols
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Media command names, as the Java side spells them.
|
||||||
|
const (
|
||||||
|
cmdPlay = "play"
|
||||||
|
cmdPause = "pause"
|
||||||
|
cmdPlayPause = "playpause"
|
||||||
|
cmdStop = "stop"
|
||||||
|
cmdNext = "next"
|
||||||
|
cmdPrevious = "previous"
|
||||||
|
cmdSeek = "seek"
|
||||||
|
cmdDuck = "duck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stateNames are what the payload's "state" key carries. Words rather
|
||||||
|
// than the PlaybackState integers, because the Java side reads them as
|
||||||
|
// JSON and a renumbered constant would silently mean something else
|
||||||
|
// there.
|
||||||
|
var stateNames = map[PlaybackState]string{
|
||||||
|
StateStopped: "stopped",
|
||||||
|
StatePlaying: "playing",
|
||||||
|
StatePaused: "paused",
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaCommand is one transport command from the notification, the
|
||||||
|
// lock screen, a headset button or an audio-focus change.
|
||||||
|
type mediaCommand struct {
|
||||||
|
name string
|
||||||
|
positionSec int
|
||||||
|
duck bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaPayload encodes the state the notification and MediaSession
|
||||||
|
// render.
|
||||||
|
func mediaPayload(
|
||||||
|
meta Metadata,
|
||||||
|
state PlaybackState,
|
||||||
|
positionSec int,
|
||||||
|
) (string, error) {
|
||||||
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
"title": meta.Title,
|
||||||
|
"artist": meta.Artist,
|
||||||
|
"album": meta.Album,
|
||||||
|
"artPath": meta.ArtFilePath,
|
||||||
|
"durationSec": meta.DurationSec,
|
||||||
|
"positionSec": positionSec,
|
||||||
|
"state": stateNames[state],
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMediaCommand reads one command out of the event payload.
|
||||||
|
//
|
||||||
|
// The numbers arrive as float64 because they came through
|
||||||
|
// encoding/json as an untyped document -- asserting int here is the
|
||||||
|
// way a seek silently becomes a seek to zero.
|
||||||
|
func parseMediaCommand(data map[string]any) mediaCommand {
|
||||||
|
cmd := mediaCommand{}
|
||||||
|
cmd.name, _ = data["command"].(string)
|
||||||
|
|
||||||
|
if position, ok := data["positionSec"].(float64); ok {
|
||||||
|
cmd.positionSec = int(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.duck, _ = data["on"].(bool)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package mediacontrols
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMediaPayloadKeys pins the document the Java side parses. The
|
||||||
|
// keys are the contract: a rename here is silently a track with no
|
||||||
|
// title on the lock screen, because WailsForegroundService reads them
|
||||||
|
// with optString and a missing key is simply "".
|
||||||
|
func TestMediaPayloadKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
payload, err := mediaPayload(Metadata{
|
||||||
|
Title: "Tideline",
|
||||||
|
Artist: "Sea Change",
|
||||||
|
Album: "Ebb",
|
||||||
|
ArtFilePath: "/covers/ebb_lg.jpg",
|
||||||
|
DurationSec: 245,
|
||||||
|
}, StatePlaying, 30)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mediaPayload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(payload), &got); err != nil {
|
||||||
|
t.Fatalf("payload is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]any{
|
||||||
|
"title": "Tideline",
|
||||||
|
"artist": "Sea Change",
|
||||||
|
"album": "Ebb",
|
||||||
|
"artPath": "/covers/ebb_lg.jpg",
|
||||||
|
"durationSec": float64(245),
|
||||||
|
"positionSec": float64(30),
|
||||||
|
"state": "playing",
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("payload has %d keys, want %d: %s", len(got), len(want), payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, expected := range want {
|
||||||
|
if got[key] != expected {
|
||||||
|
t.Errorf("payload[%q] = %v, want %v", key, got[key], expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMediaPayloadStateNames covers the one value the Java side
|
||||||
|
// compares against a literal.
|
||||||
|
func TestMediaPayloadStateNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
state PlaybackState
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{StatePlaying, "playing"},
|
||||||
|
{StatePaused, "paused"},
|
||||||
|
{StateStopped, "stopped"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
payload, err := mediaPayload(Metadata{}, tt.state, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mediaPayload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(payload), &got); err != nil {
|
||||||
|
t.Fatalf("payload is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.State != tt.want {
|
||||||
|
t.Errorf("state %d encoded as %q, want %q", tt.state, got.State, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseMediaCommand covers the direction that arrives untyped.
|
||||||
|
// The seek case is the one with teeth: the position crosses as a JSON
|
||||||
|
// number, so it is a float64 in the map and an int assertion would
|
||||||
|
// make every seek a seek to zero.
|
||||||
|
func TestParseMediaCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data map[string]any
|
||||||
|
want mediaCommand
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "play",
|
||||||
|
data: map[string]any{"command": "play"},
|
||||||
|
want: mediaCommand{name: cmdPlay},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "seek carries a position",
|
||||||
|
data: map[string]any{"command": "seek", "positionSec": float64(93)},
|
||||||
|
want: mediaCommand{name: cmdSeek, positionSec: 93},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duck carries a flag",
|
||||||
|
data: map[string]any{"command": "duck", "on": true},
|
||||||
|
want: mediaCommand{name: cmdDuck, duck: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unduck",
|
||||||
|
data: map[string]any{"command": "duck", "on": false},
|
||||||
|
want: mediaCommand{name: cmdDuck},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a command with nothing in it is not a panic",
|
||||||
|
data: map[string]any{},
|
||||||
|
want: mediaCommand{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrongly typed fields fall back to zero",
|
||||||
|
data: map[string]any{"command": "seek", "positionSec": "93"},
|
||||||
|
want: mediaCommand{name: cmdSeek},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := parseMediaCommand(tt.data); got != tt.want {
|
||||||
|
t.Errorf("parseMediaCommand(%v) = %+v, want %+v", tt.data, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMediaCommandNamesAreWhatJavaSends is a spelling check against
|
||||||
|
// the Java side, which builds these strings by hand. It is a list, not
|
||||||
|
// a mechanism: nothing can reach across into the .java file, so the
|
||||||
|
// point is that changing one of these constants fails a test that
|
||||||
|
// names the file to change with it.
|
||||||
|
//
|
||||||
|
// See build/android/app/src/main/java/com/wails/app/WailsForegroundService.java.
|
||||||
|
func TestMediaCommandNamesAreWhatJavaSends(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"play", "pause", "playpause", "stop",
|
||||||
|
"next", "previous", "seek", "duck",
|
||||||
|
}
|
||||||
|
got := []string{
|
||||||
|
cmdPlay, cmdPause, cmdPlayPause, cmdStop,
|
||||||
|
cmdNext, cmdPrevious, cmdSeek, cmdDuck,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, name := range want {
|
||||||
|
if got[i] != name {
|
||||||
|
t.Errorf("command %d = %q, want %q", i, got[i], name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,13 @@ type Callbacks struct {
|
|||||||
OnPrevious func()
|
OnPrevious func()
|
||||||
OnSeek func(positionSec int)
|
OnSeek func(positionSec int)
|
||||||
OnVolume func(volume float64) // 0.0–1.0 linear scale.
|
OnVolume func(volume float64) // 0.0–1.0 linear scale.
|
||||||
|
|
||||||
|
// OnDuck asks for playback to be attenuated (true) or restored
|
||||||
|
// (false) without changing the user's volume. Android alone sends
|
||||||
|
// it, and only below API 26 -- from Oreo the audio framework ducks
|
||||||
|
// the app itself and reports no such focus change, so doing both
|
||||||
|
// would attenuate twice.
|
||||||
|
OnDuck func(ducked bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler manages the OS media control integration.
|
// Handler manages the OS media control integration.
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
//go:build linux
|
//go:build linux && !android
|
||||||
|
|
||||||
|
// MPRIS is a D-Bus desktop specification, and `android` implies the
|
||||||
|
// `linux` build tag -- so without the `!android` this file compiled
|
||||||
|
// into the Android app and went looking for a session bus that does
|
||||||
|
// not exist. Desktop-Linux-only files need both halves; see Wails'
|
||||||
|
// mobile guide, which names this as the Android analogue of
|
||||||
|
// ios/darwin.
|
||||||
|
|
||||||
package mediacontrols
|
package mediacontrols
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
//go:build !linux
|
//go:build !linux
|
||||||
|
|
||||||
|
// Windows and macOS have no media-control integration yet. `!linux`
|
||||||
|
// covers Android too without naming it, since `android` implies the
|
||||||
|
// `linux` tag -- android.go claims it, mpris_linux.go excludes it, and
|
||||||
|
// this file is left with the platforms neither wants.
|
||||||
|
|
||||||
package mediacontrols
|
package mediacontrols
|
||||||
|
|
||||||
import "log/slog"
|
import "log/slog"
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ type Player struct {
|
|||||||
trackChangeID uint64
|
trackChangeID uint64
|
||||||
mediaControls mediacontrols.Handler
|
mediaControls mediacontrols.Handler
|
||||||
|
|
||||||
|
// duckAmount is the attenuation currently applied on top of the
|
||||||
|
// user's volume, in the same base-2 exponent effects.Volume uses.
|
||||||
|
// It is deliberately not persisted and emits no VolumeChanged: a
|
||||||
|
// duck is something the OS did for the length of a notification,
|
||||||
|
// not something the user chose.
|
||||||
|
duckAmount float64
|
||||||
|
|
||||||
// trackLengthMs holds the authoritative track duration in
|
// trackLengthMs holds the authoritative track duration in
|
||||||
// milliseconds, sourced from the database (which uses the
|
// milliseconds, sourced from the database (which uses the
|
||||||
// custom header parser). The go-mp3 decoder's Len() can be
|
// custom header parser). The go-mp3 decoder's Len() can be
|
||||||
@@ -793,12 +800,40 @@ func (p *Player) setVolumeLocked(desiredVolume UserVolume) {
|
|||||||
speaker.Lock()
|
speaker.Lock()
|
||||||
|
|
||||||
volume := clampVolume(desiredVolume)
|
volume := clampVolume(desiredVolume)
|
||||||
p.volume.Volume = float64(volume.ToVolume())
|
p.volume.Volume = float64(volume.ToVolume()) - p.duckAmount
|
||||||
p.volume.Silent = volume == MinUserVol
|
p.volume.Silent = volume == MinUserVol
|
||||||
|
|
||||||
speaker.Unlock()
|
speaker.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDuck attenuates playback (or restores it) without changing the
|
||||||
|
// user's volume, for an OS that has asked us to get out of the way of
|
||||||
|
// something short -- a navigation prompt, a notification tone.
|
||||||
|
//
|
||||||
|
// It re-applies the *user's* level through setVolumeLocked rather than
|
||||||
|
// nudging the effect directly, so the offset cannot accumulate across
|
||||||
|
// repeated ducks, and it neither emits nor persists: the level the user
|
||||||
|
// set has not changed and the UI must not claim it has.
|
||||||
|
//
|
||||||
|
//wails:ignore // driven by OS audio focus, not by the frontend.
|
||||||
|
func (p *Player) SetDuck(ducked bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
amount := 0.0
|
||||||
|
if ducked {
|
||||||
|
amount = duckAttenuation
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.volume == nil || amount == p.duckAmount {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current := p.getUserVolume()
|
||||||
|
p.duckAmount = amount
|
||||||
|
p.setVolumeLocked(current)
|
||||||
|
}
|
||||||
|
|
||||||
// ChangeVolume adjusts the volume by a relative amount.
|
// ChangeVolume adjusts the volume by a relative amount.
|
||||||
func (p *Player) ChangeVolume(deltaVolume int) error {
|
func (p *Player) ChangeVolume(deltaVolume int) error {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@@ -812,7 +847,9 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Player) getUserVolume() UserVolume {
|
func (p *Player) getUserVolume() UserVolume {
|
||||||
return Volume(p.volume.Volume).ToUserVolume()
|
// Undo any duck, so every caller -- the event, the persisted
|
||||||
|
// state, a relative change -- sees the level the user chose.
|
||||||
|
return Volume(p.volume.Volume + p.duckAmount).ToUserVolume()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Muted reports whether playback is currently silenced.
|
// Muted reports whether playback is currently silenced.
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ const (
|
|||||||
MaxVol Volume = 0
|
MaxVol Volume = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// duckAttenuation is how far playback drops when the OS asks us to
|
||||||
|
// duck, on the same base-2 exponent scale: two steps is a quarter of
|
||||||
|
// the amplitude (-12 dB), which is audible under a spoken notification
|
||||||
|
// without sounding like a pause.
|
||||||
|
const duckAttenuation = 2.0
|
||||||
|
|
||||||
// ToVolume converts user volume to internal player volume.
|
// ToVolume converts user volume to internal player volume.
|
||||||
func (oldVol UserVolume) ToVolume() Volume {
|
func (oldVol UserVolume) ToVolume() Volume {
|
||||||
var newVol Volume
|
var newVol Volume
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package player
|
package player
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gopxl/beep/v2/effects"
|
||||||
|
|
||||||
"yellowjacket/backend/mediacontrols"
|
"yellowjacket/backend/mediacontrols"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -202,3 +205,60 @@ func TestStateToMediaControls(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSetDuck covers the property the duck rests on: the attenuation
|
||||||
|
// is applied to the output and is invisible to everything that asks
|
||||||
|
// what the volume is -- the event, the persisted state, a relative
|
||||||
|
// change. Getting that wrong would let one notification tone
|
||||||
|
// permanently rewrite the user's volume.
|
||||||
|
func TestSetDuck(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
p := NewPlayer(slog.Default(), nil)
|
||||||
|
p.volume = &effects.Volume{Base: 2}
|
||||||
|
p.setVolumeLocked(80)
|
||||||
|
|
||||||
|
unducked := p.volume.Volume
|
||||||
|
|
||||||
|
p.SetDuck(true)
|
||||||
|
|
||||||
|
if p.volume.Volume >= unducked {
|
||||||
|
t.Errorf(
|
||||||
|
"ducked output volume = %v, want less than %v",
|
||||||
|
p.volume.Volume, unducked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := p.getUserVolume(); got != 80 {
|
||||||
|
t.Errorf("user volume while ducked = %d, want 80", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second duck must not stack: the offset is re-applied to the
|
||||||
|
// user's level, never subtracted again from the current output.
|
||||||
|
ducked := p.volume.Volume
|
||||||
|
|
||||||
|
p.SetDuck(true)
|
||||||
|
|
||||||
|
if p.volume.Volume != ducked {
|
||||||
|
t.Errorf(
|
||||||
|
"duck applied twice = %v, want %v", p.volume.Volume, ducked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing the volume while ducked keeps the attenuation.
|
||||||
|
p.setVolumeLocked(60)
|
||||||
|
|
||||||
|
if got := p.getUserVolume(); got != 60 {
|
||||||
|
t.Errorf("user volume set while ducked = %d, want 60", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if want := float64(UserVolume(60).ToVolume()) - duckAttenuation; p.volume.Volume != want {
|
||||||
|
t.Errorf("output while ducked = %v, want %v", p.volume.Volume, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.SetDuck(false)
|
||||||
|
|
||||||
|
if want := float64(UserVolume(60).ToVolume()); p.volume.Volume != want {
|
||||||
|
t.Errorf("output after unduck = %v, want %v", p.volume.Volume, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,29 @@ const (
|
|||||||
// without touching the current user's real config.toml or yj.db.
|
// without touching the current user's real config.toml or yj.db.
|
||||||
const envHomeOverride = "YJ_HOME"
|
const envHomeOverride = "YJ_HOME"
|
||||||
|
|
||||||
|
// UseHomeOverride points every config and data path at base, by setting
|
||||||
|
// the same override a development sandbox uses.
|
||||||
|
//
|
||||||
|
// It exists for mobile, where the switch in buildUserDirPath has no
|
||||||
|
// answer: there is no home directory and no XDG, only a per-app private
|
||||||
|
// directory the OS hands out at runtime. The caller is main(), which is
|
||||||
|
// the only place that can ask the platform for it — deliberately, so
|
||||||
|
// this package stays free of the Wails application package that knows
|
||||||
|
// (see backend/events' indexbuild split for why that matters).
|
||||||
|
//
|
||||||
|
// Two rules. An empty base is a no-op, because that is exactly what
|
||||||
|
// application.Mobile.StoragePath() returns on desktop. And an override
|
||||||
|
// that is already set wins, so YJ_HOME on the command line still
|
||||||
|
// relocates a sandbox on a platform that would otherwise decide for
|
||||||
|
// itself.
|
||||||
|
func UseHomeOverride(base string) {
|
||||||
|
if base == "" || os.Getenv(envHomeOverride) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Setenv(envHomeOverride, base)
|
||||||
|
}
|
||||||
|
|
||||||
// getUserDirPath returns and creates the path for a user directory.
|
// getUserDirPath returns and creates the path for a user directory.
|
||||||
func getUserDirPath(dt dirType) (string, error) {
|
func getUserDirPath(dt dirType) (string, error) {
|
||||||
path, err := resolveUserDirPath(dt)
|
path, err := resolveUserDirPath(dt)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package system
|
package system
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -50,3 +51,39 @@ func TestResolveUserDirPath_NoOverrideUsesOSPath(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UseHomeOverride carries two rules that a mobile launch depends on and
|
||||||
|
// that nothing else would notice breaking: an empty base must do
|
||||||
|
// nothing, because that is precisely what StoragePath() returns on
|
||||||
|
// desktop, and an override already set must win, or YJ_HOME would stop
|
||||||
|
// relocating a sandbox on the platform that decides for itself.
|
||||||
|
func TestUseHomeOverride(t *testing.T) {
|
||||||
|
const (
|
||||||
|
storage = "/data/user/0/app.yellowjacket/files"
|
||||||
|
sandbox = "/tmp/sandbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
already string
|
||||||
|
base string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty base is a no-op", already: "", base: "", want: ""},
|
||||||
|
{name: "sets the override when unset", already: "", base: storage, want: storage},
|
||||||
|
{name: "an existing override wins", already: sandbox, base: storage, want: sandbox},
|
||||||
|
{name: "empty base keeps an existing override", already: sandbox, base: "", want: sandbox},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Setenv(envHomeOverride, tt.already)
|
||||||
|
|
||||||
|
UseHomeOverride(tt.base)
|
||||||
|
|
||||||
|
if got := os.Getenv(envHomeOverride); got != tt.want {
|
||||||
|
t.Errorf("%s = %q, want %q", envHomeOverride, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ../Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# The *installed* package name, which every adb-driven task below uses
|
||||||
|
# to uninstall, launch and filter. It must agree with `applicationId`
|
||||||
|
# in app/build.gradle, and nothing enforces that.
|
||||||
|
#
|
||||||
|
# ANDROID.md says to set this in build/config.yml. That does not work
|
||||||
|
# in beta.8, checked both ways: `wails3 task` builds its var set from
|
||||||
|
# CLI KEY=VALUE arguments and the Taskfile tree only -- nothing reads
|
||||||
|
# config.yml -- and even when set it feeds only these adb commands,
|
||||||
|
# never Gradle. So the identity is declared twice, here and in
|
||||||
|
# build.gradle, and a change to one alone means the official run and
|
||||||
|
# deploy tasks address a package that is not installed.
|
||||||
|
APP_ID: '{{.APP_ID | default "app.yellowjacket"}}'
|
||||||
|
MIN_SDK: '21'
|
||||||
|
TARGET_SDK: '35'
|
||||||
|
# The emulator runs the host architecture; physical devices are arm64
|
||||||
|
HOST_ARCH:
|
||||||
|
sh: '[ "$(uname -m)" = "x86_64" ] && echo "amd64" || echo "arm64"'
|
||||||
|
# System-image ABI for the host, used in the "create an AVD" hint below.
|
||||||
|
ANDROID_ABI:
|
||||||
|
sh: '[ "$(uname -m)" = "arm64" ] && echo "arm64-v8a" || echo "x86_64"'
|
||||||
|
# SDK location: $ANDROID_HOME / $ANDROID_SDK_ROOT, else the per-OS default
|
||||||
|
# (macOS: ~/Library/Android/sdk, Linux/other: ~/Android/Sdk)
|
||||||
|
SDK_ROOT:
|
||||||
|
sh: 'echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}"'
|
||||||
|
ADB:
|
||||||
|
sh: 'command -v adb || echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}/platform-tools/adb"'
|
||||||
|
EMULATOR:
|
||||||
|
sh: 'command -v emulator || echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}/emulator/emulator"'
|
||||||
|
# avdmanager lives under cmdline-tools/<version>/bin; used to auto-create an
|
||||||
|
# AVD when none exists (mirrors the iOS `ensure-simulator` auto-create flow).
|
||||||
|
AVDMANAGER:
|
||||||
|
sh: 'command -v avdmanager || ls "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}"/cmdline-tools/*/bin/avdmanager 2>/dev/null | sort -V | tail -1 || true'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
install:deps:
|
||||||
|
summary: Check and install Android development dependencies
|
||||||
|
cmds:
|
||||||
|
- go run build/android/scripts/deps/install_deps.go
|
||||||
|
env:
|
||||||
|
TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}'
|
||||||
|
prompt: This will check and install Android development dependencies. Continue?
|
||||||
|
|
||||||
|
build:
|
||||||
|
summary: Creates a debug build of the application for Android
|
||||||
|
deps:
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: generate:android:overlay
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
PRODUCTION:
|
||||||
|
ref: .PRODUCTION
|
||||||
|
cmds:
|
||||||
|
- echo "Building Android app {{.APP_NAME}}..."
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default .HOST_ARCH}}'
|
||||||
|
# This repo's one edit to the android scaffold, and it is not
|
||||||
|
# cosmetic. Upstream forwards ARCH here and not PRODUCTION, so
|
||||||
|
# compile:go:shared recomputed BUILD_FLAGS against an unset
|
||||||
|
# .PRODUCTION and fell back to the debug branch. package:fat
|
||||||
|
# calls compile:go:shared directly for amd64 (passing it), and
|
||||||
|
# reaches arm64 only through this task -- so a release APK
|
||||||
|
# shipped a *debug* 40 MB arm64 library beside a production
|
||||||
|
# 31 MB x86_64 one. The phone ABI, which is the only one a
|
||||||
|
# release is for, was the broken one. 34 MB APK before, 27
|
||||||
|
# after.
|
||||||
|
PRODUCTION: '{{.PRODUCTION}}'
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
|
||||||
|
env:
|
||||||
|
PRODUCTION: '{{.PRODUCTION | default "false"}}'
|
||||||
|
|
||||||
|
compile:go:shared:
|
||||||
|
summary: Compile Go code to shared library (.so)
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
# Locate the NDK: $ANDROID_NDK_HOME, or the newest installed NDK
|
||||||
|
NDK_ROOT="$ANDROID_NDK_HOME"
|
||||||
|
if [ -z "$NDK_ROOT" ]; then
|
||||||
|
SDK_ROOT="{{.SDK_ROOT}}"
|
||||||
|
NDK_ROOT=$(ls -d "$SDK_ROOT"/ndk/* 2>/dev/null | sort -V | tail -1)
|
||||||
|
fi
|
||||||
|
if [ -z "$NDK_ROOT" ] || [ ! -d "$NDK_ROOT" ]; then
|
||||||
|
echo "Error: Android NDK not found"
|
||||||
|
echo "Install one with: sdkmanager 'ndk;26.3.11579264' (or set ANDROID_NDK_HOME)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine toolchain based on host OS
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) HOST_TAG="darwin-x86_64" ;;
|
||||||
|
Linux) HOST_TAG="linux-x86_64" ;;
|
||||||
|
*) echo "Unsupported host OS"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG"
|
||||||
|
|
||||||
|
# Set compiler based on architecture
|
||||||
|
case "{{.ARCH}}" in
|
||||||
|
arm64)
|
||||||
|
export CC="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang"
|
||||||
|
export CXX="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang++"
|
||||||
|
export GOARCH=arm64
|
||||||
|
JNI_DIR="arm64-v8a"
|
||||||
|
;;
|
||||||
|
amd64|x86_64)
|
||||||
|
export CC="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang"
|
||||||
|
export CXX="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang++"
|
||||||
|
export GOARCH=amd64
|
||||||
|
JNI_DIR="x86_64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported architecture: {{.ARCH}}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export GOOS=android
|
||||||
|
|
||||||
|
mkdir -p {{.BIN_DIR}}
|
||||||
|
mkdir -p build/android/app/src/main/jniLibs/$JNI_DIR
|
||||||
|
|
||||||
|
go build -buildmode=c-shared -overlay build/android/overlay.json {{.BUILD_FLAGS}} \
|
||||||
|
-o build/android/app/src/main/jniLibs/$JNI_DIR/libwails.so
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
|
||||||
|
|
||||||
|
compile:go:all-archs:
|
||||||
|
summary: Compile Go code for all Android architectures (fat APK)
|
||||||
|
cmds:
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: arm64
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: amd64
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages a production build of the application into a signed release APK
|
||||||
|
desc: |
|
||||||
|
Builds for arm64 by default (covers 99%+ of real devices). Set ARCH=amd64
|
||||||
|
for emulator-only APKs, or use package:fat for a universal APK.
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
PRODUCTION: "true"
|
||||||
|
ARCH: '{{.ARCH | default "arm64"}}'
|
||||||
|
cmds:
|
||||||
|
- task: assemble:apk:release
|
||||||
|
|
||||||
|
package:fat:
|
||||||
|
summary: Packages a production build for all architectures (fat APK)
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
PRODUCTION: "true"
|
||||||
|
ARCH: arm64
|
||||||
|
cmds:
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: amd64
|
||||||
|
PRODUCTION: "true"
|
||||||
|
- task: assemble:apk:release
|
||||||
|
|
||||||
|
bundle:
|
||||||
|
summary: Packages a production AAB (Android App Bundle) for Play Store submission
|
||||||
|
desc: |
|
||||||
|
Builds for arm64 by default. Set ARCH=amd64 for emulator builds, or use
|
||||||
|
bundle:fat for a universal AAB.
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
PRODUCTION: "true"
|
||||||
|
ARCH: '{{.ARCH | default "arm64"}}'
|
||||||
|
cmds:
|
||||||
|
- task: assemble:aab:release
|
||||||
|
|
||||||
|
bundle:fat:
|
||||||
|
summary: Packages a production AAB for all architectures
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
PRODUCTION: "true"
|
||||||
|
ARCH: arm64
|
||||||
|
cmds:
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: amd64
|
||||||
|
PRODUCTION: "true"
|
||||||
|
- task: assemble:aab:release
|
||||||
|
|
||||||
|
assemble:apk:
|
||||||
|
summary: Assembles a debug APK using Gradle
|
||||||
|
preconditions:
|
||||||
|
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
|
||||||
|
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
cd build/android
|
||||||
|
# The exec bit is lost when gradlew is extracted from the embedded
|
||||||
|
# build assets, so restore it before invoking the wrapper.
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew assembleDebug
|
||||||
|
cp app/build/outputs/apk/debug/app-debug.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
echo "APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
|
||||||
|
assemble:apk:release:
|
||||||
|
summary: Assembles a release APK using Gradle (signed with the debug keystore unless ANDROID_KEYSTORE_FILE is set)
|
||||||
|
preconditions:
|
||||||
|
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
|
||||||
|
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
cd build/android
|
||||||
|
# The exec bit is lost when gradlew is extracted from the embedded
|
||||||
|
# build assets, so restore it before invoking the wrapper.
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew assembleRelease
|
||||||
|
cp app/build/outputs/apk/release/app-release.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
echo "Release APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
|
||||||
|
assemble:aab:
|
||||||
|
summary: Assembles a debug AAB (Android App Bundle) using Gradle
|
||||||
|
preconditions:
|
||||||
|
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
|
||||||
|
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
cd build/android
|
||||||
|
# The exec bit is lost when gradlew is extracted from the embedded
|
||||||
|
# build assets, so restore it before invoking the wrapper.
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew bundleDebug
|
||||||
|
cp app/build/outputs/bundle/debug/app-debug.aab "../../{{.BIN_DIR}}/{{.APP_NAME}}.aab"
|
||||||
|
echo "AAB created: {{.BIN_DIR}}/{{.APP_NAME}}.aab"
|
||||||
|
|
||||||
|
assemble:aab:release:
|
||||||
|
summary: Assembles a release AAB for Play Store upload (signed with the debug keystore unless ANDROID_KEYSTORE_FILE is set)
|
||||||
|
preconditions:
|
||||||
|
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
|
||||||
|
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
# With Play App Signing, the keystore configured here is your UPLOAD
|
||||||
|
# key: Google verifies the upload with it, then re-signs the app with
|
||||||
|
# the app signing key it manages for distribution.
|
||||||
|
if [ -z "$ANDROID_KEYSTORE_FILE" ]; then
|
||||||
|
echo "WARNING: ANDROID_KEYSTORE_FILE is not set, so this AAB will be"
|
||||||
|
echo "signed with the debug keystore. Google Play rejects debug-signed"
|
||||||
|
echo "bundles. Set ANDROID_KEYSTORE_FILE, ANDROID_KEYSTORE_PASSWORD,"
|
||||||
|
echo "ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD before uploading."
|
||||||
|
fi
|
||||||
|
cd build/android
|
||||||
|
# The exec bit is lost when gradlew is extracted from the embedded
|
||||||
|
# build assets, so restore it before invoking the wrapper.
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew bundleRelease
|
||||||
|
cp app/build/outputs/bundle/release/app-release.aab "../../{{.BIN_DIR}}/{{.APP_NAME}}.aab"
|
||||||
|
echo "Release AAB created: {{.BIN_DIR}}/{{.APP_NAME}}.aab"
|
||||||
|
|
||||||
|
generate:android:overlay:
|
||||||
|
internal: true
|
||||||
|
summary: Generate Go build overlay that registers the Android main
|
||||||
|
sources:
|
||||||
|
- build/config.yml
|
||||||
|
generates:
|
||||||
|
- build/android/overlay.json
|
||||||
|
- build/android/gen/main_android.gen.go
|
||||||
|
cmds:
|
||||||
|
- wails3 android overlay:gen -out build/android/overlay.json -config build/config.yml
|
||||||
|
|
||||||
|
generate:android:bindings:
|
||||||
|
internal: true
|
||||||
|
summary: Generates bindings for Android
|
||||||
|
sources:
|
||||||
|
- "**/*.go"
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
generates:
|
||||||
|
- frontend/bindings/**/*
|
||||||
|
cmds:
|
||||||
|
# Bindings are generated from the Go AST; CGO is disabled so the NDK
|
||||||
|
# is not required for this step
|
||||||
|
- wails3 generate bindings -f '-tags android' -clean=true
|
||||||
|
env:
|
||||||
|
GOOS: android
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
|
||||||
|
ensure-emulator:
|
||||||
|
internal: true
|
||||||
|
summary: Ensure Android Emulator is running
|
||||||
|
silent: true
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
# Check if an emulator is already running
|
||||||
|
if "{{.ADB}}" devices | grep -q "emulator"; then
|
||||||
|
echo "Emulator already running"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get first available AVD
|
||||||
|
AVD_NAME=$("{{.EMULATOR}}" -list-avds | tail -1)
|
||||||
|
if [ -z "$AVD_NAME" ]; then
|
||||||
|
# No AVD yet. Mirror the iOS `ensure-simulator` flow and create one
|
||||||
|
# automatically — but ONLY from a system image that is already
|
||||||
|
# installed. We never trigger an sdkmanager download from a `run`
|
||||||
|
# task (that would be a surprise multi-GB download + license prompt).
|
||||||
|
# Pick the highest-API installed image matching the host ABI.
|
||||||
|
SDK_ROOT="{{.SDK_ROOT}}"
|
||||||
|
ABI="{{.ANDROID_ABI}}"
|
||||||
|
IMG=$(ls -d "$SDK_ROOT"/system-images/android-*/*/"$ABI" 2>/dev/null | sort -V | tail -1)
|
||||||
|
AVDMANAGER="{{.AVDMANAGER}}"
|
||||||
|
if [ -n "$IMG" ] && [ -x "$AVDMANAGER" ]; then
|
||||||
|
PKG="system-images;$(echo "$IMG" | sed "s|$SDK_ROOT/system-images/||" | tr '/' ';')"
|
||||||
|
echo "No Android Virtual Devices found. Creating 'wails' from $PKG..."
|
||||||
|
echo "no" | "$AVDMANAGER" create avd --name wails --package "$PKG" --device pixel_7 --force
|
||||||
|
AVD_NAME=wails
|
||||||
|
else
|
||||||
|
echo "No Android Virtual Devices found, and no system image is installed to create one from."
|
||||||
|
echo "Install a system image and create an AVD, e.g.:"
|
||||||
|
echo " sdkmanager 'system-images;android-35;google_apis;$ABI'"
|
||||||
|
echo " avdmanager create avd --name wails --package 'system-images;android-35;google_apis;$ABI' --device pixel_7"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting emulator: $AVD_NAME"
|
||||||
|
# Start the emulator daemonized so it outlives this task step. go-task's
|
||||||
|
# shell tracks background jobs by PID and reaps them when the command's
|
||||||
|
# interpreter finishes (which nohup/setsid alone don't prevent — the kill
|
||||||
|
# is direct), so a bare `emulator &` is gone before the later
|
||||||
|
# install/launch steps run. Launch it from a short-lived child shell that
|
||||||
|
# backgrounds the emulator and exits immediately: the emulator is then
|
||||||
|
# reparented to init/launchd and go-task's shell has no handle to reap it.
|
||||||
|
nohup sh -c "'{{.EMULATOR}}' -avd '$AVD_NAME' -no-snapshot-load </dev/null >/dev/null 2>&1 &" >/dev/null 2>&1
|
||||||
|
|
||||||
|
# Wait for emulator to boot (max 120 seconds)
|
||||||
|
echo "Waiting for emulator to boot..."
|
||||||
|
"{{.ADB}}" wait-for-device
|
||||||
|
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
BOOT_COMPLETED=$("{{.ADB}}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
|
||||||
|
if [ "$BOOT_COMPLETED" = "1" ]; then
|
||||||
|
echo "Emulator booted successfully"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Emulator boot timeout"
|
||||||
|
exit 1
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
|
||||||
|
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
|
||||||
|
- sh: '[ -x "{{.EMULATOR}}" ] || command -v emulator'
|
||||||
|
msg: "emulator not found. Install the Android SDK emulator package (or set ANDROID_HOME)"
|
||||||
|
|
||||||
|
deploy-emulator:
|
||||||
|
summary: Deploy the packaged release APK to the Android Emulator
|
||||||
|
deps:
|
||||||
|
- task: package
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default .HOST_ARCH}}'
|
||||||
|
cmds:
|
||||||
|
- task: ensure-emulator
|
||||||
|
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
|
||||||
|
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
|
||||||
|
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
|
||||||
|
|
||||||
|
run:
|
||||||
|
summary: Build, install and launch a debug build in the Android Emulator
|
||||||
|
deps:
|
||||||
|
- task: ensure-emulator
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: assemble:apk
|
||||||
|
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
|
||||||
|
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
|
||||||
|
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
|
||||||
|
|
||||||
|
device:list:
|
||||||
|
summary: Lists connected Android devices and emulators (serials)
|
||||||
|
cmds:
|
||||||
|
- '"{{.ADB}}" devices -l'
|
||||||
|
|
||||||
|
run:device:
|
||||||
|
summary: Build, install and launch a debug build on a connected physical Android device
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
ARCH: arm64
|
||||||
|
cmds:
|
||||||
|
- task: assemble:apk
|
||||||
|
- |
|
||||||
|
DEVICE='{{.DEVICE_ID | default ""}}'
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE="${DEVICE_ID:-}"
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
echo "Error: no connected physical Android device found."
|
||||||
|
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
|
||||||
|
echo "Find connected device serials with: {{.ADB}} devices"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
|
||||||
|
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
|
||||||
|
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
|
||||||
|
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
|
||||||
|
|
||||||
|
deploy-device:
|
||||||
|
summary: Deploy the packaged release APK to a connected physical Android device
|
||||||
|
deps:
|
||||||
|
- task: package
|
||||||
|
vars:
|
||||||
|
ARCH: arm64
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
DEVICE='{{.DEVICE_ID | default ""}}'
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE="${DEVICE_ID:-}"
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
echo "Error: no connected physical Android device found."
|
||||||
|
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
|
||||||
|
echo "Find connected device serials with: {{.ADB}} devices"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
|
||||||
|
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
|
||||||
|
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
|
||||||
|
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
|
||||||
|
|
||||||
|
studio:
|
||||||
|
summary: Open the generated Android project in Android Studio
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
if command -v studio >/dev/null 2>&1; then
|
||||||
|
studio build/android
|
||||||
|
elif [ -d "/Applications/Android Studio.app" ]; then
|
||||||
|
open -a "Android Studio" build/android
|
||||||
|
else
|
||||||
|
echo "Android Studio not found. Install it from https://developer.android.com/studio,"
|
||||||
|
echo "then open the build/android directory."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
logs:
|
||||||
|
summary: Stream Android logcat filtered to this app
|
||||||
|
cmds:
|
||||||
|
- '"{{.ADB}}" logcat -v time | grep -E "(Wails|{{.APP_NAME}})" || true'
|
||||||
|
|
||||||
|
logs:all:
|
||||||
|
summary: Stream all Android logcat (verbose)
|
||||||
|
cmds:
|
||||||
|
- '"{{.ADB}}" logcat -v time'
|
||||||
|
|
||||||
|
clean:
|
||||||
|
summary: Clean build artifacts
|
||||||
|
cmds:
|
||||||
|
- rm -rf {{.BIN_DIR}}
|
||||||
|
- rm -rf build/android/app/build
|
||||||
|
- rm -rf build/android/app/src/main/jniLibs/*/libwails.so
|
||||||
|
- rm -rf build/android/.gradle
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.wails.app'
|
||||||
|
compileSdk 35
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// The app's identity on the device. `namespace` above stays
|
||||||
|
// com.wails.app -- that is the *Java package* the scaffold's
|
||||||
|
// MainActivity/WailsBridge live in, and renaming it would mean
|
||||||
|
// renaming their source. The two being different is normal and is
|
||||||
|
// why every `am start` needs the fully-qualified activity name
|
||||||
|
// (app.yellowjacket/com.wails.app.MainActivity), not `.MainActivity`.
|
||||||
|
//
|
||||||
|
// Matches build/config.yml's productIdentifier.
|
||||||
|
applicationId "app.yellowjacket"
|
||||||
|
minSdk 21
|
||||||
|
targetSdk 35
|
||||||
|
|
||||||
|
// **Android orders releases by this integer, not by the version
|
||||||
|
// string, and refuses to install anything not greater than what is
|
||||||
|
// already there.** A hardcoded 1 means the first install is the
|
||||||
|
// last: every later build is rejected as a downgrade and the only
|
||||||
|
// way out is an uninstall, which takes the user's library with it.
|
||||||
|
// CI derives it from the tag (1.3.1 -> 10301), monotonic as long as
|
||||||
|
// minor and patch stay under 100. The defaults keep a local build
|
||||||
|
// working with no environment at all.
|
||||||
|
//
|
||||||
|
// `Integer.parseInt`, not `(...) as Integer`: Groovy binds the call
|
||||||
|
// parentheses to `versionCode` before the cast, so the latter reads
|
||||||
|
// as `versionCode("1") as Integer` -- it sets a String, then casts
|
||||||
|
// the setter's null return, and Gradle fails the entire project
|
||||||
|
// with "Value is null" pointing at this line.
|
||||||
|
versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")
|
||||||
|
versionName System.getenv("YJ_VERSION") ?: "0.0.0"
|
||||||
|
|
||||||
|
// **arm64 only, and x86_64 is not a gap.** `modernc.org/libc`'s
|
||||||
|
// Xlstat64 issues a raw lstat syscall on linux/amd64, which
|
||||||
|
// Android's seccomp policy forbids (bionic never issues it), so
|
||||||
|
// the process takes SIGSYS the first time anything touches the
|
||||||
|
// database -- which for this app is startup. That is every
|
||||||
|
// x86_64 Android, emulators and x86 Chromebooks alike, not just
|
||||||
|
// some. arm64 is structurally unaffected: the architecture has
|
||||||
|
// no lstat syscall at all, so modernc routes through fstatat.
|
||||||
|
//
|
||||||
|
// So the second ABI was ~31 MB of an artifact that could not run
|
||||||
|
// anywhere. If modernc fixes it, adding 'x86_64' back here and
|
||||||
|
// to the native-code assertion in android-apk.yml is the whole
|
||||||
|
// change.
|
||||||
|
ndk {
|
||||||
|
abiFilters 'arm64-v8a'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")
|
||||||
|
def hasKeystore = keystoreFile != null && !keystoreFile.trim().isEmpty()
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
// A real keystore can be provided via environment variables; without
|
||||||
|
// one, release builds are signed with the debug keystore so they can
|
||||||
|
// be installed for testing (not suitable for Play Store uploads).
|
||||||
|
release {
|
||||||
|
if (hasKeystore) {
|
||||||
|
storeFile file(keystoreFile)
|
||||||
|
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||||
|
keyAlias System.getenv("ANDROID_KEY_ALIAS")
|
||||||
|
keyPassword System.getenv("ANDROID_KEY_PASSWORD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
signingConfig hasKeystore ? signingConfigs.release : signingConfigs.debug
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
debuggable true
|
||||||
|
// Its own application id, so it installs *beside* the release
|
||||||
|
// app rather than needing an uninstall to replace it. The two
|
||||||
|
// are signed by different certificates (the release one comes
|
||||||
|
// from a keystore CI holds), and Android's remedy for a
|
||||||
|
// certificate change is an uninstall -- which takes the
|
||||||
|
// user's library with it. This is also what makes the WebView
|
||||||
|
// inspectable on a real phone: `debuggable` is what turns on
|
||||||
|
// `setWebContentsDebuggingEnabled`, and `make android-inspect`
|
||||||
|
// drives it.
|
||||||
|
applicationIdSuffix ".dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source sets configuration
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
// JNI libraries are in jniLibs folder
|
||||||
|
jniLibs.srcDirs = ['src/main/jniLibs']
|
||||||
|
// Assets for the WebView
|
||||||
|
assets.srcDirs = ['src/main/assets']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packaging options
|
||||||
|
packagingOptions {
|
||||||
|
// Don't strip Go symbols in debug builds
|
||||||
|
doNotStrip '*/arm64-v8a/libwails.so'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'androidx.webkit:webkit:1.9.0'
|
||||||
|
implementation 'com.google.android.material:material:1.11.0'
|
||||||
|
implementation 'androidx.biometric:biometric:1.1.0'
|
||||||
|
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
|
||||||
|
# Keep native methods
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep Wails bridge classes
|
||||||
|
-keep class com.wails.app.WailsBridge { *; }
|
||||||
|
-keep class com.wails.app.WailsJSBridge { *; }
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Internet permission for WebView -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<!-- Observe network connectivity / type for android:NetworkChanged events -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Playback has to survive the screen locking, and that needs a
|
||||||
|
foreground service typed mediaPlayback rather than dataSync. The
|
||||||
|
type in the <service> element and the permission here must agree,
|
||||||
|
or startForeground throws at runtime.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Reading the user's music.
|
||||||
|
|
||||||
|
READ_MEDIA_AUDIO is the Android 13+ grant and READ_EXTERNAL_STORAGE
|
||||||
|
is its predecessor, capped so it is not requested where it no
|
||||||
|
longer applies. Both give access through **MediaStore**.
|
||||||
|
|
||||||
|
MANAGE_EXTERNAL_STORAGE is what gives access through the
|
||||||
|
*filesystem*, and this app needs it rather than merely preferring
|
||||||
|
it: `audio_files.file_path` is the primary key of ownership, the
|
||||||
|
scanner walks a directory the user chose, and every
|
||||||
|
GetFilePathsBy... query exists to hand a path to the player.
|
||||||
|
MediaStore offers no stable directory to walk and no way to write
|
||||||
|
a tag back in place, so the alternative is not "more work" but a
|
||||||
|
different application.
|
||||||
|
|
||||||
|
It is a Play-restricted permission, granted on a Settings screen
|
||||||
|
rather than in a dialog. That is acceptable *here* only because
|
||||||
|
this app is distributed as an APK through the package registry and
|
||||||
|
not through Play — see docs/android-release.md. If it ever targets
|
||||||
|
Play, this is the line that has to go, and plan 016 says what
|
||||||
|
would replace it.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="29" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.media.action.VIDEO_CAPTURE" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.WailsApp"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
mediaPlayback, not the scaffold's dataSync: this app's reason
|
||||||
|
for staying alive in the background is that a song is
|
||||||
|
playing, and Android matches the declared type against what
|
||||||
|
the service actually does.
|
||||||
|
-->
|
||||||
|
<service
|
||||||
|
android:name=".WailsForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,967 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
|
import android.content.res.Configuration;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.net.ConnectivityManager;
|
||||||
|
import android.net.Network;
|
||||||
|
import android.net.NetworkCapabilities;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.Manifest;
|
||||||
|
import android.os.BatteryManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.os.PowerManager;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.provider.MediaStore;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
import android.util.Base64;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.WebResourceRequest;
|
||||||
|
import android.webkit.WebResourceResponse;
|
||||||
|
import android.webkit.WebSettings;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import android.webkit.WebViewClient;
|
||||||
|
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.core.content.FileProvider;
|
||||||
|
import androidx.core.graphics.Insets;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
|
import androidx.core.view.WindowCompat;
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat;
|
||||||
|
import androidx.webkit.WebViewAssetLoader;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MainActivity hosts the WebView and manages the Wails application lifecycle.
|
||||||
|
* It uses WebViewAssetLoader to serve assets from the Go library without
|
||||||
|
* requiring a network server.
|
||||||
|
*/
|
||||||
|
public class MainActivity extends AppCompatActivity {
|
||||||
|
private static final String TAG = "WailsActivity";
|
||||||
|
private static final boolean DEBUG = BuildConfig.DEBUG;
|
||||||
|
private static final String WAILS_SCHEME = "https";
|
||||||
|
private static final String WAILS_HOST = "wails.localhost";
|
||||||
|
private static final int FILE_PICKER_REQUEST = 7001;
|
||||||
|
|
||||||
|
private WebView webView;
|
||||||
|
private WailsBridge bridge;
|
||||||
|
// Battery: system-event receivers are registered only while the activity is
|
||||||
|
// in the foreground (onStart) and torn down in onStop, so background battery/
|
||||||
|
// network/screen broadcasts don't wake the app.
|
||||||
|
private boolean systemReceiversRegistered = false;
|
||||||
|
private WebViewAssetLoader assetLoader;
|
||||||
|
|
||||||
|
// The Go-side dialog ID of the in-flight file picker (-1 when idle)
|
||||||
|
private int pendingFilePickerCallbackID = -1;
|
||||||
|
private static final int PHOTO_CAPTURE_REQUEST = 7002;
|
||||||
|
private static final int VIDEO_CAPTURE_REQUEST = 7003;
|
||||||
|
private static final int CAMERA_PERMISSION_REQUEST = 7010;
|
||||||
|
private File pendingCaptureFile;
|
||||||
|
private boolean pendingCaptureIsVideo;
|
||||||
|
|
||||||
|
// System-event sources (battery/power, screen lock, network). Registered in
|
||||||
|
// onCreate, torn down in onDestroy. Each forwards a "system:*" event to JS
|
||||||
|
// via the bridge.
|
||||||
|
private BroadcastReceiver batteryReceiver;
|
||||||
|
private BroadcastReceiver screenReceiver;
|
||||||
|
private BroadcastReceiver powerSaveReceiver;
|
||||||
|
private ConnectivityManager connectivityManager;
|
||||||
|
private ConnectivityManager.NetworkCallback networkCallback;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_main);
|
||||||
|
|
||||||
|
// Before anything renders: the page is laid out inside the
|
||||||
|
// window, and on Android 15 the window is the whole screen.
|
||||||
|
applyWindowInsets();
|
||||||
|
|
||||||
|
// Initialize the native Go library
|
||||||
|
bridge = new WailsBridge(this);
|
||||||
|
bridge.initialize();
|
||||||
|
|
||||||
|
// Ask for access to the user's music before the frontend has
|
||||||
|
// anything to say about it. See ensureStorageAccess().
|
||||||
|
ensureStorageAccess();
|
||||||
|
|
||||||
|
// Set up WebView
|
||||||
|
setupWebView();
|
||||||
|
|
||||||
|
// Load the application
|
||||||
|
loadApplication();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain access to the user's music.
|
||||||
|
*
|
||||||
|
* <p>This app is a library manager: its database is keyed on file
|
||||||
|
* paths, its scanner walks a directory the user chose, and its tag
|
||||||
|
* writer rewrites files in place. MediaStore offers none of those,
|
||||||
|
* so the app holds MANAGE_EXTERNAL_STORAGE — which is granted on a
|
||||||
|
* Settings screen rather than in a dialog, and therefore cannot be
|
||||||
|
* requested with requestPermissions().
|
||||||
|
*
|
||||||
|
* <p>The screen is opened on every cold start until access exists,
|
||||||
|
* because without it the app can see nothing at all and there is no
|
||||||
|
* degraded mode worth offering. Returning from it lands in
|
||||||
|
* onResume, which re-checks and tells the frontend.
|
||||||
|
*
|
||||||
|
* <p>Below Android 11 there is no all-files concept and plain
|
||||||
|
* READ_EXTERNAL_STORAGE is both sufficient and a normal dialog.
|
||||||
|
*/
|
||||||
|
private void ensureStorageAccess() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
if (Environment.isExternalStorageManager()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The per-app screen is the one that can actually grant it.
|
||||||
|
// A few OEM builds do not implement it, so fall back to the
|
||||||
|
// global list rather than leaving the user with nothing.
|
||||||
|
try {
|
||||||
|
startActivity(new Intent(
|
||||||
|
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||||
|
Uri.parse("package:" + getPackageName())));
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "per-app all-files screen unavailable: " + e.getMessage());
|
||||||
|
try {
|
||||||
|
startActivity(new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION));
|
||||||
|
} catch (Exception e2) {
|
||||||
|
Log.w(TAG, "no all-files settings screen at all: " + e2.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 1010);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app can currently read the user's music, by the same
|
||||||
|
* test the Go side uses.
|
||||||
|
*/
|
||||||
|
private boolean hasStorageAccess() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
return Environment.isExternalStorageManager();
|
||||||
|
}
|
||||||
|
return checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
== PackageManager.PERMISSION_GRANTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the frontend whether music is readable. Emitted on resume
|
||||||
|
* rather than only at startup, because the grant happens on a
|
||||||
|
* Settings screen in another task and the way back is a resume.
|
||||||
|
*/
|
||||||
|
private void emitStorageAccess() {
|
||||||
|
if (bridge == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bridge.emitEvent("android:storageAccess",
|
||||||
|
"{\"granted\":" + (hasStorageAccess() ? "true" : "false") + "}");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private void setupWebView() {
|
||||||
|
webView = findViewById(R.id.webview);
|
||||||
|
bridge.setWebView(webView);
|
||||||
|
|
||||||
|
// Configure WebView settings
|
||||||
|
WebSettings settings = webView.getSettings();
|
||||||
|
settings.setJavaScriptEnabled(true);
|
||||||
|
settings.setDomStorageEnabled(true);
|
||||||
|
settings.setDatabaseEnabled(true);
|
||||||
|
settings.setAllowFileAccess(false);
|
||||||
|
settings.setAllowContentAccess(false);
|
||||||
|
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||||
|
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
|
||||||
|
|
||||||
|
// Enable debugging in debug builds
|
||||||
|
if (DEBUG) {
|
||||||
|
WebView.setWebContentsDebuggingEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up asset loader for serving local assets
|
||||||
|
assetLoader = new WebViewAssetLoader.Builder()
|
||||||
|
.setDomain(WAILS_HOST)
|
||||||
|
.addPathHandler("/", new WailsPathHandler(bridge))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Set up WebView client to intercept requests
|
||||||
|
webView.setWebViewClient(new WebViewClient() {
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||||
|
// Handle wails.localhost requests
|
||||||
|
if (request.getUrl().getHost() != null &&
|
||||||
|
request.getUrl().getHost().equals(WAILS_HOST)) {
|
||||||
|
|
||||||
|
// For wails API calls (runtime, capabilities, etc.) pass the
|
||||||
|
// full URL including the query string, because
|
||||||
|
// WebViewAssetLoader.PathHandler strips query params
|
||||||
|
String path = request.getUrl().getPath();
|
||||||
|
if (path != null && path.startsWith("/wails/")) {
|
||||||
|
String fullPath = path;
|
||||||
|
String query = request.getUrl().getQuery();
|
||||||
|
if (query != null && !query.isEmpty()) {
|
||||||
|
fullPath = path + "?" + query;
|
||||||
|
}
|
||||||
|
if (DEBUG) Log.d(TAG, "Wails API call: " + fullPath);
|
||||||
|
|
||||||
|
byte[] data = bridge.serveAsset(fullPath, request.getMethod(), "{}");
|
||||||
|
if (data != null && data.length > 0) {
|
||||||
|
java.io.InputStream inputStream = new java.io.ByteArrayInputStream(data);
|
||||||
|
java.util.Map<String, String> headers = new java.util.HashMap<>();
|
||||||
|
headers.put("Access-Control-Allow-Origin", "*");
|
||||||
|
headers.put("Cache-Control", "no-cache");
|
||||||
|
headers.put("Content-Type", "application/json");
|
||||||
|
|
||||||
|
return new WebResourceResponse(
|
||||||
|
"application/json",
|
||||||
|
"UTF-8",
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
headers,
|
||||||
|
inputStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Return error response if data is null
|
||||||
|
return new WebResourceResponse(
|
||||||
|
"application/json",
|
||||||
|
"UTF-8",
|
||||||
|
500,
|
||||||
|
"Internal Error",
|
||||||
|
new java.util.HashMap<>(),
|
||||||
|
new java.io.ByteArrayInputStream("{}".getBytes())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream captured photos/videos from the cache with HTTP Range
|
||||||
|
// support so <video> can seek/stream a clip of any length.
|
||||||
|
if (path != null && path.startsWith("/__capture__/")) {
|
||||||
|
return serveCaptureFile(path.substring("/__capture__/".length()), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular assets, use the asset loader
|
||||||
|
return assetLoader.shouldInterceptRequest(request.getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.shouldInterceptRequest(view, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPageFinished(WebView view, String url) {
|
||||||
|
super.onPageFinished(view, url);
|
||||||
|
if (DEBUG) Log.d(TAG, "Page loaded: " + url);
|
||||||
|
bridge.onPageFinished(url);
|
||||||
|
// Now that JS listeners are mounted, push a snapshot of the
|
||||||
|
// current battery / network / theme so the UI starts populated.
|
||||||
|
emitSystemSnapshot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add JavaScript interface for Go communication
|
||||||
|
webView.addJavascriptInterface(new WailsJSBridge(bridge, webView), "wails");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadApplication() {
|
||||||
|
String url = WAILS_SCHEME + "://" + WAILS_HOST + "/";
|
||||||
|
if (DEBUG) Log.d(TAG, "Loading URL: " + url);
|
||||||
|
webView.loadUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch the system camera to capture a photo (video=false) or a video
|
||||||
|
* (video=true). The capture is written to a FileProvider URI in the cache and
|
||||||
|
* the result is delivered to JS as a "common:capture" event.
|
||||||
|
*/
|
||||||
|
public void launchCameraCapture(boolean video) {
|
||||||
|
if (checkSelfPermission("android.permission.CAMERA") != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
pendingCaptureIsVideo = video;
|
||||||
|
requestPermissions(new String[]{"android.permission.CAMERA"}, CAMERA_PERMISSION_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
File dir = new File(getCacheDir(), "captures");
|
||||||
|
if (!dir.exists()) dir.mkdirs();
|
||||||
|
pendingCaptureFile = new File(dir, "capture_" + System.currentTimeMillis() + (video ? ".mp4" : ".jpg"));
|
||||||
|
pendingCaptureIsVideo = video;
|
||||||
|
Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", pendingCaptureFile);
|
||||||
|
Intent intent = new Intent(video ? MediaStore.ACTION_VIDEO_CAPTURE : MediaStore.ACTION_IMAGE_CAPTURE);
|
||||||
|
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri);
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||||
|
// Don't pre-check with resolveActivity(): Android 11+ package visibility
|
||||||
|
// hides other apps' intents unless declared in <queries>, so it can
|
||||||
|
// return null even when a camera app exists. Just launch and handle a miss.
|
||||||
|
startActivityForResult(intent, video ? VIDEO_CAPTURE_REQUEST : PHOTO_CAPTURE_REQUEST);
|
||||||
|
} catch (android.content.ActivityNotFoundException e) {
|
||||||
|
bridge.emitEvent("common:capture", "{\"error\":\"no camera app available\"}");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "launchCameraCapture failed", e);
|
||||||
|
bridge.emitEvent("common:capture", "{\"error\":\"capture failed\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
|
if (requestCode == CAMERA_PERMISSION_REQUEST) {
|
||||||
|
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
launchCameraCapture(pendingCaptureIsVideo);
|
||||||
|
} else {
|
||||||
|
bridge.emitEvent("common:capture", "{\"error\":\"camera permission denied\"}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onRequestPermissionsResult(requestCode, grantResults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleCaptureResult(int resultCode, @Nullable Intent data) {
|
||||||
|
File file = pendingCaptureFile;
|
||||||
|
final boolean video = pendingCaptureIsVideo;
|
||||||
|
pendingCaptureFile = null;
|
||||||
|
if (resultCode != RESULT_OK) {
|
||||||
|
bridge.emitEvent("common:capture", "{\"cancelled\":true}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Some camera apps (commonly for video) ignore EXTRA_OUTPUT and instead
|
||||||
|
// return a content URI in the result data; copy that into our cache.
|
||||||
|
if ((file == null || !file.exists() || file.length() == 0)
|
||||||
|
&& data != null && data.getData() != null) {
|
||||||
|
String copied = copyUriToCache(data.getData());
|
||||||
|
if (copied != null) file = new File(copied);
|
||||||
|
}
|
||||||
|
final File f = file;
|
||||||
|
if (f == null || !f.exists() || f.length() == 0) {
|
||||||
|
bridge.emitEvent("common:capture", "{\"cancelled\":true}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("type", video ? "video" : "photo");
|
||||||
|
o.put("path", f.getAbsolutePath());
|
||||||
|
o.put("size", f.length());
|
||||||
|
if (!video) {
|
||||||
|
String thumb = makePhotoThumbnail(f);
|
||||||
|
if (thumb != null) o.put("thumb", thumb);
|
||||||
|
}
|
||||||
|
// Stream URL works for both: <video>/<img> load it from the cache
|
||||||
|
// via shouldInterceptRequest (Range-enabled), no size limit.
|
||||||
|
o.put("streamUrl", captureStreamUrl(f));
|
||||||
|
bridge.emitEvent("common:capture", o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "handleCaptureResult failed", e);
|
||||||
|
bridge.emitEvent("common:capture", "{\"error\":\"result processing failed\"}");
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downscale a captured photo into a base64 JPEG data URL for display in the webview. */
|
||||||
|
@Nullable
|
||||||
|
private String makePhotoThumbnail(File file) {
|
||||||
|
try {
|
||||||
|
BitmapFactory.Options bounds = new BitmapFactory.Options();
|
||||||
|
bounds.inJustDecodeBounds = true;
|
||||||
|
BitmapFactory.decodeFile(file.getAbsolutePath(), bounds);
|
||||||
|
int sample = 1;
|
||||||
|
while (Math.max(bounds.outWidth, bounds.outHeight) / sample > 640) sample *= 2;
|
||||||
|
BitmapFactory.Options opts = new BitmapFactory.Options();
|
||||||
|
opts.inSampleSize = sample;
|
||||||
|
Bitmap bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), opts);
|
||||||
|
if (bmp == null) return null;
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
bmp.compress(Bitmap.CompressFormat.JPEG, 70, baos);
|
||||||
|
bmp.recycle();
|
||||||
|
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a same-origin URL the webview can stream a capture from. Served by
|
||||||
|
* serveCaptureFile (via shouldInterceptRequest); the path is relative to the
|
||||||
|
* cache dir so both camera files (captures/) and copied content URIs
|
||||||
|
* (wails-picker/) resolve.
|
||||||
|
*/
|
||||||
|
private String captureStreamUrl(File file) {
|
||||||
|
String base = getCacheDir().getAbsolutePath() + File.separator;
|
||||||
|
String abs = file.getAbsolutePath();
|
||||||
|
String rel = abs.startsWith(base) ? abs.substring(base.length()) : file.getName();
|
||||||
|
return "/__capture__/" + Uri.encode(rel, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve a captured file (under the app cache) to the webview with HTTP Range
|
||||||
|
* support, so <video> can stream and seek a clip of any length without
|
||||||
|
* inlining it as a data URL.
|
||||||
|
*/
|
||||||
|
private WebResourceResponse serveCaptureFile(String relPath, WebResourceRequest request) {
|
||||||
|
try {
|
||||||
|
File cache = getCacheDir();
|
||||||
|
File file = new File(cache, Uri.decode(relPath));
|
||||||
|
// Path-traversal guard: only ever serve files under the cache dir.
|
||||||
|
if (!file.getCanonicalPath().startsWith(cache.getCanonicalPath() + File.separator)
|
||||||
|
|| !file.exists() || !file.isFile()) {
|
||||||
|
return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found",
|
||||||
|
new java.util.HashMap<>(), new java.io.ByteArrayInputStream(new byte[0]));
|
||||||
|
}
|
||||||
|
String name = file.getName().toLowerCase();
|
||||||
|
String mime = name.endsWith(".mp4") ? "video/mp4"
|
||||||
|
: name.endsWith(".mov") ? "video/quicktime"
|
||||||
|
: name.endsWith(".jpg") || name.endsWith(".jpeg") ? "image/jpeg"
|
||||||
|
: name.endsWith(".png") ? "image/png" : "application/octet-stream";
|
||||||
|
long length = file.length();
|
||||||
|
java.util.Map<String, String> reqHeaders = request.getRequestHeaders();
|
||||||
|
String range = reqHeaders != null ? reqHeaders.get("Range") : null;
|
||||||
|
if (range == null && reqHeaders != null) range = reqHeaders.get("range");
|
||||||
|
|
||||||
|
java.util.Map<String, String> headers = new java.util.HashMap<>();
|
||||||
|
headers.put("Accept-Ranges", "bytes");
|
||||||
|
headers.put("Cache-Control", "no-store");
|
||||||
|
|
||||||
|
if (range != null && range.startsWith("bytes=")) {
|
||||||
|
long start = 0, end = length - 1;
|
||||||
|
String spec = range.substring(6).trim();
|
||||||
|
int dash = spec.indexOf('-');
|
||||||
|
if (dash >= 0) {
|
||||||
|
try {
|
||||||
|
if (dash > 0) start = Long.parseLong(spec.substring(0, dash).trim());
|
||||||
|
String e = spec.substring(dash + 1).trim();
|
||||||
|
if (!e.isEmpty()) end = Long.parseLong(e);
|
||||||
|
} catch (NumberFormatException ignored) { }
|
||||||
|
}
|
||||||
|
if (start < 0) start = 0;
|
||||||
|
if (end >= length) end = length - 1;
|
||||||
|
if (start > end) { start = 0; end = length - 1; }
|
||||||
|
long count = end - start + 1;
|
||||||
|
java.io.InputStream in = new java.io.FileInputStream(file);
|
||||||
|
long toSkip = start;
|
||||||
|
while (toSkip > 0) {
|
||||||
|
long s = in.skip(toSkip);
|
||||||
|
if (s <= 0) break;
|
||||||
|
toSkip -= s;
|
||||||
|
}
|
||||||
|
headers.put("Content-Range", "bytes " + start + "-" + end + "/" + length);
|
||||||
|
headers.put("Content-Length", String.valueOf(count));
|
||||||
|
return new WebResourceResponse(mime, null, 206, "Partial Content",
|
||||||
|
headers, new LimitedInputStream(in, count));
|
||||||
|
}
|
||||||
|
headers.put("Content-Length", String.valueOf(length));
|
||||||
|
return new WebResourceResponse(mime, null, 200, "OK", headers,
|
||||||
|
new java.io.FileInputStream(file));
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "serveCaptureFile failed", e);
|
||||||
|
return new WebResourceResponse("text/plain", "UTF-8", 500, "Error",
|
||||||
|
new java.util.HashMap<>(), new java.io.ByteArrayInputStream(new byte[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps a stream to yield at most a fixed number of bytes (for Range responses). */
|
||||||
|
private static final class LimitedInputStream extends java.io.FilterInputStream {
|
||||||
|
private long remaining;
|
||||||
|
LimitedInputStream(java.io.InputStream in, long limit) {
|
||||||
|
super(in);
|
||||||
|
this.remaining = limit;
|
||||||
|
}
|
||||||
|
@Override public int read() throws java.io.IOException {
|
||||||
|
if (remaining <= 0) return -1;
|
||||||
|
int b = super.read();
|
||||||
|
if (b >= 0) remaining--;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
@Override public int read(byte[] b, int off, int len) throws java.io.IOException {
|
||||||
|
if (remaining <= 0) return -1;
|
||||||
|
int n = super.read(b, off, (int) Math.min(len, remaining));
|
||||||
|
if (n > 0) remaining -= n;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch the system document picker. Results are copied into the app's
|
||||||
|
* cache directory so Go receives real filesystem paths. Called by
|
||||||
|
* WailsBridge on the main thread.
|
||||||
|
*/
|
||||||
|
public void launchFilePicker(int callbackID, boolean multiple) {
|
||||||
|
synchronized (this) {
|
||||||
|
if (pendingFilePickerCallbackID != -1) {
|
||||||
|
// Only one picker can be in flight
|
||||||
|
bridge.filePickerDone(callbackID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingFilePickerCallbackID = callbackID;
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||||
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
|
intent.setType("*/*");
|
||||||
|
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple);
|
||||||
|
try {
|
||||||
|
startActivityForResult(intent, FILE_PICKER_REQUEST);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Failed to launch file picker", e);
|
||||||
|
pendingFilePickerCallbackID = -1;
|
||||||
|
bridge.filePickerDone(callbackID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
|
if (requestCode == PHOTO_CAPTURE_REQUEST || requestCode == VIDEO_CAPTURE_REQUEST) {
|
||||||
|
handleCaptureResult(resultCode, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestCode != FILE_PICKER_REQUEST) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int callbackID = pendingFilePickerCallbackID;
|
||||||
|
pendingFilePickerCallbackID = -1;
|
||||||
|
if (callbackID == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Uri> uris = new ArrayList<>();
|
||||||
|
if (resultCode == RESULT_OK && data != null) {
|
||||||
|
if (data.getClipData() != null) {
|
||||||
|
for (int i = 0; i < data.getClipData().getItemCount(); i++) {
|
||||||
|
uris.add(data.getClipData().getItemAt(i).getUri());
|
||||||
|
}
|
||||||
|
} else if (data.getData() != null) {
|
||||||
|
uris.add(data.getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the documents off the main thread, then notify Go
|
||||||
|
new Thread(() -> {
|
||||||
|
for (Uri uri : uris) {
|
||||||
|
String path = copyUriToCache(uri);
|
||||||
|
if (path != null) {
|
||||||
|
bridge.filePickerResult(callbackID, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bridge.filePickerDone(callbackID);
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a content URI into the app cache and return its filesystem path.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private String copyUriToCache(Uri uri) {
|
||||||
|
String name = "document";
|
||||||
|
try (Cursor cursor = getContentResolver().query(uri, null, null, null, null)) {
|
||||||
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||||
|
if (idx >= 0 && cursor.getString(idx) != null) {
|
||||||
|
name = new File(cursor.getString(idx)).getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
File dir = new File(getCacheDir(), "wails-picker/" + System.nanoTime());
|
||||||
|
if (!dir.mkdirs()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
File out = new File(dir, name);
|
||||||
|
try (InputStream in = getContentResolver().openInputStream(uri);
|
||||||
|
OutputStream os = new FileOutputStream(out)) {
|
||||||
|
if (in == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] buf = new byte[64 * 1024];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) > 0) {
|
||||||
|
os.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.getAbsolutePath();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Failed to copy picked document", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute JavaScript in the WebView from the Go side
|
||||||
|
*/
|
||||||
|
public void executeJavaScript(final String js) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
if (webView != null) {
|
||||||
|
webView.evaluateJavascript(js, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- System events ---------------------------------------------------
|
||||||
|
// Battery/power, screen lock and network connectivity are surfaced to JS as
|
||||||
|
// "system:*" events. The OS broadcasts used here (ACTION_BATTERY_CHANGED,
|
||||||
|
// SCREEN_OFF, USER_PRESENT, POWER_SAVE_MODE_CHANGED) are protected system
|
||||||
|
// broadcasts, so dynamic registration needs no RECEIVER_* export flag.
|
||||||
|
|
||||||
|
private void registerSystemEventReceivers() {
|
||||||
|
// Battery + charging state (sticky broadcast: the current value is
|
||||||
|
// delivered to the receiver immediately on registration).
|
||||||
|
batteryReceiver = new BroadcastReceiver() {
|
||||||
|
@Override public void onReceive(Context context, Intent intent) {
|
||||||
|
emitBattery(intent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
registerReceiver(batteryReceiver, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
|
||||||
|
|
||||||
|
// Low-power (battery saver) mode toggles → re-emit battery with the flag.
|
||||||
|
powerSaveReceiver = new BroadcastReceiver() {
|
||||||
|
@Override public void onReceive(Context context, Intent intent) {
|
||||||
|
emitBattery(registerSticky(Intent.ACTION_BATTERY_CHANGED));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
registerReceiver(powerSaveReceiver,
|
||||||
|
new IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED));
|
||||||
|
|
||||||
|
// Screen lock / unlock. SCREEN_OFF ≈ locked; USER_PRESENT = unlocked.
|
||||||
|
screenReceiver = new BroadcastReceiver() {
|
||||||
|
@Override public void onReceive(Context context, Intent intent) {
|
||||||
|
String action = intent.getAction();
|
||||||
|
if (Intent.ACTION_SCREEN_OFF.equals(action)) {
|
||||||
|
emitLock(true);
|
||||||
|
} else if (Intent.ACTION_USER_PRESENT.equals(action)) {
|
||||||
|
emitLock(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
IntentFilter screenFilter = new IntentFilter();
|
||||||
|
screenFilter.addAction(Intent.ACTION_SCREEN_OFF);
|
||||||
|
screenFilter.addAction(Intent.ACTION_USER_PRESENT);
|
||||||
|
registerReceiver(screenReceiver, screenFilter);
|
||||||
|
|
||||||
|
// Network connectivity / transport type / cellular signal strength.
|
||||||
|
connectivityManager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||||
|
if (connectivityManager != null) {
|
||||||
|
networkCallback = new ConnectivityManager.NetworkCallback() {
|
||||||
|
@Override public void onAvailable(Network network) { emitNetwork(network); }
|
||||||
|
@Override public void onLost(Network network) { emitNetworkDisconnected(); }
|
||||||
|
@Override public void onCapabilitiesChanged(Network network, NetworkCapabilities caps) {
|
||||||
|
emitNetwork(network);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
connectivityManager.registerDefaultNetworkCallback(networkCallback);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "registerDefaultNetworkCallback failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void unregisterSystemEventReceivers() {
|
||||||
|
safeUnregister(batteryReceiver);
|
||||||
|
batteryReceiver = null;
|
||||||
|
safeUnregister(powerSaveReceiver);
|
||||||
|
powerSaveReceiver = null;
|
||||||
|
safeUnregister(screenReceiver);
|
||||||
|
screenReceiver = null;
|
||||||
|
if (connectivityManager != null && networkCallback != null) {
|
||||||
|
try {
|
||||||
|
connectivityManager.unregisterNetworkCallback(networkCallback);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
networkCallback = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void safeUnregister(BroadcastReceiver r) {
|
||||||
|
if (r != null) {
|
||||||
|
try {
|
||||||
|
unregisterReceiver(r);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the current sticky value for an action without a standing receiver. */
|
||||||
|
@Nullable
|
||||||
|
private Intent registerSticky(String action) {
|
||||||
|
return registerReceiver(null, new IntentFilter(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push current battery / network / theme so a freshly-loaded UI is populated. */
|
||||||
|
private void emitSystemSnapshot() {
|
||||||
|
emitBattery(registerSticky(Intent.ACTION_BATTERY_CHANGED));
|
||||||
|
if (connectivityManager != null) {
|
||||||
|
Network active = connectivityManager.getActiveNetwork();
|
||||||
|
if (active != null) {
|
||||||
|
emitNetwork(active);
|
||||||
|
} else {
|
||||||
|
emitNetworkDisconnected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitBattery(@Nullable Intent batteryStatus) {
|
||||||
|
try {
|
||||||
|
float level = -1f;
|
||||||
|
String state = "unknown";
|
||||||
|
if (batteryStatus != null) {
|
||||||
|
int lvl = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
|
||||||
|
int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
|
||||||
|
if (lvl >= 0 && scale > 0) {
|
||||||
|
level = lvl / (float) scale;
|
||||||
|
}
|
||||||
|
switch (batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)) {
|
||||||
|
case BatteryManager.BATTERY_STATUS_CHARGING: state = "charging"; break;
|
||||||
|
case BatteryManager.BATTERY_STATUS_FULL: state = "full"; break;
|
||||||
|
case BatteryManager.BATTERY_STATUS_DISCHARGING:
|
||||||
|
case BatteryManager.BATTERY_STATUS_NOT_CHARGING: state = "unplugged"; break;
|
||||||
|
default: state = "unknown"; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean lowPower = false;
|
||||||
|
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||||
|
if (pm != null) {
|
||||||
|
lowPower = pm.isPowerSaveMode();
|
||||||
|
}
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("level", (double) level);
|
||||||
|
o.put("state", state);
|
||||||
|
o.put("lowPowerMode", lowPower);
|
||||||
|
if (bridge != null) bridge.emitSystemEvent("android:BatteryChanged", o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "emitBattery failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitNetwork(@Nullable Network network) {
|
||||||
|
try {
|
||||||
|
boolean connected = false;
|
||||||
|
String type = "none";
|
||||||
|
boolean metered = false;
|
||||||
|
Integer signal = null;
|
||||||
|
if (connectivityManager != null && network != null) {
|
||||||
|
NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(network);
|
||||||
|
if (caps != null) {
|
||||||
|
connected = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||||
|
type = "wifi";
|
||||||
|
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||||
|
type = "cellular";
|
||||||
|
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||||
|
type = "wired";
|
||||||
|
} else {
|
||||||
|
type = "other";
|
||||||
|
}
|
||||||
|
metered = !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
int s = caps.getSignalStrength();
|
||||||
|
if (s != Integer.MIN_VALUE) {
|
||||||
|
signal = s; // dBm; closer to 0 is a stronger signal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("connected", connected);
|
||||||
|
o.put("type", type);
|
||||||
|
o.put("metered", metered);
|
||||||
|
if (signal != null) {
|
||||||
|
o.put("signal", (int) signal);
|
||||||
|
}
|
||||||
|
if (bridge != null) bridge.emitSystemEvent("android:NetworkChanged", o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "emitNetwork failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitNetworkDisconnected() {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("connected", false);
|
||||||
|
o.put("type", "none");
|
||||||
|
o.put("metered", false);
|
||||||
|
if (bridge != null) bridge.emitSystemEvent("android:NetworkChanged", o.toString());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitLock(boolean locked) {
|
||||||
|
// Lock/unlock are signals (no payload); name carries the state.
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.emitSystemEvent(locked ? "android:ScreenLocked" : "android:ScreenUnlocked", "{}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitTheme() {
|
||||||
|
try {
|
||||||
|
int mode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
// "isDarkMode" matches the context key the desktop platforms use.
|
||||||
|
o.put("isDarkMode", mode == Configuration.UI_MODE_NIGHT_YES);
|
||||||
|
if (bridge != null) bridge.emitSystemEvent("android:ThemeChanged", o.toString());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConfigurationChanged(Configuration newConfig) {
|
||||||
|
super.onConfigurationChanged(newConfig);
|
||||||
|
// Fires for light/dark switches because the manifest lists uiMode in
|
||||||
|
// android:configChanges (otherwise the activity would be recreated).
|
||||||
|
emitTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStart() {
|
||||||
|
super.onStart();
|
||||||
|
// Battery: only monitor system events while the app is visible.
|
||||||
|
if (!systemReceiversRegistered) {
|
||||||
|
registerSystemEventReceivers();
|
||||||
|
systemReceiversRegistered = true;
|
||||||
|
}
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onResume();
|
||||||
|
}
|
||||||
|
// The all-files grant happens on a Settings screen in another
|
||||||
|
// task, so a resume is how the app finds out it was given.
|
||||||
|
emitStorageAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onPause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStop() {
|
||||||
|
super.onStop();
|
||||||
|
if (systemReceiversRegistered) {
|
||||||
|
unregisterSystemEventReceivers();
|
||||||
|
systemReceiversRegistered = false;
|
||||||
|
}
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLowMemory() {
|
||||||
|
super.onLowMemory();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onLowMemory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
super.onDestroy();
|
||||||
|
unregisterSystemEventReceivers();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.shutdown();
|
||||||
|
}
|
||||||
|
if (webView != null) {
|
||||||
|
webView.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the web content inside the safe area.
|
||||||
|
*
|
||||||
|
* <p>targetSdk 35 is Android 15, which lays every app out
|
||||||
|
* edge-to-edge and ignores the {@code statusBarColor} and
|
||||||
|
* {@code navigationBarColor} this app's theme still sets. The
|
||||||
|
* WebView is {@code match_parent}, so the page's bottom band -- the
|
||||||
|
* transport and, on a phone, the tab bar -- was drawn underneath the
|
||||||
|
* gesture bar and reported from a device as "I can't see the
|
||||||
|
* playback controls, they seem to be off screen".
|
||||||
|
*
|
||||||
|
* <p>No web-tier test can see this: a browser viewport has no system
|
||||||
|
* bars, so the phone specs at 390x844 render a shell that fits
|
||||||
|
* while the device does not.
|
||||||
|
*
|
||||||
|
* <p>The insets are applied as padding and the window insets are
|
||||||
|
* returned rather than consumed, so the WebView is laid out inside
|
||||||
|
* them. {@code ime()} is in the mask because the same reasoning
|
||||||
|
* covers the keyboard: a focused search box that the keyboard
|
||||||
|
* covers is the same bug one surface over.
|
||||||
|
*/
|
||||||
|
private void applyWindowInsets() {
|
||||||
|
final View container = findViewById(R.id.main_container);
|
||||||
|
|
||||||
|
if (container == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(container, (view, windowInsets) -> {
|
||||||
|
Insets insets = windowInsets.getInsets(
|
||||||
|
WindowInsetsCompat.Type.systemBars()
|
||||||
|
| WindowInsetsCompat.Type.displayCutout()
|
||||||
|
| WindowInsetsCompat.Type.ime());
|
||||||
|
|
||||||
|
view.setPadding(insets.left, insets.top, insets.right, insets.bottom);
|
||||||
|
|
||||||
|
return windowInsets;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The padded band shows the window background, which is dark
|
||||||
|
// (this app's own default ramp is black), so the system's icons
|
||||||
|
// have to be the light set or they vanish into it. The theme is
|
||||||
|
// DayNight and would otherwise ask for dark icons in light mode.
|
||||||
|
WindowInsetsControllerCompat controller =
|
||||||
|
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
|
||||||
|
|
||||||
|
controller.setAppearanceLightStatusBars(false);
|
||||||
|
controller.setAppearanceLightNavigationBars(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBackPressed() {
|
||||||
|
// The frontend records every navigation as a history entry, so
|
||||||
|
// this is the app's own back stack: `canGoBack()` is false only
|
||||||
|
// at the launch entry, which is where back should leave.
|
||||||
|
if (webView != null && webView.canGoBack()) {
|
||||||
|
webView.goBack();
|
||||||
|
} else {
|
||||||
|
super.onBackPressed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.app.Notification;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
|
import android.content.pm.ServiceInfo;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.media.AudioAttributes;
|
||||||
|
import android.media.AudioFocusRequest;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.media.MediaMetadata;
|
||||||
|
import android.media.session.MediaSession;
|
||||||
|
import android.media.session.PlaybackState;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The foreground service that keeps playback alive with the screen off, and
|
||||||
|
* the app's whole media-control surface: a {@link MediaSession} for the lock
|
||||||
|
* screen and headset buttons, a transport notification, and audio focus.
|
||||||
|
*
|
||||||
|
* <p>The scaffold shipped this as a generic "keep the process alive" service
|
||||||
|
* typed {@code dataSync}. YellowJacket's reason for staying alive in the
|
||||||
|
* background is that a song is playing, so it is {@code mediaPlayback} — the
|
||||||
|
* manifest and {@code startForeground} must agree on that or the call throws.
|
||||||
|
*
|
||||||
|
* <p>It is driven entirely from Go. {@code backend/mediacontrols/android.go}
|
||||||
|
* pushes a JSON payload through
|
||||||
|
* {@link WailsBridge#startForegroundService(String)}, and every command the
|
||||||
|
* user gives here — a notification button, the lock screen, a headset, or the
|
||||||
|
* OS taking audio focus away — goes back the other way as a
|
||||||
|
* {@code yj:media:command} event. Nothing about playback is decided here: this
|
||||||
|
* class renders state and reports intent.
|
||||||
|
*/
|
||||||
|
public class WailsForegroundService extends android.app.Service {
|
||||||
|
public static final String ACTION_START = "com.wails.app.FGS_START";
|
||||||
|
|
||||||
|
// Transport actions, delivered to ourselves by the notification's
|
||||||
|
// PendingIntents. getService rather than a broadcast: a receiver would
|
||||||
|
// have to be exported or registered, and this service is already the
|
||||||
|
// thing that has to be running for any of them to be meaningful.
|
||||||
|
private static final String ACTION_PLAY = "com.wails.app.MEDIA_PLAY";
|
||||||
|
private static final String ACTION_PAUSE = "com.wails.app.MEDIA_PAUSE";
|
||||||
|
private static final String ACTION_NEXT = "com.wails.app.MEDIA_NEXT";
|
||||||
|
private static final String ACTION_PREVIOUS = "com.wails.app.MEDIA_PREVIOUS";
|
||||||
|
|
||||||
|
private static final String TAG = "WailsMedia";
|
||||||
|
private static final String CHANNEL_ID = "yellowjacket_playback";
|
||||||
|
private static final int NOTIFICATION_ID = 0x57A1; // "WAI"
|
||||||
|
private static final String COMMAND_EVENT = "yj:media:command";
|
||||||
|
|
||||||
|
/** Cover art is decoded down to this, which is larger than any lock screen. */
|
||||||
|
private static final int ART_MAX_PX = 512;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an instance is alive. {@link WailsBridge} reads it to decide
|
||||||
|
* between startForegroundService and startService: from Android 12 an app
|
||||||
|
* in the background may not <em>start</em> a foreground service, but it
|
||||||
|
* may go on delivering intents to one it already has — and every update
|
||||||
|
* after the first (a track change with the screen off, most of them) is
|
||||||
|
* exactly that case.
|
||||||
|
*/
|
||||||
|
static volatile boolean running = false;
|
||||||
|
|
||||||
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
private final ExecutorService artExecutor = Executors.newSingleThreadExecutor();
|
||||||
|
|
||||||
|
private MediaSession session;
|
||||||
|
private AudioManager audioManager;
|
||||||
|
private AudioFocusRequest focusRequest; // API 26+ only.
|
||||||
|
private AudioManager.OnAudioFocusChangeListener focusListener;
|
||||||
|
|
||||||
|
private String title = "";
|
||||||
|
private String artist = "";
|
||||||
|
private String album = "";
|
||||||
|
private String artPath = "";
|
||||||
|
private long durationMs = 0;
|
||||||
|
private long positionMs = 0;
|
||||||
|
private boolean playing = false;
|
||||||
|
|
||||||
|
private Bitmap art;
|
||||||
|
|
||||||
|
private boolean hasFocus = false;
|
||||||
|
/**
|
||||||
|
* Whether *we* paused because focus went away. Only then does regaining it
|
||||||
|
* resume: a user who paused during a phone call did not ask us to start
|
||||||
|
* again when it ended.
|
||||||
|
*/
|
||||||
|
private boolean pausedByFocusLoss = false;
|
||||||
|
|
||||||
|
private boolean noisyRegistered = false;
|
||||||
|
|
||||||
|
/** Headphones pulled out. Anything else and the room hears the album. */
|
||||||
|
private final BroadcastReceiver noisyReceiver = new BroadcastReceiver() {
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
|
||||||
|
emitCommand("pause");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
running = true;
|
||||||
|
audioManager = (AudioManager) getSystemService(AUDIO_SERVICE);
|
||||||
|
createChannel();
|
||||||
|
createSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
String action = intent == null ? null : intent.getAction();
|
||||||
|
|
||||||
|
if (ACTION_PLAY.equals(action)) {
|
||||||
|
emitCommand("play");
|
||||||
|
} else if (ACTION_PAUSE.equals(action)) {
|
||||||
|
emitCommand("pause");
|
||||||
|
} else if (ACTION_NEXT.equals(action)) {
|
||||||
|
emitCommand("next");
|
||||||
|
} else if (ACTION_PREVIOUS.equals(action)) {
|
||||||
|
emitCommand("previous");
|
||||||
|
} else if (intent != null) {
|
||||||
|
applyPayload(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unconditionally, on every path: a service started with
|
||||||
|
// startForegroundService that returns from onStartCommand without
|
||||||
|
// calling startForeground is killed with a
|
||||||
|
// ForegroundServiceDidNotStartInTimeException.
|
||||||
|
goForeground();
|
||||||
|
|
||||||
|
return START_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the state Go pushed. "payload" is the whole JSON document; the
|
||||||
|
* title/text extras are the scaffold's original contract and are kept as a
|
||||||
|
* fallback so a non-media caller still gets a sensible notification.
|
||||||
|
*/
|
||||||
|
private void applyPayload(Intent intent) {
|
||||||
|
String payload = intent.getStringExtra("payload");
|
||||||
|
if (payload == null || payload.isEmpty()) {
|
||||||
|
if (intent.getStringExtra("title") != null) {
|
||||||
|
title = intent.getStringExtra("title");
|
||||||
|
}
|
||||||
|
if (intent.getStringExtra("text") != null) {
|
||||||
|
artist = intent.getStringExtra("text");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject(payload);
|
||||||
|
title = o.optString("title", "");
|
||||||
|
artist = o.optString("artist", "");
|
||||||
|
album = o.optString("album", "");
|
||||||
|
durationMs = o.optLong("durationSec", 0) * 1000L;
|
||||||
|
positionMs = o.optLong("positionSec", 0) * 1000L;
|
||||||
|
playing = "playing".equals(o.optString("state", "paused"));
|
||||||
|
|
||||||
|
String path = o.optString("artPath", "");
|
||||||
|
if (!path.equals(artPath)) {
|
||||||
|
artPath = path;
|
||||||
|
loadArt(path);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "bad media payload", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
requestFocus();
|
||||||
|
registerNoisy();
|
||||||
|
} else {
|
||||||
|
unregisterNoisy();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MediaSession ------------------------------------------------------
|
||||||
|
|
||||||
|
private void createSession() {
|
||||||
|
session = new MediaSession(this, "YellowJacket");
|
||||||
|
session.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS
|
||||||
|
| MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS);
|
||||||
|
session.setCallback(new MediaSession.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onPlay() {
|
||||||
|
emitCommand("play");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPause() {
|
||||||
|
emitCommand("pause");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStop() {
|
||||||
|
emitCommand("stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSkipToNext() {
|
||||||
|
emitCommand("next");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSkipToPrevious() {
|
||||||
|
emitCommand("previous");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSeekTo(long pos) {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("command", "seek");
|
||||||
|
o.put("positionSec", pos / 1000L);
|
||||||
|
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "seek command failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
session.setActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateSession() {
|
||||||
|
MediaMetadata.Builder meta = new MediaMetadata.Builder()
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_TITLE, title)
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_ARTIST, artist)
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_ALBUM, album)
|
||||||
|
.putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs);
|
||||||
|
if (art != null) {
|
||||||
|
meta.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, art);
|
||||||
|
}
|
||||||
|
session.setMetadata(meta.build());
|
||||||
|
|
||||||
|
// The position is an anchor, not a clock: the state carries the
|
||||||
|
// playback speed and the OS interpolates from here, which is why the
|
||||||
|
// Go side only pushes on a real state change or a seek.
|
||||||
|
PlaybackState state = new PlaybackState.Builder()
|
||||||
|
.setActions(PlaybackState.ACTION_PLAY
|
||||||
|
| PlaybackState.ACTION_PAUSE
|
||||||
|
| PlaybackState.ACTION_PLAY_PAUSE
|
||||||
|
| PlaybackState.ACTION_STOP
|
||||||
|
| PlaybackState.ACTION_SKIP_TO_NEXT
|
||||||
|
| PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||||
|
| PlaybackState.ACTION_SEEK_TO)
|
||||||
|
.setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED,
|
||||||
|
positionMs, playing ? 1.0f : 0.0f)
|
||||||
|
.build();
|
||||||
|
session.setPlaybackState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Notification ------------------------------------------------------
|
||||||
|
|
||||||
|
private void createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||||
|
// LOW: a transport notification is a control surface, not news, and
|
||||||
|
// IMPORTANCE_DEFAULT would make a sound on every track change.
|
||||||
|
NotificationChannel ch = new NotificationChannel(
|
||||||
|
CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW);
|
||||||
|
ch.setShowBadge(false);
|
||||||
|
nm.createNotificationChannel(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void goForeground() {
|
||||||
|
Notification n = buildNotification();
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
private Notification buildNotification() {
|
||||||
|
Notification.Builder b = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||||
|
? new Notification.Builder(this, CHANNEL_ID)
|
||||||
|
: new Notification.Builder(this);
|
||||||
|
|
||||||
|
b.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentTitle(title.isEmpty() ? getString(R.string.app_name) : title)
|
||||||
|
.setContentText(artist)
|
||||||
|
.setSubText(album)
|
||||||
|
.setOngoing(playing)
|
||||||
|
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||||
|
.setContentIntent(launchIntent());
|
||||||
|
|
||||||
|
if (art != null) {
|
||||||
|
b.setLargeIcon(art);
|
||||||
|
}
|
||||||
|
|
||||||
|
b.addAction(new Notification.Action.Builder(
|
||||||
|
android.R.drawable.ic_media_previous, "Previous",
|
||||||
|
transportIntent(ACTION_PREVIOUS, 1)).build());
|
||||||
|
b.addAction(playing
|
||||||
|
? new Notification.Action.Builder(android.R.drawable.ic_media_pause, "Pause",
|
||||||
|
transportIntent(ACTION_PAUSE, 2)).build()
|
||||||
|
: new Notification.Action.Builder(android.R.drawable.ic_media_play, "Play",
|
||||||
|
transportIntent(ACTION_PLAY, 3)).build());
|
||||||
|
b.addAction(new Notification.Action.Builder(
|
||||||
|
android.R.drawable.ic_media_next, "Next",
|
||||||
|
transportIntent(ACTION_NEXT, 4)).build());
|
||||||
|
|
||||||
|
Notification.MediaStyle style = new Notification.MediaStyle()
|
||||||
|
.setShowActionsInCompactView(0, 1, 2);
|
||||||
|
if (session != null) {
|
||||||
|
style.setMediaSession(session.getSessionToken());
|
||||||
|
}
|
||||||
|
b.setStyle(style);
|
||||||
|
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingIntent transportIntent(String action, int requestCode) {
|
||||||
|
Intent i = new Intent(this, WailsForegroundService.class).setAction(action);
|
||||||
|
return PendingIntent.getService(this, requestCode, i, pendingIntentFlags());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingIntent launchIntent() {
|
||||||
|
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||||
|
if (launch == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(this, 0, launch, pendingIntentFlags());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int pendingIntentFlags() {
|
||||||
|
// Mandatory from S, unavailable before M.
|
||||||
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||||
|
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
: PendingIntent.FLAG_UPDATE_CURRENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cover art ---------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the cover off the main thread and redraw when it lands. A track
|
||||||
|
* change must not wait on a JPEG, and the notification is correct without
|
||||||
|
* one — it simply has no image until this returns.
|
||||||
|
*/
|
||||||
|
private void loadArt(final String path) {
|
||||||
|
art = null;
|
||||||
|
if (path == null || path.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
artExecutor.execute(() -> {
|
||||||
|
Bitmap decoded = decodeScaled(path);
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
// The track may have changed while we decoded.
|
||||||
|
if (!path.equals(artPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
art = decoded;
|
||||||
|
updateSession();
|
||||||
|
goForeground();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bitmap decodeScaled(String path) {
|
||||||
|
try {
|
||||||
|
BitmapFactory.Options bounds = new BitmapFactory.Options();
|
||||||
|
bounds.inJustDecodeBounds = true;
|
||||||
|
BitmapFactory.decodeFile(path, bounds);
|
||||||
|
|
||||||
|
int longest = Math.max(bounds.outWidth, bounds.outHeight);
|
||||||
|
int sample = 1;
|
||||||
|
while (longest / sample > ART_MAX_PX) {
|
||||||
|
sample *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
BitmapFactory.Options opts = new BitmapFactory.Options();
|
||||||
|
opts.inSampleSize = sample;
|
||||||
|
return BitmapFactory.decodeFile(path, opts);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
// OutOfMemoryError included: a missing cover is not a crash.
|
||||||
|
Log.w(TAG, "cover art decode failed: " + path, t);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audio focus -------------------------------------------------------
|
||||||
|
|
||||||
|
private void requestFocus() {
|
||||||
|
if (hasFocus || audioManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusListener == null) {
|
||||||
|
focusListener = this::onFocusChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
int result;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
AudioAttributes attrs = new AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.build();
|
||||||
|
// No setWillPauseWhenDucked: from Oreo the framework ducks us
|
||||||
|
// itself and reports no CAN_DUCK loss, so the Go-side duck below
|
||||||
|
// is a pre-Oreo path. Asking to be told instead would mean
|
||||||
|
// pausing for every notification tone.
|
||||||
|
focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
.setAudioAttributes(attrs)
|
||||||
|
.setOnAudioFocusChangeListener(focusListener, mainHandler)
|
||||||
|
.build();
|
||||||
|
result = audioManager.requestAudioFocus(focusRequest);
|
||||||
|
} else {
|
||||||
|
result = requestFocusLegacy();
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
private int requestFocusLegacy() {
|
||||||
|
return audioManager.requestAudioFocus(focusListener,
|
||||||
|
AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
private void abandonFocus() {
|
||||||
|
if (!hasFocus || audioManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) {
|
||||||
|
audioManager.abandonAudioFocusRequest(focusRequest);
|
||||||
|
} else {
|
||||||
|
audioManager.abandonAudioFocus(focusListener);
|
||||||
|
}
|
||||||
|
hasFocus = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onFocusChange(int change) {
|
||||||
|
switch (change) {
|
||||||
|
case AudioManager.AUDIOFOCUS_LOSS:
|
||||||
|
// Someone else owns the output now, for good.
|
||||||
|
hasFocus = false;
|
||||||
|
pausedByFocusLoss = false;
|
||||||
|
emitCommand("pause");
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
|
||||||
|
// A phone call. Remember that the pause was ours to undo.
|
||||||
|
pausedByFocusLoss = playing;
|
||||||
|
emitCommand("pause");
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
|
||||||
|
emitDuck(true);
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_GAIN:
|
||||||
|
hasFocus = true;
|
||||||
|
emitDuck(false);
|
||||||
|
if (pausedByFocusLoss) {
|
||||||
|
pausedByFocusLoss = false;
|
||||||
|
emitCommand("play");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Noisy (headphones) ------------------------------------------------
|
||||||
|
|
||||||
|
private void registerNoisy() {
|
||||||
|
if (noisyRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registerReceiver(noisyReceiver,
|
||||||
|
new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
|
||||||
|
noisyRegistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void unregisterNoisy() {
|
||||||
|
if (!noisyRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
unregisterReceiver(noisyReceiver);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// Already gone; nothing to undo.
|
||||||
|
}
|
||||||
|
noisyRegistered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Talking to Go -----------------------------------------------------
|
||||||
|
|
||||||
|
private void emitCommand(String command) {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("command", command);
|
||||||
|
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "command emit failed: " + command, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitDuck(boolean on) {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("command", "duck");
|
||||||
|
o.put("on", on);
|
||||||
|
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "duck emit failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
running = false;
|
||||||
|
unregisterNoisy();
|
||||||
|
abandonFocus();
|
||||||
|
if (session != null) {
|
||||||
|
session.setActive(false);
|
||||||
|
session.release();
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
artExecutor.shutdownNow();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import android.webkit.JavascriptInterface;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import com.wails.app.BuildConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WailsJSBridge provides the JavaScript interface that allows the web frontend
|
||||||
|
* to communicate with the Go backend. This is exposed to JavaScript as the
|
||||||
|
* `window.wails` object.
|
||||||
|
*
|
||||||
|
* Similar to iOS's WKScriptMessageHandler but using Android's addJavascriptInterface.
|
||||||
|
*/
|
||||||
|
public class WailsJSBridge {
|
||||||
|
private static final String TAG = "WailsJSBridge";
|
||||||
|
private static final boolean DEBUG = BuildConfig.DEBUG;
|
||||||
|
// Pooled threads avoid unbounded thread creation under high call volume.
|
||||||
|
private static final ExecutorService executor = Executors.newCachedThreadPool();
|
||||||
|
|
||||||
|
private final WailsBridge bridge;
|
||||||
|
private final WebView webView;
|
||||||
|
|
||||||
|
public WailsJSBridge(WailsBridge bridge, WebView webView) {
|
||||||
|
this.bridge = bridge;
|
||||||
|
this.webView = webView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to Go and return the response synchronously.
|
||||||
|
* Called from JavaScript: wails.invoke(message)
|
||||||
|
*
|
||||||
|
* @param message The message to send (JSON string)
|
||||||
|
* @return The response from Go (JSON string)
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public String invoke(String message) {
|
||||||
|
if (DEBUG) Log.d(TAG, "Invoke called: " + message);
|
||||||
|
return bridge.handleMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to Go asynchronously.
|
||||||
|
* The response will be sent back via a callback.
|
||||||
|
* Called from JavaScript: wails.invokeAsync(callbackId, message)
|
||||||
|
*
|
||||||
|
* @param callbackId The callback ID to use for the response
|
||||||
|
* @param message The message to send (JSON string)
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public void invokeAsync(final String callbackId, final String payload) {
|
||||||
|
if (DEBUG) Log.d(TAG, "InvokeAsync called: " + payload);
|
||||||
|
|
||||||
|
// Handle off the JS thread so we don't block the WebView.
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
String response = bridge.handleRuntimeCall(payload);
|
||||||
|
sendCallback(callbackId, response, null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error in async invoke", e);
|
||||||
|
sendCallback(callbackId, null, e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a message from JavaScript to Android's logcat
|
||||||
|
* Called from JavaScript: wails.log(level, message)
|
||||||
|
*
|
||||||
|
* @param level The log level (debug, info, warn, error)
|
||||||
|
* @param message The message to log
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public void log(String level, String message) {
|
||||||
|
switch (level.toLowerCase()) {
|
||||||
|
case "debug":
|
||||||
|
Log.d(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
case "info":
|
||||||
|
Log.i(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
case "warn":
|
||||||
|
Log.w(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
Log.e(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Log.v(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the platform name
|
||||||
|
* Called from JavaScript: wails.platform()
|
||||||
|
*
|
||||||
|
* @return "android"
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public String platform() {
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're running in debug mode
|
||||||
|
* Called from JavaScript: wails.isDebug()
|
||||||
|
*
|
||||||
|
* @return true if debug build, false otherwise
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public boolean isDebug() {
|
||||||
|
return BuildConfig.DEBUG;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a callback response to JavaScript
|
||||||
|
*/
|
||||||
|
private void sendCallback(String callbackId, String result, String error) {
|
||||||
|
final String js;
|
||||||
|
if (error != null) {
|
||||||
|
js = String.format(
|
||||||
|
"window._wailsAndroidCallback && window._wailsAndroidCallback('%s', null, '%s');",
|
||||||
|
escapeJsString(callbackId),
|
||||||
|
escapeJsString(error)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
js = String.format(
|
||||||
|
"window._wailsAndroidCallback && window._wailsAndroidCallback('%s', '%s', null);",
|
||||||
|
escapeJsString(callbackId),
|
||||||
|
escapeJsString(result != null ? result : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.post(() -> webView.evaluateJavascript(js, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeJsString(String str) {
|
||||||
|
if (str == null) return "";
|
||||||
|
return str.replace("\\", "\\\\")
|
||||||
|
.replace("'", "\\'")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
// JS line terminators (U+2028/U+2029) must be escaped too; built via
|
||||||
|
// (char) casts so the Java lexer does not reinterpret them as newlines.
|
||||||
|
.replace(String.valueOf((char) 0x2028), "\\u2028")
|
||||||
|
.replace(String.valueOf((char) 0x2029), "\\u2029");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.WebResourceResponse;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.webkit.WebViewAssetLoader;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WailsPathHandler implements WebViewAssetLoader.PathHandler to serve assets
|
||||||
|
* from the Go asset server. This allows the WebView to load assets without
|
||||||
|
* using a network server, similar to iOS's WKURLSchemeHandler.
|
||||||
|
*/
|
||||||
|
public class WailsPathHandler implements WebViewAssetLoader.PathHandler {
|
||||||
|
private static final String TAG = "WailsPathHandler";
|
||||||
|
private static final boolean DEBUG = BuildConfig.DEBUG;
|
||||||
|
|
||||||
|
private final WailsBridge bridge;
|
||||||
|
|
||||||
|
public WailsPathHandler(WailsBridge bridge) {
|
||||||
|
this.bridge = bridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public WebResourceResponse handle(@NonNull String path) {
|
||||||
|
if (DEBUG) Log.d(TAG, "Handling path: " + path);
|
||||||
|
|
||||||
|
// Normalize path
|
||||||
|
if (path.isEmpty() || path.equals("/")) {
|
||||||
|
path = "/index.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get asset from Go
|
||||||
|
byte[] data = bridge.serveAsset(path, "GET", "{}");
|
||||||
|
|
||||||
|
if (data == null || data.length == 0) {
|
||||||
|
Log.w(TAG, "Asset not found: " + path);
|
||||||
|
return null; // Return null to let WebView handle 404
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type
|
||||||
|
String mimeType = bridge.getAssetMimeType(path);
|
||||||
|
if (DEBUG) Log.d(TAG, "Serving " + path + " with type " + mimeType + " (" + data.length + " bytes)");
|
||||||
|
|
||||||
|
// Create response
|
||||||
|
InputStream inputStream = new ByteArrayInputStream(data);
|
||||||
|
Map<String, String> headers = new HashMap<>();
|
||||||
|
headers.put("Access-Control-Allow-Origin", "*");
|
||||||
|
headers.put("Cache-Control", "no-cache");
|
||||||
|
|
||||||
|
return new WebResourceResponse(
|
||||||
|
mimeType,
|
||||||
|
"UTF-8",
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
headers,
|
||||||
|
inputStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/main_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:id="@+id/webview"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="wails_blue">#3574D4</color>
|
||||||
|
<color name="wails_blue_dark">#2C5FB8</color>
|
||||||
|
<!-- The window background, which is what the launch screen shows and
|
||||||
|
what the system-bar padding leaves visible. Black rather than the
|
||||||
|
scaffold's blue-grey because this app's own default ramp is
|
||||||
|
black: a band of #1B2636 above and below it reads as the app
|
||||||
|
failing to fill the screen. -->
|
||||||
|
<color name="wails_background">#000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">YellowJacket</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.WailsApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/wails_blue</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/wails_blue_dark</item>
|
||||||
|
<item name="colorOnPrimary">@android:color/white</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor">@color/wails_background</item>
|
||||||
|
<item name="android:navigationBarColor">@color/wails_background</item>
|
||||||
|
<!-- Window background -->
|
||||||
|
<item name="android:windowBackground">@color/wails_background</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<!-- Camera captures are written here and shared with the camera app via the
|
||||||
|
FileProvider. -->
|
||||||
|
<cache-path name="captures" path="captures/" />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.7.3' apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/build/optimize-your-build#parallel
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Register main function to be called when the Android app initializes
|
||||||
|
// This is necessary because in c-shared build mode, main() is not automatically called
|
||||||
|
application.RegisterAndroidMain(main)
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Checking Android development dependencies...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
errors := []string{}
|
||||||
|
|
||||||
|
// Check Go
|
||||||
|
if !checkCommand("go", "version") {
|
||||||
|
errors = append(errors, "Go is not installed. Install from https://go.dev/dl/")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Go is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ANDROID_HOME
|
||||||
|
androidHome := os.Getenv("ANDROID_HOME")
|
||||||
|
if androidHome == "" {
|
||||||
|
androidHome = os.Getenv("ANDROID_SDK_ROOT")
|
||||||
|
}
|
||||||
|
if androidHome == "" {
|
||||||
|
// Try common default locations
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
possiblePaths := []string{
|
||||||
|
filepath.Join(home, "Android", "Sdk"),
|
||||||
|
filepath.Join(home, "Library", "Android", "sdk"),
|
||||||
|
"/usr/local/share/android-sdk",
|
||||||
|
}
|
||||||
|
for _, p := range possiblePaths {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
androidHome = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if androidHome == "" {
|
||||||
|
errors = append(errors, "ANDROID_HOME not set. Install Android Studio and set ANDROID_HOME environment variable")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ ANDROID_HOME: %s\n", androidHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check adb
|
||||||
|
if !checkCommand("adb", "version") {
|
||||||
|
if androidHome != "" {
|
||||||
|
platformTools := filepath.Join(androidHome, "platform-tools")
|
||||||
|
errors = append(errors, fmt.Sprintf("adb not found. Add %s to PATH", platformTools))
|
||||||
|
} else {
|
||||||
|
errors = append(errors, "adb not found. Install Android SDK Platform-Tools")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ adb is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check emulator
|
||||||
|
if !checkCommand("emulator", "-list-avds") {
|
||||||
|
if androidHome != "" {
|
||||||
|
emulatorPath := filepath.Join(androidHome, "emulator")
|
||||||
|
errors = append(errors, fmt.Sprintf("emulator not found. Add %s to PATH", emulatorPath))
|
||||||
|
} else {
|
||||||
|
errors = append(errors, "emulator not found. Install Android Emulator via SDK Manager")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Android Emulator is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check NDK
|
||||||
|
ndkHome := os.Getenv("ANDROID_NDK_HOME")
|
||||||
|
if ndkHome == "" && androidHome != "" {
|
||||||
|
// Look for NDK in default location
|
||||||
|
ndkDir := filepath.Join(androidHome, "ndk")
|
||||||
|
if entries, err := os.ReadDir(ndkDir); err == nil {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
ndkHome = filepath.Join(ndkDir, entry.Name())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ndkHome == "" {
|
||||||
|
errors = append(errors, "Android NDK not found. Install NDK via Android Studio > SDK Manager > SDK Tools > NDK (Side by side)")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ Android NDK: %s\n", ndkHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Java
|
||||||
|
if !checkCommand("java", "-version") {
|
||||||
|
errors = append(errors, "Java not found. Install JDK 11+ (OpenJDK recommended)")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Java is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for AVD (Android Virtual Device)
|
||||||
|
if checkCommand("emulator", "-list-avds") {
|
||||||
|
cmd := exec.Command("emulator", "-list-avds")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
|
||||||
|
avds := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
fmt.Printf("✓ Found %d Android Virtual Device(s)\n", len(avds))
|
||||||
|
} else {
|
||||||
|
// Mirror the iOS installer, which offers to create a simulator when
|
||||||
|
// none exist. Only create from an already-installed system image.
|
||||||
|
offerCreateAVD(androidHome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
fmt.Println("❌ Missing dependencies:")
|
||||||
|
for _, err := range errors {
|
||||||
|
fmt.Printf(" - %s\n", err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Setup instructions:")
|
||||||
|
fmt.Println("1. Install Android Studio: https://developer.android.com/studio")
|
||||||
|
fmt.Println("2. Open SDK Manager and install:")
|
||||||
|
fmt.Println(" - Android SDK Platform (API 35)")
|
||||||
|
fmt.Println(" - Android SDK Build-Tools")
|
||||||
|
fmt.Println(" - Android SDK Platform-Tools")
|
||||||
|
fmt.Println(" - Android Emulator")
|
||||||
|
fmt.Println(" - NDK (Side by side)")
|
||||||
|
fmt.Println("3. Set environment variables:")
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
fmt.Println(" export ANDROID_HOME=$HOME/Library/Android/sdk")
|
||||||
|
} else {
|
||||||
|
fmt.Println(" export ANDROID_HOME=$HOME/Android/Sdk")
|
||||||
|
}
|
||||||
|
fmt.Println(" export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator")
|
||||||
|
fmt.Println("4. Create an AVD via Android Studio > Tools > Device Manager")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✓ All Android development dependencies are installed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkCommand(name string, args ...string) bool {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerCreateAVD mirrors the iOS installer's simulator offer: when no AVD
|
||||||
|
// exists, offer to create one — but ONLY from a system image that is already
|
||||||
|
// installed. We never run sdkmanager here (a multi-GB download with a license
|
||||||
|
// prompt is a surprise the user should trigger themselves).
|
||||||
|
func offerCreateAVD(androidHome string) {
|
||||||
|
abi := "x86_64"
|
||||||
|
if runtime.GOARCH == "arm64" {
|
||||||
|
abi = "arm64-v8a"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the highest-API installed system image matching the host ABI.
|
||||||
|
// The API level must be compared numerically: lexicographic sorting
|
||||||
|
// would rank android-9 above android-35.
|
||||||
|
var img string
|
||||||
|
if androidHome != "" {
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(androidHome, "system-images", "android-*", "*", abi))
|
||||||
|
bestAPI := -1
|
||||||
|
for _, m := range matches {
|
||||||
|
apiDir := filepath.Base(filepath.Dir(filepath.Dir(m)))
|
||||||
|
api, err := strconv.Atoi(strings.TrimPrefix(apiDir, "android-"))
|
||||||
|
if err != nil {
|
||||||
|
continue // preview/extension images (e.g. android-35-ext14)
|
||||||
|
}
|
||||||
|
if api > bestAPI {
|
||||||
|
bestAPI = api
|
||||||
|
img = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
avdmanager := findAVDManager(androidHome)
|
||||||
|
|
||||||
|
if img == "" || avdmanager == "" {
|
||||||
|
fmt.Println("⚠ No Android Virtual Devices found.")
|
||||||
|
fmt.Println(" Install a system image and create an AVD, e.g.:")
|
||||||
|
fmt.Printf(" sdkmanager 'system-images;android-35;google_apis;%s'\n", abi)
|
||||||
|
fmt.Printf(" avdmanager create avd --name wails --package 'system-images;android-35;google_apis;%s' --device pixel_7\n", abi)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the package path from the installed image directory, e.g.
|
||||||
|
// <sdk>/system-images/android-35/google_apis/arm64-v8a
|
||||||
|
// -> system-images;android-35;google_apis;arm64-v8a
|
||||||
|
rel := strings.TrimPrefix(img, filepath.Join(androidHome, "system-images")+string(os.PathSeparator))
|
||||||
|
pkg := "system-images;" + strings.ReplaceAll(rel, string(os.PathSeparator), ";")
|
||||||
|
|
||||||
|
fmt.Println("⚠ No Android Virtual Devices found.")
|
||||||
|
fmt.Printf(" Would you like to create a 'wails' AVD from %s?\n", pkg)
|
||||||
|
if !promptUser("Create AVD?") {
|
||||||
|
fmt.Println(" Skipping AVD creation.")
|
||||||
|
fmt.Printf(" Create manually: avdmanager create avd --name wails --package '%s' --device pixel_7\n", pkg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(avdmanager, "create", "avd", "--name", "wails", "--package", pkg, "--device", "pixel_7", "--force")
|
||||||
|
cmd.Stdin = strings.NewReader("no\n") // decline the custom hardware-profile prompt
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Printf(" Failed to create AVD: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println(" ✅ 'wails' AVD created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAVDManager returns the avdmanager path from PATH, or from the SDK's
|
||||||
|
// cmdline-tools (preferring the newest version), or "" if not found.
|
||||||
|
func findAVDManager(androidHome string) string {
|
||||||
|
if p, err := exec.LookPath("avdmanager"); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if androidHome != "" {
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(androidHome, "cmdline-tools", "*", "bin", "avdmanager"))
|
||||||
|
// Prefer the "latest" alias; otherwise compare versions numerically
|
||||||
|
// ("9.0" would lexicographically outrank "11.0").
|
||||||
|
best := ""
|
||||||
|
bestVersion := -1.0
|
||||||
|
for _, m := range matches {
|
||||||
|
version := filepath.Base(filepath.Dir(filepath.Dir(m)))
|
||||||
|
if version == "latest" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(version, 64)
|
||||||
|
if err != nil {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
if v > bestVersion {
|
||||||
|
bestVersion = v
|
||||||
|
best = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptUser(question string) bool {
|
||||||
|
if os.Getenv("CI") != "" || os.Getenv("TASK_FORCE_YES") == "true" {
|
||||||
|
fmt.Printf("%s [y/N]: y (auto-accepted)\n", question)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
fmt.Printf("%s [y/N]: ", question)
|
||||||
|
response, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
response = strings.ToLower(strings.TrimSpace(response))
|
||||||
|
return response == "y" || response == "yes"
|
||||||
|
}
|
||||||