Compare commits

...

37 Commits

Author SHA1 Message Date
funman300 ab35fcf906 fix(ci): free disk space + drop x86_64 from release build to fix OOM
Android Build / build-apk (push) Successful in 16m44s
Build and Deploy / build-and-push (push) Failing after 30s
Android Release / build-release-apk (push) Failing after 12m5s
Run 181 (v0.25.0 tag) failed at "Build signed release APK" after ~7 min —
same disk-exhaustion pattern that hit the debug build. The debug workflow
was already fixed to arm64-v8a only; the release workflow still built all 3
ABIs and exceeded the runner's disk budget.

Changes:
- Add "Free disk space" step before system deps: removes /usr/local/lib/android,
  /usr/share/dotnet, /opt/ghc, /usr/local/share/boost (~10 GB reclaimed).
- Limit ABIS to arm64-v8a + armeabi-v7a (drops x86_64, which is emulator-only).
- Remove x86_64 from rustup target add to match.

arm64-v8a covers all modern Android devices; armeabi-v7a covers legacy ARM.
x86_64 can be re-added later if a simulator-targeted test build is needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:53:48 -07:00
funman300 32991301dd fix(engine): restore Dark as default theme; migrate stale theme IDs
Android Build / build-apk (push) Successful in 12m19s
Build and Deploy / build-and-push (push) Successful in 55s
- default_theme_id() returns "dark" (was briefly "classic" after the
  rename commit 20b7a61)
- sanitized() migrates "default" and "classic" → "dark" so existing
  settings.json files are upgraded automatically on next launch
- Registry lists Dark first so the Settings picker opens with it at top
- Classic remains available as an option in the picker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:47:43 -07:00
Gitea CI c5fd928dcb chore(deploy): bump image to f6907671 [skip ci] 2026-05-14 20:20:49 +00:00
funman300 f6907671be fix(ci): pin upload-artifact to v3 for Gitea Actions compatibility
Android Build / build-apk (push) Successful in 12m49s
Build and Deploy / build-and-push (push) Successful in 47s
Android Release / build-release-apk (push) Failing after 10m11s
The disk-budget fix worked — debug APK now builds, signs, and verifies
in ~6 minutes on a single ABI. But the upload step failed with:

  GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
  and download-artifact@v4+ are not currently supported on GHES.

upload-artifact@v4 rewrote the upload path to use a new artifact
service hosted on github.com; Gitea's GHES-compatibility layer doesn't
implement that endpoint. v3 still uses the older chunked HTTP upload
API that Gitea supports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:07:09 -07:00
Gitea CI a54fff7257 chore(deploy): bump image to 533bcec2 [skip ci] 2026-05-14 20:01:12 +00:00
funman300 533bcec2d8 fix(ci): limit debug APK to arm64-v8a so apksigner has disk to write
Android Build / build-apk (push) Failing after 9m10s
Build and Deploy / build-and-push (push) Successful in 52s
The previous run got all the way through compile + link + zipalign and
then died inside apksigner with `IOException: No space left on device`.
Cross-compiling all three Android ABIs (arm64-v8a, armeabi-v7a, x86_64)
in debug mode blows target/ past 25 GB, and by the time apksigner is
streaming the signed APK to disk the runner has nothing left.

Two changes:

  1. build_android_apk.sh now reads `ABIS` from the environment (defaults
     to all three for backwards compat) and uses it to assemble the
     cargo-ndk `-t` flags.
  2. android-build.yml passes ABIS=arm64-v8a, since the debug artifact
     is consumed by adb-installing to a single arm64 device and the
     other two ABIs were dead weight.

Also frees \$STAGING/app-unsigned.apk right after zipalign so it's not
sitting next to the aligned APK and the output APK during signing.

Release workflow is untouched — release APKs still ship all three ABIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:51:17 -07:00
Gitea CI ba786f5a09 chore(deploy): bump image to 7ee7cb6d [skip ci] 2026-05-14 19:17:40 +00:00
funman300 7ee7cb6d93 ci(android): replace cargo-apk with cargo-ndk + manual APK assembly
Android Build / build-apk (push) Failing after 23m0s
Build and Deploy / build-and-push (push) Successful in 43s
cargo-apk 0.10 and its fork cargo-apk2 both failed to discover the
installed Android platform in this Gitea runner, despite ANDROID_HOME,
platforms;android-34, build-tools, and NDK all being present, readable,
and pointed at correctly. We never isolated whether the bug is in the
shared ndk-build crate's discovery logic or in the runner's env-var
propagation through cargo subcommand exec, so this commit stops fighting
either tool and assembles the APK from explicit toolchain steps instead:

  cargo ndk          -> per-ABI .so files
  aapt2 compile/link -> manifest + resources -> base APK
  zip                -> bundle native libs into lib/<abi>/
  zipalign           -> 4-byte alignment
  apksigner          -> v2/v3 signing (debug keystore for CI, real for release)

