Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27eed98922 | |||
| f304917d62 | |||
| d49c478efa | |||
| 29f9b9358e | |||
| 9ef5759f40 | |||
| 9c9c0c76d3 | |||
| d4fb9e36a8 | |||
| ab35fcf906 | |||
| 32991301dd | |||
| c5fd928dcb | |||
| f6907671be | |||
| a54fff7257 | |||
| 533bcec2d8 | |||
| ba786f5a09 | |||
| 7ee7cb6d93 | |||
| 14324b09ef | |||
| 124f1f5cf5 | |||
| a6a73b5f36 | |||
| b84fe79806 | |||
| 3248f00d66 | |||
| c680a043ae | |||
| d0ab7ed97b | |||
| 1144a96757 | |||
| ac6668cee7 | |||
| eba1f66b45 | |||
| 90959728b1 | |||
| 8b30f8778b | |||
| d6a7924f14 | |||
| 4db43fb3fb | |||
| 01d6b27e61 | |||
| 3cffbc2c51 | |||
| 2ef25934ac | |||
| bb670d6cc6 | |||
| 76911c57c9 | |||
| 8391235a1a | |||
| 2f3a6b9586 | |||
| 4d20b70809 | |||
| bfadcf0e0d | |||
| 356dbebe57 | |||
| c90c783177 | |||
| bbf4b2c14a | |||
| 62be72e918 | |||
| 1f46785b31 | |||
| 2e5d82f83c |
@@ -12,9 +12,10 @@ on:
|
||||
- '**.md'
|
||||
|
||||
env:
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
ANDROID_SDK: /opt/android-sdk
|
||||
NDK_VERSION: "25.2.9519653"
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
PLATFORM: "android-34"
|
||||
|
||||
jobs:
|
||||
build-apk:
|
||||
@@ -28,39 +29,36 @@ jobs:
|
||||
id: meta
|
||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Android SDK + NDK ──────────────────────────────────────────────
|
||||
# Cache the entire SDK root so subsequent runs skip the ~2 GB download.
|
||||
# ── System dependencies ────────────────────────────────────────────
|
||||
- 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
|
||||
uses: actions/cache@v4
|
||||
id: sdk-cache
|
||||
with:
|
||||
path: ${{ env.ANDROID_SDK_ROOT }}
|
||||
key: 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
|
||||
path: ${{ env.ANDROID_SDK }}
|
||||
key: v2-android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
|
||||
|
||||
- name: Install Android SDK + NDK
|
||||
if: steps.sdk-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||
sudo mkdir -p ${{ env.ANDROID_SDK }}/cmdline-tools
|
||||
curl -sL \
|
||||
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
|
||||
-o /tmp/cmdtools.zip
|
||||
unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools
|
||||
mv /tmp/cmdtools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||
# Accept all SDK licences non-interactively.
|
||||
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \
|
||||
> /dev/null 2>&1 || true
|
||||
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
|
||||
"build-tools;$BUILD_TOOLS_VERSION" \
|
||||
"platforms;android-34" \
|
||||
"ndk;$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"
|
||||
sudo mv /tmp/cmdtools/cmdline-tools ${{ env.ANDROID_SDK }}/cmdline-tools/latest
|
||||
yes | sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
|
||||
--sdk_root=${{ env.ANDROID_SDK }} --licenses > /dev/null 2>&1 || true
|
||||
sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
|
||||
--sdk_root=${{ env.ANDROID_SDK }} \
|
||||
"build-tools;${{ env.BUILD_TOOLS_VERSION }}" \
|
||||
"platforms;${{ env.PLATFORM }}" \
|
||||
"ndk;${{ env.NDK_VERSION }}"
|
||||
|
||||
# ── Rust toolchain ─────────────────────────────────────────────────
|
||||
- name: Install Rust stable
|
||||
@@ -87,12 +85,12 @@ jobs:
|
||||
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: cargo-registry-
|
||||
|
||||
- name: Cache cargo-apk binary
|
||||
- name: Cache cargo-ndk binary
|
||||
uses: actions/cache@v4
|
||||
id: apk-tool-cache
|
||||
id: ndk-tool-cache
|
||||
with:
|
||||
path: ~/.cargo/bin/cargo-apk
|
||||
key: cargo-apk-${{ runner.os }}-stable
|
||||
path: ~/.cargo/bin/cargo-ndk
|
||||
key: cargo-ndk-${{ runner.os }}-stable
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
@@ -101,17 +99,32 @@ jobs:
|
||||
key: android-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
|
||||
restore-keys: android-target-${{ hashFiles('**/Cargo.lock') }}-
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────────────
|
||||
- name: Install cargo-apk
|
||||
if: steps.apk-tool-cache.outputs.cache-hit != 'true'
|
||||
run: cargo install cargo-apk --locked
|
||||
- name: Install cargo-ndk
|
||||
if: steps.ndk-tool-cache.outputs.cache-hit != 'true'
|
||||
run: cargo install cargo-ndk --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
|
||||
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 ───────────────────────────────────────────────────────
|
||||
# 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
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: solitaire-quest-debug-${{ steps.meta.outputs.sha }}
|
||||
path: target/debug/apk/solitaire-quest.apk
|
||||
|
||||
@@ -6,9 +6,10 @@ on:
|
||||
- 'v*.*.*'
|
||||
|
||||
env:
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
ANDROID_SDK: /opt/android-sdk
|
||||
NDK_VERSION: "25.2.9519653"
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
PLATFORM: "android-34"
|
||||
GITEA_API: https://git.aleshym.co/api/v1
|
||||
REPO: funman300/Rusty_Solitare
|
||||
|
||||
@@ -24,40 +25,36 @@ jobs:
|
||||
id: meta
|
||||
run: echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Android SDK + NDK ──────────────────────────────────────────────
|
||||
# Shared cache key with the debug workflow so a warm debug run
|
||||
# saves the ~2 GB SDK download for the release run too.
|
||||
# ── 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
|
||||
uses: actions/cache@v4
|
||||
id: sdk-cache
|
||||
with:
|
||||
path: ${{ env.ANDROID_SDK_ROOT }}
|
||||
key: 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
|
||||
path: ${{ env.ANDROID_SDK }}
|
||||
key: v2-android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
|
||||
|
||||
- name: Install Android SDK + NDK
|
||||
if: steps.sdk-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||
sudo mkdir -p ${{ env.ANDROID_SDK }}/cmdline-tools
|
||||
curl -sL \
|
||||
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
|
||||
-o /tmp/cmdtools.zip
|
||||
unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools
|
||||
mv /tmp/cmdtools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \
|
||||
> /dev/null 2>&1 || true
|
||||
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
|
||||
"build-tools;$BUILD_TOOLS_VERSION" \
|
||||
"platforms;android-34" \
|
||||
"ndk;$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"
|
||||
sudo mv /tmp/cmdtools/cmdline-tools ${{ env.ANDROID_SDK }}/cmdline-tools/latest
|
||||
yes | sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
|
||||
--sdk_root=${{ env.ANDROID_SDK }} --licenses > /dev/null 2>&1 || true
|
||||
sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
|
||||
--sdk_root=${{ env.ANDROID_SDK }} \
|
||||
"build-tools;${{ env.BUILD_TOOLS_VERSION }}" \
|
||||
"platforms;${{ env.PLATFORM }}" \
|
||||
"ndk;${{ env.NDK_VERSION }}"
|
||||
|
||||
# ── Rust toolchain ─────────────────────────────────────────────────
|
||||
- name: Install Rust stable
|
||||
@@ -84,12 +81,12 @@ jobs:
|
||||
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: cargo-registry-
|
||||
|
||||
- name: Cache cargo-apk binary
|
||||
- name: Cache cargo-ndk binary
|
||||
uses: actions/cache@v4
|
||||
id: apk-tool-cache
|
||||
id: ndk-tool-cache
|
||||
with:
|
||||
path: ~/.cargo/bin/cargo-apk
|
||||
key: cargo-apk-${{ runner.os }}-stable
|
||||
path: ~/.cargo/bin/cargo-ndk
|
||||
key: cargo-ndk-${{ runner.os }}-stable
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
@@ -98,48 +95,43 @@ jobs:
|
||||
key: android-release-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
|
||||
restore-keys: android-release-target-${{ hashFiles('**/Cargo.lock') }}-
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────────────
|
||||
- name: Install cargo-apk
|
||||
if: steps.apk-tool-cache.outputs.cache-hit != 'true'
|
||||
run: cargo install cargo-apk --locked
|
||||
- name: Install cargo-ndk
|
||||
if: steps.ndk-tool-cache.outputs.cache-hit != 'true'
|
||||
run: cargo install cargo-ndk --locked
|
||||
|
||||
- name: Build release APK
|
||||
run: cargo apk build --release --package solitaire_app --lib
|
||||
|
||||
# ── Sign ───────────────────────────────────────────────────────────
|
||||
# ── Build & sign with release keystore ─────────────────────────────
|
||||
- name: Decode keystore
|
||||
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > /tmp/solitaire-release.jks
|
||||
|
||||
- name: Align and sign APK
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
UNSIGNED="target/release/apk/solitaire-quest.apk"
|
||||
ALIGNED="/tmp/solitaire-quest-aligned.apk"
|
||||
SIGNED="ferrous-solitaire-${TAG}.apk"
|
||||
secret_len=$(echo -n "${{ secrets.KEYSTORE_BASE64 }}" | wc -c)
|
||||
echo "KEYSTORE_BASE64 secret length: ${secret_len} chars"
|
||||
set +e
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > /tmp/solitaire-release.jks 2>/tmp/b64_err.txt
|
||||
b64_exit=$?
|
||||
set -e
|
||||
size=$(wc -c < /tmp/solitaire-release.jks)
|
||||
echo "base64 exit code: ${b64_exit}, keystore size: ${size} bytes"
|
||||
[ -s /tmp/b64_err.txt ] && echo "base64 error: $(cat /tmp/b64_err.txt)" || true
|
||||
[ "$size" -gt 0 ] || { echo "ERROR: KEYSTORE_BASE64 is empty or invalid base64"; exit 1; }
|
||||
|
||||
"$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOLS_VERSION/zipalign" -v 4 \
|
||||
"$UNSIGNED" "$ALIGNED"
|
||||
- name: Build signed release APK
|
||||
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: release
|
||||
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/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 ────────────────────────────────────────────────────────
|
||||
# ── Publish to Gitea release ───────────────────────────────────────
|
||||
- name: Create Gitea release
|
||||
id: release
|
||||
run: |
|
||||
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}" \
|
||||
-X POST "$GITEA_API/repos/$REPO/releases" \
|
||||
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
|
||||
|
||||
@@ -20,4 +20,4 @@ resources:
|
||||
images:
|
||||
- name: solitaire-server
|
||||
newName: git.aleshym.co/funman300/solitaire-server
|
||||
newTag: 0f650311
|
||||
newTag: 9ef5759f
|
||||
|
||||
Executable
+140
@@ -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"
|
||||
@@ -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>
|
||||
@@ -143,10 +143,10 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub window_geometry: Option<WindowGeometry>,
|
||||
/// 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.
|
||||
/// Older `settings.json` files that stored `"default"` will fall
|
||||
/// back to the dark embedded theme at runtime.
|
||||
/// Older `settings.json` files that stored `"default"` or `"classic"`
|
||||
/// are migrated to `"dark"` by [`Settings::sanitized`].
|
||||
#[serde(default = "default_theme_id")]
|
||||
pub selected_theme_id: String,
|
||||
/// 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 {
|
||||
"classic".to_string()
|
||||
"dark".to_string()
|
||||
}
|
||||
|
||||
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
||||
@@ -395,6 +395,13 @@ impl Settings {
|
||||
/// their respective ranges after deserialization or hand-editing of
|
||||
/// `settings.json`.
|
||||
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 {
|
||||
sfx_volume: self.sfx_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
|
||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||
selected_theme_id,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ pub mod svg_loader;
|
||||
pub mod user_dir;
|
||||
|
||||
pub use sources::{
|
||||
bundled_theme_url, dark_theme_svg_bytes, populate_embedded_dark_theme,
|
||||
register_theme_asset_sources, AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES,
|
||||
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes,
|
||||
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 user_dir::{set_user_theme_dir, user_theme_dir};
|
||||
|
||||
@@ -78,6 +78,20 @@ const DARK_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/dark/them
|
||||
const DARK_THEME_MANIFEST_BYTES: &[u8] =
|
||||
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.
|
||||
macro_rules! embed_dark_svg {
|
||||
($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.
|
||||
const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||
embed_dark_svg!("back.svg"),
|
||||
@@ -145,6 +169,63 @@ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
|
||||
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*
|
||||
/// `AssetPlugin` is built.
|
||||
///
|
||||
@@ -181,6 +262,7 @@ pub struct AssetSourcesPlugin;
|
||||
impl Plugin for AssetSourcesPlugin {
|
||||
fn build(&self, app: &mut 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> {
|
||||
match id {
|
||||
"dark" => Some(DARK_THEME_MANIFEST_URL),
|
||||
"classic" => Some("themes/classic/theme.ron"),
|
||||
"classic" => Some(CLASSIC_THEME_MANIFEST_URL),
|
||||
_ => 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
|
||||
/// [`EmbeddedAssetRegistry`] under its stable URL.
|
||||
pub fn populate_embedded_dark_theme(app: &mut App) {
|
||||
@@ -305,4 +420,52 @@ mod tests {
|
||||
.expect("dark theme URL must use embedded:// scheme");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use bevy::prelude::*;
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
|
||||
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::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
|
||||
/// [`dark_theme_svg_bytes`]. No filesystem I/O.
|
||||
/// - For bundled non-embedded themes (e.g. `classic`), reads from the
|
||||
/// `assets/themes/<id>/` directory.
|
||||
/// - For the embedded `classic` theme, reads from the in-binary table via
|
||||
/// [`classic_theme_svg_bytes`]. No filesystem I/O.
|
||||
/// - For user themes, reads from `<user_theme_dir>/<id>/<filename>`.
|
||||
/// Returns `None` for any I/O failure.
|
||||
fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option<Vec<u8>> {
|
||||
if theme_id == "dark" {
|
||||
return dark_theme_svg_bytes(filename).map(|b| b.to_vec());
|
||||
}
|
||||
// Bundled non-embedded themes live alongside the binary in assets/.
|
||||
let bundled_path = std::path::Path::new("assets/themes")
|
||||
.join(theme_id)
|
||||
.join(filename);
|
||||
if let Ok(bytes) = std::fs::read(&bundled_path) {
|
||||
return Some(bytes);
|
||||
if theme_id == "classic" {
|
||||
return classic_theme_svg_bytes(filename).map(|b| b.to_vec());
|
||||
}
|
||||
// Fall back to user theme dir.
|
||||
// User themes live in the user theme dir.
|
||||
let path = user_theme_dir().join(theme_id).join(filename);
|
||||
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
|
||||
/// the same registry must not regenerate or replace already-cached
|
||||
/// entries. This guards against the per-frame Update tick churning
|
||||
|
||||
@@ -100,8 +100,8 @@ fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegist
|
||||
/// [`user_theme_dir`].
|
||||
pub fn build_registry(user_dir: &Path) -> ThemeRegistry {
|
||||
let mut entries = Vec::new();
|
||||
entries.push(classic_entry());
|
||||
entries.push(dark_entry());
|
||||
entries.push(classic_entry());
|
||||
entries.extend(discover_user_themes(user_dir));
|
||||
ThemeRegistry { entries }
|
||||
}
|
||||
@@ -264,8 +264,8 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(registry.len(), BUNDLED_COUNT);
|
||||
assert_eq!(registry.entries[0].id, "classic");
|
||||
assert_eq!(registry.entries[1].id, "dark");
|
||||
assert_eq!(registry.entries[0].id, "dark");
|
||||
assert_eq!(registry.entries[1].id, "classic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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:
|
||||
# /app/solitaire_server/web → /web 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 assets ./assets
|
||||
COPY solitaire_engine/assets/themes/classic ./assets/themes/classic
|
||||
|
||||
ENV SERVER_PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
Reference in New Issue
Block a user