The pipeline lives in scripts/build_android_apk.sh so it's reproducible
locally (same env vars, same commands). AndroidManifest.xml is now
checked in under solitaire_app/android/ and mirrors what cargo-apk would
have generated from [package.metadata.android] — keep them in sync if
either is changed. Local `cargo apk build` still works on developer
machines where cargo-apk is happy; CI just stops depending on it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:53:55 -07:00
funman300 14324b09ef ci(android): switch from cargo-apk 0.10.0 to cargo-apk2
Android Build / build-apk (push) Failing after 3m49s
Build and Deploy / build-and-push (push) Failing after 23s
cargo-apk 0.10.0 has been unable to discover an installed Android
platform in this runner environment despite ANDROID_HOME, NDK,
build-tools, and platforms;android-34 all being present and readable.
cargo-apk2 is the maintained community fork on crates.io that reads
the same `[package.metadata.android]` block, so the solitaire_app
Cargo.toml needs no changes. Cache keys updated to apk2- so we don't
restore the broken cargo-apk binary from prior runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:43:50 -07:00
Gitea CI 124f1f5cf5 chore(deploy): bump image to a6a73b5f [skip ci] 2026-05-14 18:38:04 +00:00
funman300 a6a73b5f36 fix(ci): add permission and env diagnostics to build step
Android Build / build-apk (push) Failing after 3m47s
Build and Deploy / build-and-push (push) Successful in 36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:33:54 -07:00
Gitea CI b84fe79806 chore(deploy): bump image to 3248f00d [skip ci] 2026-05-14 18:32:22 +00:00
funman300 3248f00d66 fix(ci): deeper SDK verification — find android.jar actual location
Android Build / build-apk (push) Failing after 3m41s
Build and Deploy / build-and-push (push) Successful in 25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:28:17 -07:00
Gitea CI c680a043ae chore(deploy): bump image to d0ab7ed9 [skip ci] 2026-05-14 18:26:23 +00:00
funman300 d0ab7ed97b fix(ci): add SDK verification step to diagnose platforms-not-found
Android Build / build-apk (push) Failing after 3m28s
Build and Deploy / build-and-push (push) Successful in 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:22:11 -07:00
Gitea CI 1144a96757 chore(deploy): bump image to eba1f66b [skip ci] 2026-05-14 18:18:13 +00:00
funman300 ac6668cee7 fix(ci): apply template-expansion pattern to release workflow
Android Build / build-apk (push) Failing after 2m59s
Build and Deploy / build-and-push (push) Failing after 46s
Mirror the fix from android-build.yml: rename ANDROID_HOME -> ANDROID_SDK
in the env block to avoid the Docker-image-baked ANDROID_HOME overriding
the workflow value in run scripts. Use ${{ env.ANDROID_SDK }} template
expressions throughout, and explicitly export ANDROID_HOME/ANDROID_NDK_HOME
before cargo-apk build so it finds the SDK at the right path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:17:58 -07:00
funman300 eba1f66b45 fix(ci): use template-expanded paths in run scripts to bypass Docker ENV
Android Build / build-apk (push) Failing after 3m6s
Build and Deploy / build-and-push (push) Successful in 31s
Replace shell variable $ANDROID_HOME references in run blocks with
${{ env.ANDROID_SDK }} template expressions. Gitea runner v1 may not
override Docker-image-baked ENV vars via docker exec; template expansion
happens at workflow compilation time, so the literal path is hardcoded
into the script before the shell runs it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:14:34 -07:00
Gitea CI 90959728b1 chore(deploy): bump image to 8b30f877 [skip ci] 2026-05-14 18:11:34 +00:00
funman300 8b30f8778b fix(ci): use fresh /opt/android-sdk path to avoid container ENV conflict
Android Build / build-apk (push) Failing after 3m9s
Build and Deploy / build-and-push (push) Successful in 50s
Remove SDK detection logic and install directly to /opt/android-sdk,
matching the release workflow. The container Docker image has ANDROID_HOME
baked in at /usr/local/lib/android/sdk; installing there with sudo while
cargo-apk resolves ANDROID_HOME from the image ENV created a divergence.
Using a controlled path we own eliminates that class of conflict entirely.
Add SDK cache shared with the release workflow (same key prefix v2-).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:07:46 -07:00
Gitea CI d6a7924f14 chore(deploy): bump image to 4db43fb3 [skip ci] 2026-05-14 18:02:16 +00:00
funman300 4db43fb3fb fix(ci): replace ANDROID_SDK_ROOT with ANDROID_HOME in release workflow
Android Build / build-apk (push) Failing after 3m49s
Build and Deploy / build-and-push (push) Successful in 54s
ANDROID_SDK_ROOT was never set; zipalign and apksigner were resolving
to empty paths and would fail. All three occurrences replaced with
ANDROID_HOME which is defined in the workflow env block.

Also adds sudo to the cache-miss SDK install (mkdir/mv/sdkmanager) to
match the debug workflow pattern — /opt/android-sdk requires root on
a fresh runner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:56:42 -07:00
funman300 01d6b27e61 fix(ci): detect existing container SDK before installing, set ANDROID_HOME via GITHUB_ENV
Android Build / build-apk (push) Failing after 3m50s
Build and Deploy / build-and-push (push) Failing after 33s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:53:14 -07:00
funman300 3cffbc2c51 feat(engine): embed classic theme into binary like dark theme
Classic SVGs and manifest are now compiled in via include_bytes!(),
making the theme available on all platforms (desktop, Android) without
requiring filesystem assets. Removes the now-redundant Dockerfile COPY
of solitaire_engine/assets/themes/classic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:53:14 -07:00
Gitea CI 2ef25934ac chore(deploy): bump image to bb670d6c [skip ci] 2026-05-14 17:51:40 +00:00
funman300 bb670d6cc6 fix(ci): drop ANDROID_SDK_ROOT, pass --sdk_root to sdkmanager explicitly
Android Build / build-apk (push) Failing after 3m19s
Build and Deploy / build-and-push (push) Successful in 30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:47:52 -07:00
Gitea CI 76911c57c9 chore(deploy): bump image to 8391235a [skip ci] 2026-05-14 17:45:46 +00:00
funman300 8391235a1a fix(ci): check android.jar existence in platform dir
Android Build / build-apk (push) Failing after 3m25s
Build and Deploy / build-and-push (push) Successful in 30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:41:57 -07:00
funman300 2f3a6b9586 fix(ci): dump env at build time to diagnose ANDROID_HOME visibility
Android Build / build-apk (push) Failing after 3m23s
Build and Deploy / build-and-push (push) Failing after 40s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:37:37 -07:00
Gitea CI 4d20b70809 chore(deploy): bump image to bfadcf0e [skip ci] 2026-05-14 17:32:04 +00:00
funman300 bfadcf0e0d fix(ci): add SDK layout debug step to diagnose platforms-not-found error
Android Build / build-apk (push) Failing after 3m19s
Build and Deploy / build-and-push (push) Successful in 42s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:28:10 -07:00
Gitea CI 356dbebe57 chore(deploy): bump image to c90c7831 [skip ci] 2026-05-14 17:28:04 +00:00
funman300 c90c783177 fix(ci): set ANDROID_HOME/NDK_HOME in workflow env block instead of GITHUB_ENV
Android Build / build-apk (push) Failing after 3m23s
Build and Deploy / build-and-push (push) Successful in 40s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:23:58 -07:00
Gitea CI bbf4b2c14a chore(deploy): bump image to 1f46785b [skip ci] 2026-05-14 17:19:04 +00:00
funman300 62be72e918 fix(ci): bust SDK cache key to force fresh SDK install after prior broken cache
Android Build / build-apk (push) Failing after 3m16s
Build and Deploy / build-and-push (push) Failing after 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:18:57 -07:00
funman300 1f46785b31 fix(ci): add apt-get update before package install to fix exit code 100
Android Build / build-apk (push) Failing after 3m50s
Build and Deploy / build-and-push (push) Successful in 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:14:32 -07:00
Gitea CI 2e5d82f83c chore(deploy): bump image to 396ba6bc [skip ci] 2026-05-14 17:12:21 +00:00
11 changed files with 499 additions and 116 deletions
+45 -32
View File
@@ -12,9 +12,10 @@ on:
- '**.md' - '**.md'
env: env:
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK: /opt/android-sdk
NDK_VERSION: "25.2.9519653" NDK_VERSION: "25.2.9519653"
BUILD_TOOLS_VERSION: "34.0.0" BUILD_TOOLS_VERSION: "34.0.0"
PLATFORM: "android-34"
jobs: jobs:
build-apk: build-apk:
@@ -28,39 +29,36 @@ jobs:
id: meta id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
# ── Android SDK + NDK ────────────────────────────────────────────── # ── System dependencies ────────────────────────────────────────────
# Cache the entire SDK root so subsequent runs skip the ~2 GB download. - name: Install system dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y openjdk-17-jdk-headless unzip zip
# ── Android SDK (shared cache key with release workflow) ──────────
- name: Cache Android SDK - name: Cache Android SDK
uses: actions/cache@v4 uses: actions/cache@v4
id: sdk-cache id: sdk-cache
with: with:
path: ${{ env.ANDROID_SDK_ROOT }} path: ${{ env.ANDROID_SDK }}
key: android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }} key: v2-android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
- name: Install system dependencies
run: sudo apt-get install -y openjdk-17-jdk-headless unzip
- name: Install Android SDK + NDK - name: Install Android SDK + NDK
if: steps.sdk-cache.outputs.cache-hit != 'true' if: steps.sdk-cache.outputs.cache-hit != 'true'
run: | run: |
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" sudo mkdir -p ${{ env.ANDROID_SDK }}/cmdline-tools
curl -sL \ curl -sL \
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \ "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
-o /tmp/cmdtools.zip -o /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools
mv /tmp/cmdtools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest" sudo mv /tmp/cmdtools/cmdline-tools ${{ env.ANDROID_SDK }}/cmdline-tools/latest
# Accept all SDK licences non-interactively. yes | sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \ --sdk_root=${{ env.ANDROID_SDK }} --licenses > /dev/null 2>&1 || true
> /dev/null 2>&1 || true sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \ --sdk_root=${{ env.ANDROID_SDK }} \
"build-tools;$BUILD_TOOLS_VERSION" \ "build-tools;${{ env.BUILD_TOOLS_VERSION }}" \
"platforms;android-34" \ "platforms;${{ env.PLATFORM }}" \
"ndk;$NDK_VERSION" "ndk;${{ env.NDK_VERSION }}"
- name: Export Android environment
run: |
echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
# ── Rust toolchain ───────────────────────────────────────────────── # ── Rust toolchain ─────────────────────────────────────────────────
- name: Install Rust stable - name: Install Rust stable
@@ -87,12 +85,12 @@ jobs:
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }} key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-registry- restore-keys: cargo-registry-
- name: Cache cargo-apk binary - name: Cache cargo-ndk binary
uses: actions/cache@v4 uses: actions/cache@v4
id: apk-tool-cache id: ndk-tool-cache
with: with:
path: ~/.cargo/bin/cargo-apk path: ~/.cargo/bin/cargo-ndk
key: cargo-apk-${{ runner.os }}-stable key: cargo-ndk-${{ runner.os }}-stable
- name: Cache build artifacts - name: Cache build artifacts
uses: actions/cache@v4 uses: actions/cache@v4
@@ -101,17 +99,32 @@ jobs:
key: android-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }} key: android-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
restore-keys: android-target-${{ hashFiles('**/Cargo.lock') }}- restore-keys: android-target-${{ hashFiles('**/Cargo.lock') }}-
# ── Build ────────────────────────────────────────────────────────── - name: Install cargo-ndk
- name: Install cargo-apk if: steps.ndk-tool-cache.outputs.cache-hit != 'true'
if: steps.apk-tool-cache.outputs.cache-hit != 'true' run: cargo install cargo-ndk --locked
run: cargo install cargo-apk --locked
# ── Build APK ──────────────────────────────────────────────────────
# Debug CI only builds arm64-v8a — full three-ABI debug builds blow
# past the runner's disk budget (~25 GB of target/ + intermediate
# APKs caused apksigner to OOM-on-disk in the previous run). Release
# CI still ships all three ABIs from android-release.yml.
- name: Build debug APK - name: Build debug APK
run: cargo apk build --package solitaire_app --lib env:
ANDROID_HOME: ${{ env.ANDROID_SDK }}
ANDROID_NDK_HOME: ${{ env.ANDROID_SDK }}/ndk/${{ env.NDK_VERSION }}
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOLS_VERSION }}
PLATFORM: ${{ env.PLATFORM }}
PROFILE: debug
ABIS: arm64-v8a
run: ./scripts/build_android_apk.sh
# ── Artifact ─────────────────────────────────────────────────────── # ── Artifact ───────────────────────────────────────────────────────
# Pinned to v3 because Gitea Actions doesn't implement the github.com
# artifact service that upload-artifact@v4+ requires; v3 uses the
# older chunked HTTP API that Gitea's GHES-compatibility layer
# supports.
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: solitaire-quest-debug-${{ steps.meta.outputs.sha }} name: solitaire-quest-debug-${{ steps.meta.outputs.sha }}
path: target/debug/apk/solitaire-quest.apk path: target/debug/apk/solitaire-quest.apk
+59 -62
View File
@@ -6,9 +6,10 @@ on:
- 'v*.*.*' - 'v*.*.*'
env: env:
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK: /opt/android-sdk
NDK_VERSION: "25.2.9519653" NDK_VERSION: "25.2.9519653"
BUILD_TOOLS_VERSION: "34.0.0" BUILD_TOOLS_VERSION: "34.0.0"
PLATFORM: "android-34"
GITEA_API: https://git.aleshym.co/api/v1 GITEA_API: https://git.aleshym.co/api/v1
REPO: funman300/Rusty_Solitare REPO: funman300/Rusty_Solitare
@@ -24,40 +25,48 @@ jobs:
id: meta id: meta
run: echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" run: echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
# ── Android SDK + NDK ────────────────────────────────────────────── # ── Free disk space ────────────────────────────────────────────────
# Shared cache key with the debug workflow so a warm debug run # A 2-ABI release build (arm64 + armv7) generates ~15 GB of target/
# saves the ~2 GB SDK download for the release run too. # output. Remove pre-installed runner tooling that is never used
# during an Android build to reclaim ~10 GB before we start.
- name: Free disk space
run: |
sudo rm -rf /usr/local/lib/android # runner pre-installed SDK
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/share/boost
df -h /
# ── System dependencies ────────────────────────────────────────────
- name: Install system dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y openjdk-17-jdk-headless unzip zip jq
# ── Android SDK (shared cache key with debug workflow) ─────────────
- name: Cache Android SDK - name: Cache Android SDK
uses: actions/cache@v4 uses: actions/cache@v4
id: sdk-cache id: sdk-cache
with: with:
path: ${{ env.ANDROID_SDK_ROOT }} path: ${{ env.ANDROID_SDK }}
key: android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }} key: v2-android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
# Java and jq are always needed (apksigner requires Java even on cache hits).
- name: Install system dependencies
run: sudo apt-get install -y openjdk-17-jdk-headless unzip jq
- name: Install Android SDK + NDK - name: Install Android SDK + NDK
if: steps.sdk-cache.outputs.cache-hit != 'true' if: steps.sdk-cache.outputs.cache-hit != 'true'
run: | run: |
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" sudo mkdir -p ${{ env.ANDROID_SDK }}/cmdline-tools
curl -sL \ curl -sL \
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \ "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
-o /tmp/cmdtools.zip -o /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools
mv /tmp/cmdtools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest" sudo mv /tmp/cmdtools/cmdline-tools ${{ env.ANDROID_SDK }}/cmdline-tools/latest
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \ yes | sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
> /dev/null 2>&1 || true --sdk_root=${{ env.ANDROID_SDK }} --licenses > /dev/null 2>&1 || true
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \ sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
"build-tools;$BUILD_TOOLS_VERSION" \ --sdk_root=${{ env.ANDROID_SDK }} \
"platforms;android-34" \ "build-tools;${{ env.BUILD_TOOLS_VERSION }}" \
"ndk;$NDK_VERSION" "platforms;${{ env.PLATFORM }}" \
"ndk;${{ env.NDK_VERSION }}"
- name: Export Android environment
run: |
echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
# ── Rust toolchain ───────────────────────────────────────────────── # ── Rust toolchain ─────────────────────────────────────────────────
- name: Install Rust stable - name: Install Rust stable
@@ -70,8 +79,7 @@ jobs:
run: | run: |
rustup target add \ rustup target add \
aarch64-linux-android \ aarch64-linux-android \
armv7-linux-androideabi \ armv7-linux-androideabi
x86_64-linux-android
# ── Cargo caches ─────────────────────────────────────────────────── # ── Cargo caches ───────────────────────────────────────────────────
- name: Cache Cargo registry - name: Cache Cargo registry
@@ -84,12 +92,12 @@ jobs:
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }} key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-registry- restore-keys: cargo-registry-
- name: Cache cargo-apk binary - name: Cache cargo-ndk binary
uses: actions/cache@v4 uses: actions/cache@v4
id: apk-tool-cache id: ndk-tool-cache
with: with:
path: ~/.cargo/bin/cargo-apk path: ~/.cargo/bin/cargo-ndk
key: cargo-apk-${{ runner.os }}-stable key: cargo-ndk-${{ runner.os }}-stable
- name: Cache build artifacts - name: Cache build artifacts
uses: actions/cache@v4 uses: actions/cache@v4
@@ -98,48 +106,37 @@ jobs:
key: android-release-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }} key: android-release-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
restore-keys: android-release-target-${{ hashFiles('**/Cargo.lock') }}- restore-keys: android-release-target-${{ hashFiles('**/Cargo.lock') }}-
# ── Build ────────────────────────────────────────────────────────── - name: Install cargo-ndk
- name: Install cargo-apk if: steps.ndk-tool-cache.outputs.cache-hit != 'true'
if: steps.apk-tool-cache.outputs.cache-hit != 'true' run: cargo install cargo-ndk --locked
run: cargo install cargo-apk --locked
- name: Build release APK # ── Build & sign with release keystore ─────────────────────────────
run: cargo apk build --release --package solitaire_app --lib
# ── Sign ───────────────────────────────────────────────────────────
- name: Decode keystore - name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > /tmp/solitaire-release.jks run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > /tmp/solitaire-release.jks
- name: Align and sign APK - name: Build signed release APK
run: | env:
TAG="${{ steps.meta.outputs.tag }}" ANDROID_HOME: ${{ env.ANDROID_SDK }}
UNSIGNED="target/release/apk/solitaire-quest.apk" ANDROID_NDK_HOME: ${{ env.ANDROID_SDK }}/ndk/${{ env.NDK_VERSION }}
ALIGNED="/tmp/solitaire-quest-aligned.apk" BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOLS_VERSION }}
SIGNED="ferrous-solitaire-${TAG}.apk" PLATFORM: ${{ env.PLATFORM }}
PROFILE: release
# arm64-v8a covers all modern Android phones; armeabi-v7a covers
# legacy ARM devices. x86_64 is emulator-only and dropped to
# stay within the runner's ~25 GB disk budget.
ABIS: arm64-v8a armeabi-v7a
KEYSTORE: /tmp/solitaire-release.jks
KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASS: ${{ secrets.KEY_PASS }}
APK_OUT: ferrous-solitaire-${{ steps.meta.outputs.tag }}.apk
run: ./scripts/build_android_apk.sh
"$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOLS_VERSION/zipalign" -v 4 \ # ── Publish to Gitea release ───────────────────────────────────────
"$UNSIGNED" "$ALIGNED"
"$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOLS_VERSION/apksigner" sign \
--ks /tmp/solitaire-release.jks \
--ks-pass "pass:${{ secrets.KEYSTORE_PASS }}" \
--ks-key-alias "${{ secrets.KEY_ALIAS }}" \
--key-pass "pass:${{ secrets.KEY_PASS }}" \
--out "$SIGNED" \
"$ALIGNED"
- name: Verify APK signature
run: |
TAG="${{ steps.meta.outputs.tag }}"
"$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOLS_VERSION/apksigner" verify \
--verbose "ferrous-solitaire-${TAG}.apk"
# ── Publish ────────────────────────────────────────────────────────
- name: Create Gitea release - name: Create Gitea release
id: release id: release
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
# Try to create; fall back to fetching the existing release on 409.
RESPONSE=$(curl -s -o /tmp/release.json -w "%{http_code}" \ RESPONSE=$(curl -s -o /tmp/release.json -w "%{http_code}" \
-X POST "$GITEA_API/repos/$REPO/releases" \ -X POST "$GITEA_API/repos/$REPO/releases" \
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \ -H "Authorization: token ${{ secrets.CI_TOKEN }}" \
+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images: images:
- name: solitaire-server - name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server newName: git.aleshym.co/funman300/solitaire-server
newTag: 0f650311 newTag: f6907671
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Build a self-signed Android APK from solitaire_app's cdylib targets.
#
# Replaces the cargo-apk pipeline with explicit cargo-ndk + aapt2 + apksigner
# steps. The CI runner was hitting an SDK-discovery bug inside cargo-apk's
# ndk-build crate that we couldn't isolate; running each Android toolchain
# step explicitly gives us a debuggable pipeline.
#
# Required environment:
# ANDROID_HOME Path to Android SDK root
# ANDROID_NDK_HOME Path to the specific NDK version
# BUILD_TOOLS_VERSION e.g. "34.0.0"
# PLATFORM e.g. "android-34"
#
# Optional environment:
# PROFILE "debug" (default) | "release"
# ABIS Space-separated Android ABIs to build (default:
# "arm64-v8a armeabi-v7a x86_64"). Reduce in CI to
# fit the runner's disk budget — a full three-ABI
# debug build can exceed 25 GB of target/ output.
# APK_OUT Output APK path (default: target/$PROFILE/apk/solitaire-quest.apk)
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
# KEY_ALIAS Key alias (default: "androiddebugkey")
# KEY_PASS Key password (default: same as KEYSTORE_PASS)
#
# Outputs:
# $APK_OUT Signed, zipaligned APK
set -euo pipefail
: "${ANDROID_HOME:?ANDROID_HOME must be set}"
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}"
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}"
: "${PLATFORM:?PLATFORM must be set (e.g. android-34)}"
PROFILE="${PROFILE:-debug}"
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/solitaire-quest.apk}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
MANIFEST="solitaire_app/android/AndroidManifest.xml"
RES_DIR="solitaire_app/res"
ASSETS_DIR="assets"
# --- sanity ----------------------------------------------------------------
for f in "$BT/aapt2" "$BT/zipalign" "$BT/apksigner" "$PLATFORM_JAR" "$MANIFEST"; do
[ -e "$f" ] || { echo "missing: $f"; exit 1; }
done
STAGING="$(mktemp -d)"
trap 'rm -rf "$STAGING"' EXIT
mkdir -p "$STAGING/lib" "$STAGING/compiled-res"
# --- 1. native libraries via cargo-ndk -------------------------------------
# `-o $STAGING/lib` lays out files as $STAGING/lib/<abi>/libsolitaire_app.so
# which is the directory structure the APK expects under lib/.
CARGO_NDK_ARGS=( --platform 26 -o "$STAGING/lib" )
for abi in $ABIS; do
CARGO_NDK_ARGS+=( -t "$abi" )
done
CARGO_NDK_ARGS+=( build --package solitaire_app --lib )
if [ "$PROFILE" = "release" ]; then
CARGO_NDK_ARGS+=( --release )
fi
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
cargo ndk "${CARGO_NDK_ARGS[@]}"
# --- 2. compile + link resources and manifest ------------------------------
if [ -d "$RES_DIR" ]; then
echo ">>> aapt2 compile resources"
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
fi
LINK_ARGS=(
link
-o "$STAGING/app-unsigned.apk"
-I "$PLATFORM_JAR"
--manifest "$MANIFEST"
)
[ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
# Add compiled resources if any
shopt -s nullglob
RES_FLATS=( "$STAGING/compiled-res"/*.flat )
shopt -u nullglob
if [ ${#RES_FLATS[@]} -gt 0 ]; then
LINK_ARGS+=( "${RES_FLATS[@]}" )
fi
echo ">>> aapt2 link"
"$BT/aapt2" "${LINK_ARGS[@]}"
# --- 3. add native libraries to the APK ------------------------------------
echo ">>> bundle native libraries"
( cd "$STAGING" && zip -r -q app-unsigned.apk lib/ )
# --- 4. zipalign -----------------------------------------------------------
echo ">>> zipalign"
"$BT/zipalign" -p -f 4 "$STAGING/app-unsigned.apk" "$STAGING/app-aligned.apk"
# Free the unsigned intermediate now — apksigner reads $app-aligned.apk and
# writes $APK_OUT, and the runner's disk is tight after a multi-ABI build.
rm -f "$STAGING/app-unsigned.apk"
# --- 5. sign ---------------------------------------------------------------
if [ -z "${KEYSTORE:-}" ]; then
# Generate a deterministic debug keystore on the fly.
KEYSTORE="$STAGING/debug.keystore"
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
echo ">>> generating debug keystore at $KEYSTORE"
keytool -genkeypair -v \
-keystore "$KEYSTORE" \
-storepass "$KEYSTORE_PASS" \
-alias "$KEY_ALIAS" \
-keypass "$KEY_PASS" \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US" > /dev/null
fi
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
mkdir -p "$(dirname "$APK_OUT")"
echo ">>> apksigner sign -> $APK_OUT"
"$BT/apksigner" sign \
--ks "$KEYSTORE" \
--ks-pass "pass:$KEYSTORE_PASS" \
--ks-key-alias "$KEY_ALIAS" \
--key-pass "pass:$KEY_PASS" \
--out "$APK_OUT" \
"$STAGING/app-aligned.apk"
echo ">>> verify"
"$BT/apksigner" verify --verbose "$APK_OUT"
echo ">>> done: $APK_OUT"
+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Mirrors what cargo-apk would generate from [package.metadata.android]
in solitaire_app/Cargo.toml. Kept in-tree so the CI workflow can drive
aapt2 directly without going through cargo-apk's brittle SDK discovery.
Keep in sync with:
* Cargo.toml: package, min_sdk_version, target_sdk_version,
uses_feature, uses_permission, application label/icon,
activity orientation
* [lib].name (currently "solitaire_app") — matches the
`android.app.lib_name` meta-data value below, which is the
shared object name without the `lib` prefix or `.so` suffix.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.solitairequest.app"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk
android:minSdkVersion="26"
android:targetSdkVersion="34" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="true" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Ferrous Solitaire"
android:icon="@mipmap/ic_launcher"
android:hasCode="false">
<activity
android:name="android.app.NativeActivity"
android:exported="true"
android:screenOrientation="portrait"
android:configChanges="orientation|keyboardHidden|screenSize|keyboard|navigation|screenLayout">
<meta-data
android:name="android.app.lib_name"
android:value="solitaire_app" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+12 -4
View File
@@ -143,10 +143,10 @@ pub struct Settings {
#[serde(default)] #[serde(default)]
pub window_geometry: Option<WindowGeometry>, pub window_geometry: Option<WindowGeometry>,
/// Identifier of the active card-art theme. Matches `meta.id` from /// Identifier of the active card-art theme. Matches `meta.id` from
/// the theme's `theme.ron` manifest. `"classic"` and `"dark"` are /// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
/// always present; user-supplied themes register under their own ids. /// always present; user-supplied themes register under their own ids.
/// Older `settings.json` files that stored `"default"` will fall /// Older `settings.json` files that stored `"default"` or `"classic"`
/// back to the dark embedded theme at runtime. /// are migrated to `"dark"` by [`Settings::sanitized`].
#[serde(default = "default_theme_id")] #[serde(default = "default_theme_id")]
pub selected_theme_id: String, pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been /// Set to `true` once the achievement-onboarding info-toast has been
@@ -272,7 +272,7 @@ fn default_music_volume() -> f32 {
} }
fn default_theme_id() -> String { fn default_theme_id() -> String {
"classic".to_string() "dark".to_string()
} }
/// Default tooltip-hover dwell delay in seconds. Mirrors /// Default tooltip-hover dwell delay in seconds. Mirrors
@@ -395,6 +395,13 @@ impl Settings {
/// their respective ranges after deserialization or hand-editing of /// their respective ranges after deserialization or hand-editing of
/// `settings.json`. /// `settings.json`.
pub fn sanitized(self) -> Self { pub fn sanitized(self) -> Self {
// Migrate stale theme IDs: "default" was removed when the theme was
// renamed to "dark"; "classic" was briefly the default before "dark"
// was restored as the shipped default.
let selected_theme_id = match self.selected_theme_id.as_str() {
"default" | "classic" => "dark".to_string(),
_ => self.selected_theme_id,
};
Self { Self {
sfx_volume: self.sfx_volume.clamp(0.0, 1.0), sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
music_volume: self.music_volume.clamp(0.0, 1.0), music_volume: self.music_volume.clamp(0.0, 1.0),
@@ -407,6 +414,7 @@ impl Settings {
replay_move_interval_secs: self replay_move_interval_secs: self
.replay_move_interval_secs .replay_move_interval_secs
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS), .clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
selected_theme_id,
..self ..self
} }
} }
+3 -2
View File
@@ -11,8 +11,9 @@ pub mod svg_loader;
pub mod user_dir; pub mod user_dir;
pub use sources::{ pub use sources::{
bundled_theme_url, dark_theme_svg_bytes, populate_embedded_dark_theme, bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes,
register_theme_asset_sources, AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, populate_embedded_classic_theme, populate_embedded_dark_theme, register_theme_asset_sources,
AssetSourcesPlugin, CLASSIC_THEME_MANIFEST_URL, DARK_THEME_MANIFEST_URL, USER_THEMES,
}; };
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings}; pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
pub use user_dir::{set_user_theme_dir, user_theme_dir}; pub use user_dir::{set_user_theme_dir, user_theme_dir};
+164 -1
View File
@@ -78,6 +78,20 @@ const DARK_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/dark/them
const DARK_THEME_MANIFEST_BYTES: &[u8] = const DARK_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/dark/theme.ron"); include_bytes!("../../assets/themes/dark/theme.ron");
/// Stable embedded asset URL of the bundled Classic theme manifest.
pub const CLASSIC_THEME_MANIFEST_URL: &str =
"embedded://solitaire_engine/assets/themes/classic/theme.ron";
/// Path the embedded Classic-theme manifest registers under, relative
/// to the `embedded://` source root. Kept in lockstep with
/// [`CLASSIC_THEME_MANIFEST_URL`] by the unit test
/// `classic_theme_url_constant_matches_embedded_path`.
const CLASSIC_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/classic/theme.ron";
/// Bytes of the bundled Classic theme manifest, embedded at compile time.
const CLASSIC_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/classic/theme.ron");
/// Generates a `(stable_path, bytes)` entry for one Dark-theme SVG. /// Generates a `(stable_path, bytes)` entry for one Dark-theme SVG.
macro_rules! embed_dark_svg { macro_rules! embed_dark_svg {
($name:literal) => { ($name:literal) => {
@@ -88,6 +102,16 @@ macro_rules! embed_dark_svg {
}; };
} }
/// Generates a `(stable_path, bytes)` entry for one Classic-theme SVG.
macro_rules! embed_classic_svg {
($name:literal) => {
(
concat!("solitaire_engine/assets/themes/classic/", $name),
include_bytes!(concat!("../../assets/themes/classic/", $name)) as &[u8],
)
};
}
/// Every Dark-theme SVG file bundled into the binary. /// Every Dark-theme SVG file bundled into the binary.
const DARK_THEME_SVGS: &[(&str, &[u8])] = &[ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
embed_dark_svg!("back.svg"), embed_dark_svg!("back.svg"),
@@ -145,6 +169,63 @@ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
embed_dark_svg!("spades_king.svg"), embed_dark_svg!("spades_king.svg"),
]; ];
/// Every Classic-theme SVG file bundled into the binary.
const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
embed_classic_svg!("back.svg"),
embed_classic_svg!("clubs_ace.svg"),
embed_classic_svg!("clubs_2.svg"),
embed_classic_svg!("clubs_3.svg"),
embed_classic_svg!("clubs_4.svg"),
embed_classic_svg!("clubs_5.svg"),
embed_classic_svg!("clubs_6.svg"),
embed_classic_svg!("clubs_7.svg"),
embed_classic_svg!("clubs_8.svg"),
embed_classic_svg!("clubs_9.svg"),
embed_classic_svg!("clubs_10.svg"),
embed_classic_svg!("clubs_jack.svg"),
embed_classic_svg!("clubs_queen.svg"),
embed_classic_svg!("clubs_king.svg"),
embed_classic_svg!("diamonds_ace.svg"),
embed_classic_svg!("diamonds_2.svg"),
embed_classic_svg!("diamonds_3.svg"),
embed_classic_svg!("diamonds_4.svg"),
embed_classic_svg!("diamonds_5.svg"),
embed_classic_svg!("diamonds_6.svg"),
embed_classic_svg!("diamonds_7.svg"),
embed_classic_svg!("diamonds_8.svg"),
embed_classic_svg!("diamonds_9.svg"),
embed_classic_svg!("diamonds_10.svg"),
embed_classic_svg!("diamonds_jack.svg"),
embed_classic_svg!("diamonds_queen.svg"),
embed_classic_svg!("diamonds_king.svg"),
embed_classic_svg!("hearts_ace.svg"),
embed_classic_svg!("hearts_2.svg"),
embed_classic_svg!("hearts_3.svg"),
embed_classic_svg!("hearts_4.svg"),
embed_classic_svg!("hearts_5.svg"),
embed_classic_svg!("hearts_6.svg"),
embed_classic_svg!("hearts_7.svg"),
embed_classic_svg!("hearts_8.svg"),
embed_classic_svg!("hearts_9.svg"),
embed_classic_svg!("hearts_10.svg"),
embed_classic_svg!("hearts_jack.svg"),
embed_classic_svg!("hearts_queen.svg"),
embed_classic_svg!("hearts_king.svg"),
embed_classic_svg!("spades_ace.svg"),
embed_classic_svg!("spades_2.svg"),
embed_classic_svg!("spades_3.svg"),
embed_classic_svg!("spades_4.svg"),
embed_classic_svg!("spades_5.svg"),
embed_classic_svg!("spades_6.svg"),
embed_classic_svg!("spades_7.svg"),
embed_classic_svg!("spades_8.svg"),
embed_classic_svg!("spades_9.svg"),
embed_classic_svg!("spades_10.svg"),
embed_classic_svg!("spades_jack.svg"),
embed_classic_svg!("spades_queen.svg"),
embed_classic_svg!("spades_king.svg"),
];
/// Registers asset sources that must be in place *before* /// Registers asset sources that must be in place *before*
/// `AssetPlugin` is built. /// `AssetPlugin` is built.
/// ///
@@ -181,6 +262,7 @@ pub struct AssetSourcesPlugin;
impl Plugin for AssetSourcesPlugin { impl Plugin for AssetSourcesPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
populate_embedded_dark_theme(app); populate_embedded_dark_theme(app);
populate_embedded_classic_theme(app);
} }
} }
@@ -208,11 +290,44 @@ pub fn dark_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> {
pub fn bundled_theme_url(id: &str) -> Option<&'static str> { pub fn bundled_theme_url(id: &str) -> Option<&'static str> {
match id { match id {
"dark" => Some(DARK_THEME_MANIFEST_URL), "dark" => Some(DARK_THEME_MANIFEST_URL),
"classic" => Some("themes/classic/theme.ron"), "classic" => Some(CLASSIC_THEME_MANIFEST_URL),
_ => None, _ => None,
} }
} }
/// Returns the embedded SVG bytes for a single Classic-theme file
/// (e.g. `"back.svg"` or `"spades_ace.svg"`), or `None` when the
/// filename is not bundled.
pub fn classic_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> {
let suffix = format!("/{filename}");
CLASSIC_THEME_SVGS
.iter()
.find(|(path, _)| path.ends_with(&suffix))
.map(|(_, bytes)| *bytes)
}
/// Pushes every bundled Classic-theme file into the
/// [`EmbeddedAssetRegistry`] under its stable URL.
pub fn populate_embedded_classic_theme(app: &mut App) {
let registry = app
.world_mut()
.get_resource_or_insert_with(EmbeddedAssetRegistry::default);
registry.insert_asset(
std::path::PathBuf::from(CLASSIC_THEME_MANIFEST_PATH),
std::path::Path::new(CLASSIC_THEME_MANIFEST_PATH),
CLASSIC_THEME_MANIFEST_BYTES,
);
for (path, bytes) in CLASSIC_THEME_SVGS {
registry.insert_asset(
std::path::PathBuf::from(*path),
std::path::Path::new(*path),
*bytes,
);
}
}
/// Pushes every bundled Dark-theme file into the /// Pushes every bundled Dark-theme file into the
/// [`EmbeddedAssetRegistry`] under its stable URL. /// [`EmbeddedAssetRegistry`] under its stable URL.
pub fn populate_embedded_dark_theme(app: &mut App) { pub fn populate_embedded_dark_theme(app: &mut App) {
@@ -305,4 +420,52 @@ mod tests {
.expect("dark theme URL must use embedded:// scheme"); .expect("dark theme URL must use embedded:// scheme");
assert_eq!(url_tail, DARK_THEME_MANIFEST_PATH); assert_eq!(url_tail, DARK_THEME_MANIFEST_PATH);
} }
#[test]
fn populate_embedded_classic_theme_runs_without_asset_plugin() {
let mut app = App::new();
populate_embedded_classic_theme(&mut app);
assert!(app
.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some());
}
#[test]
fn embedded_classic_theme_manifest_validates() {
use crate::theme::ThemeManifest;
let manifest: ThemeManifest = ron::de::from_bytes(CLASSIC_THEME_MANIFEST_BYTES)
.expect("classic manifest must parse as RON");
let faces = manifest
.validate()
.expect("classic manifest must list all 52 faces");
assert_eq!(faces.len(), 52);
}
#[test]
fn classic_theme_svg_bytes_finds_back_and_ace_of_spades() {
assert!(
classic_theme_svg_bytes("back.svg").is_some(),
"classic theme must bundle a back.svg"
);
assert!(
classic_theme_svg_bytes("spades_ace.svg").is_some(),
"classic theme must bundle a spades_ace.svg"
);
}
#[test]
fn classic_theme_svg_bytes_returns_none_for_unknown_file() {
assert!(classic_theme_svg_bytes("nope.svg").is_none());
assert!(classic_theme_svg_bytes("").is_none());
}
#[test]
fn classic_theme_url_constant_matches_embedded_path() {
let url_tail = CLASSIC_THEME_MANIFEST_URL
.strip_prefix("embedded://")
.expect("classic theme URL must use embedded:// scheme");
assert_eq!(url_tail, CLASSIC_THEME_MANIFEST_PATH);
}
} }
+20 -10
View File
@@ -15,7 +15,7 @@ use bevy::prelude::*;
use solitaire_core::card::{Rank, Suit}; use solitaire_core::card::{Rank, Suit};
use crate::assets::{ use crate::assets::{
bundled_theme_url, dark_theme_svg_bytes, rasterize_svg, user_theme_dir, bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
}; };
use crate::card_plugin::CardImageSet; use crate::card_plugin::CardImageSet;
use crate::events::StateChangedEvent; use crate::events::StateChangedEvent;
@@ -306,22 +306,18 @@ const PREVIEW_BACK_FILENAME: &str = "back.svg";
/// ///
/// - For the embedded `dark` theme, reads from the in-binary table via /// - For the embedded `dark` theme, reads from the in-binary table via
/// [`dark_theme_svg_bytes`]. No filesystem I/O. /// [`dark_theme_svg_bytes`]. No filesystem I/O.
/// - For bundled non-embedded themes (e.g. `classic`), reads from the /// - For the embedded `classic` theme, reads from the in-binary table via
/// `assets/themes/<id>/` directory. /// [`classic_theme_svg_bytes`]. No filesystem I/O.
/// - For user themes, reads from `<user_theme_dir>/<id>/<filename>`. /// - For user themes, reads from `<user_theme_dir>/<id>/<filename>`.
/// Returns `None` for any I/O failure. /// Returns `None` for any I/O failure.
fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option<Vec<u8>> { fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option<Vec<u8>> {
if theme_id == "dark" { if theme_id == "dark" {
return dark_theme_svg_bytes(filename).map(|b| b.to_vec()); return dark_theme_svg_bytes(filename).map(|b| b.to_vec());
} }
// Bundled non-embedded themes live alongside the binary in assets/. if theme_id == "classic" {
let bundled_path = std::path::Path::new("assets/themes") return classic_theme_svg_bytes(filename).map(|b| b.to_vec());
.join(theme_id)
.join(filename);
if let Ok(bytes) = std::fs::read(&bundled_path) {
return Some(bytes);
} }
// Fall back to user theme dir. // User themes live in the user theme dir.
let path = user_theme_dir().join(theme_id).join(filename); let path = user_theme_dir().join(theme_id).join(filename);
std::fs::read(&path).ok() std::fs::read(&path).ok()
} }
@@ -577,6 +573,20 @@ mod tests {
); );
} }
/// `read_theme_preview_svg_bytes` for the classic theme always returns
/// embedded bytes for the canonical preview pair.
#[test]
fn read_classic_theme_preview_returns_some_for_canonical_files() {
assert!(
read_theme_preview_svg_bytes("classic", PREVIEW_BACK_FILENAME).is_some(),
"classic theme back.svg must be embedded"
);
assert!(
read_theme_preview_svg_bytes("classic", PREVIEW_FACE_FILENAME).is_some(),
"classic theme spades_ace.svg must be embedded"
);
}
/// `ensure_theme_thumbnails` is idempotent: calling it twice with /// `ensure_theme_thumbnails` is idempotent: calling it twice with
/// the same registry must not regenerate or replace already-cached /// the same registry must not regenerate or replace already-cached
/// entries. This guards against the per-frame Update tick churning /// entries. This guards against the per-frame Update tick churning
+3 -3
View File
@@ -100,8 +100,8 @@ fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegist
/// [`user_theme_dir`]. /// [`user_theme_dir`].
pub fn build_registry(user_dir: &Path) -> ThemeRegistry { pub fn build_registry(user_dir: &Path) -> ThemeRegistry {
let mut entries = Vec::new(); let mut entries = Vec::new();
entries.push(classic_entry());
entries.push(dark_entry()); entries.push(dark_entry());
entries.push(classic_entry());
entries.extend(discover_user_themes(user_dir)); entries.extend(discover_user_themes(user_dir));
ThemeRegistry { entries } ThemeRegistry { entries }
} }
@@ -264,8 +264,8 @@ mod tests {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let registry = build_registry(tmp.path()); let registry = build_registry(tmp.path());
assert_eq!(registry.len(), BUNDLED_COUNT); assert_eq!(registry.len(), BUNDLED_COUNT);
assert_eq!(registry.entries[0].id, "classic"); assert_eq!(registry.entries[0].id, "dark");
assert_eq!(registry.entries[1].id, "dark"); assert_eq!(registry.entries[1].id, "classic");
} }
#[test] #[test]
+1 -1
View File
@@ -62,9 +62,9 @@ COPY --from=builder /build/target/release/solitaire_server ./server
# Static web assets are served via ServeDir at runtime from these paths: # Static web assets are served via ServeDir at runtime from these paths:
# /app/solitaire_server/web → /web route # /app/solitaire_server/web → /web route
# /app/assets → /assets route # /app/assets → /assets route
# Card themes (dark + classic) are embedded in the binary; no theme files needed here.
COPY solitaire_server/web ./solitaire_server/web COPY solitaire_server/web ./solitaire_server/web
COPY assets ./assets COPY assets ./assets
COPY solitaire_engine/assets/themes/classic ./assets/themes/classic
ENV SERVER_PORT=8080 ENV SERVER_PORT=8080
EXPOSE 8080 EXPOSE 8080