Build all apps from Markdown content; Compose Desktop replaces GTK on Linux and Windows

- content/ + scripts/build-content.py produce content.json for every app
- stable lesson IDs with one-time progress migration (Swift, Kotlin)
- shared answer-check tests (tests/answers.json) for Kotlin and Swift
- android/desktop: Compose app with updater, speech and GTK progress import
- CI packages AppImage and Windows zip with ProGuard, runs the tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-09 20:40:37 +02:00
commit c3bc32113b
964 changed files with 57285 additions and 1156 deletions

1127
scripts/build-content.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ set -eu
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
cd "$root"
# The Xcode target extracts content.json itself during the build.
# The Xcode target builds content.json from content/ itself.
export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
COMMIT="${COMMIT:-$(git -C "$root" rev-parse HEAD 2>/dev/null || echo dev)}"
dd="$root/ios/DerivedData"

View file

@ -1,6 +1,8 @@
#!/usr/bin/env bash
# Build a self-contained Linux AppImage (x86_64).
# Requires: make, pkg-config, GTK4, curl, and fuse/appimagetool (pulled as needed).
# Linux AppImage (x86_64) of the Compose desktop app, with its own Java runtime.
# Needs python3, curl and a JDK 17+ that ships jmods (set PACKAGE_JDK, e.g.
# Temurin 21). The asset name and the icon path inside the image are what the
# installer and the updater of older builds expect; keep them.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@ -9,116 +11,52 @@ cd "$ROOT"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ARCH=x86_64 ;;
aarch64|arm64) ARCH=aarch64 ;;
*)
echo "Unsupported architecture: $ARCH" >&2
echo "Only x86_64 AppImages are built, this machine is $ARCH" >&2
exit 1
;;
esac
OUT_NAME="graduately-linux-${ARCH}.AppImage"
OUT="$ROOT/dist/graduately-linux-${ARCH}.AppImage"
APPDIR="$ROOT/dist/AppDir"
TOOLS="$ROOT/dist/tools"
# Real 512×512 PNG (assets/app-icon.png is 1024×1024 and linuxdeploy rejects it).
ICON_512="$ROOT/data/share/icons/hicolor/512x512/apps/maturita.png"
ICON="$ROOT/data/share/icons/hicolor/512x512/apps/maturita.png"
IMAGE="$ROOT/android/desktop/build/compose/binaries/main-release/app/graduately"
echo "==> Building graduately"
make -C "$ROOT" clean
make -C "$ROOT"
echo "==> Building the desktop app"
(cd android && ./gradlew :desktop:createReleaseDistributable --no-daemon)
echo "==> Preparing AppDir"
rm -rf "$APPDIR"
mkdir -p "$APPDIR/usr/bin" \
"$APPDIR/usr/share/applications" \
"$APPDIR/usr/share/icons/hicolor/512x512/apps" \
"$APPDIR/icons/hicolor/512x512/apps" \
"$APPDIR/share"
mkdir -p "$APPDIR/usr/share/icons/hicolor/512x512/apps" "$APPDIR/usr/share/applications"
cp -R "$IMAGE" "$APPDIR/graduately"
cp -f "$ICON" "$APPDIR/maturita.png"
cp -f "$ICON" "$APPDIR/.DirIcon"
cp -f "$ICON" "$APPDIR/usr/share/icons/hicolor/512x512/apps/maturita.png"
cp -f "$ROOT/graduately" "$APPDIR/usr/bin/graduately"
cp -f "$ROOT/data/style.css" "$APPDIR/style.css"
cp -f "$ROOT/data/changelog.txt" "$APPDIR/changelog.txt"
cp -f "$ICON_512" "$APPDIR/icons/hicolor/512x512/apps/maturita.png"
cp -f "$ICON_512" "$APPDIR/usr/share/icons/hicolor/512x512/apps/maturita.png"
cp -f "$ICON_512" "$APPDIR/maturita.png"
cp -R "$ROOT/data/share/." "$APPDIR/share/"
cp -R "$ROOT/data/share/." "$APPDIR/usr/share/"
cp -f "$ROOT/data/share/applications/org.maturita.Maturita.desktop" \
"$APPDIR/usr/share/applications/org.maturita.Maturita.desktop"
# AppImage desktop entry must live at AppDir root and Exec= must be AppRun-compatible.
cp -f "$ROOT/data/share/applications/org.maturita.Maturita.desktop" \
"$APPDIR/org.maturita.Maturita.desktop"
# linuxdeploy expects Icon= without path and a matching PNG at AppDir root.
sed -i.bak 's|^Exec=.*|Exec=graduately|' "$APPDIR/org.maturita.Maturita.desktop"
sed -i.bak 's|^Icon=.*|Icon=maturita|' "$APPDIR/org.maturita.Maturita.desktop"
rm -f "$APPDIR/org.maturita.Maturita.desktop.bak"
cat > "$APPDIR/AppRun" <<'EOF'
#!/bin/sh
HERE="$(dirname "$(readlink -f "$0")")"
exec "$HERE/graduately/bin/graduately" "$@"
EOF
chmod +x "$APPDIR/AppRun"
mkdir -p "$TOOLS"
LINUXDEPLOY="$TOOLS/linuxdeploy-${ARCH}.AppImage"
GTK_PLUGIN="$TOOLS/linuxdeploy-plugin-gtk.sh"
DESKTOP="$ROOT/data/share/applications/org.maturita.Maturita.desktop"
cp -f "$DESKTOP" "$APPDIR/org.maturita.Maturita.desktop"
cp -f "$DESKTOP" "$APPDIR/usr/share/applications/org.maturita.Maturita.desktop"
if [[ ! -x "$LINUXDEPLOY" ]]; then
echo "==> Downloading linuxdeploy"
curl -fsSL -o "$LINUXDEPLOY" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage"
chmod +x "$LINUXDEPLOY"
fi
if [[ ! -f "$GTK_PLUGIN" ]]; then
echo "==> Downloading linuxdeploy-plugin-gtk"
curl -fsSL -o "$GTK_PLUGIN" \
"https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh"
chmod +x "$GTK_PLUGIN"
TOOL="$TOOLS/appimagetool-${ARCH}.AppImage"
if [[ ! -x "$TOOL" ]]; then
echo "==> Downloading appimagetool"
mkdir -p "$TOOLS"
curl -fsSL -o "$TOOL" \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage"
chmod +x "$TOOL"
fi
# Extract linuxdeploy if FUSE is unavailable (common on CI).
run_linuxdeploy() {
if "$LINUXDEPLOY" --appimage-help >/dev/null 2>&1; then
"$LINUXDEPLOY" "$@"
else
local extract_dir="$TOOLS/linuxdeploy-extracted"
rm -rf "$extract_dir"
cd "$TOOLS"
"$LINUXDEPLOY" --appimage-extract >/dev/null
mv squashfs-root "$extract_dir"
cd "$ROOT"
"$extract_dir/AppRun" "$@"
fi
}
echo "==> Bundling GTK libraries into AppDir"
export LDAI_OUTPUT="$ROOT/dist/$OUT_NAME"
export LINUXDEPLOY_OUTPUT_VERSION="${VERSION:-continuous}"
# Place plugin on PATH so linuxdeploy can find it.
export PATH="$TOOLS:$PATH"
run_linuxdeploy \
--appdir "$APPDIR" \
--executable "$APPDIR/usr/bin/graduately" \
--desktop-file "$APPDIR/org.maturita.Maturita.desktop" \
--icon-file "$APPDIR/maturita.png" \
--plugin gtk \
--output appimage
# linuxdeploy may write the AppImage into the cwd under another name;
# only rename when it is not already at the expected path (mv same→same
# fails under `set -e` and was aborting an otherwise successful build).
DEST="$ROOT/dist/$OUT_NAME"
if [[ ! -f "$DEST" ]]; then
shopt -s nullglob
for f in "$ROOT"/*.AppImage "$ROOT/dist"/*.AppImage; do
case "$(basename "$f")" in
linuxdeploy*|appimagetool*) continue ;;
esac
mv -f "$f" "$DEST"
break
done
shopt -u nullglob
fi
if [[ ! -f "$DEST" ]]; then
echo "AppImage was not created" >&2
find "$ROOT" "$ROOT/dist" -maxdepth 2 -name '*.AppImage' -print >&2 || true
exit 1
fi
chmod +x "$DEST"
echo "Created dist/$OUT_NAME"
echo "==> Building AppImage"
rm -f "$OUT"
# CI runners have no FUSE; extract-and-run avoids needing it.
APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$ARCH" "$TOOL" --no-appstream "$APPDIR" "$OUT"
chmod +x "$OUT"
echo "Created ${OUT#"$ROOT"/}"

29
scripts/bundle-windows.sh Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Windows x64 zip of the Compose desktop app, with its own Java runtime.
# Runs in Git Bash or MSYS2. Needs python3 and a JDK 17+ that ships jmods
# (set PACKAGE_JDK, e.g. Temurin 21).
#
# graduately.exe must sit at the root of the zip: the installer and the
# updater of older builds unpack it over the install folder and start it.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
OUT="$ROOT/dist/graduately-windows-x64.zip"
IMAGE="$ROOT/android/desktop/build/compose/binaries/main-release/app/graduately"
echo "==> Building the desktop app"
(cd android && ./gradlew :desktop:createReleaseDistributable --no-daemon)
[[ -f "$IMAGE/graduately.exe" ]] || { echo "graduately.exe missing in $IMAGE" >&2; exit 1; }
echo "==> Zipping"
mkdir -p "$ROOT/dist"
rm -f "$OUT"
if command -v 7z >/dev/null 2>&1; then
(cd "$IMAGE" && 7z a -tzip -mx=9 "$OUT" . >/dev/null)
else
powershell -NoProfile -Command "Compress-Archive -Path '$(cygpath -w "$IMAGE")\\*' -DestinationPath '$(cygpath -w "$OUT")'"
fi
echo "Created ${OUT#"$ROOT"/}"

View file

@ -32,9 +32,9 @@ expect_zip installers/graduately-installer-windows.zip "graduately-installer-win
expect_zip installers/graduately-installer-android.zip "Stahnout Graduately.html"
expect_zip installers/graduately-installer-ios.zip "Jak nainstalovat Graduately.html"
say "Extract content.json"
say "Build content.json"
tmp=$(mktemp)
python3 scripts/extract-android-content.py --out "$tmp" >/dev/null
python3 scripts/build-content.py --out "$tmp" >/dev/null
python3 - "$tmp" <<'PY'
import json, sys
path = sys.argv[1]
@ -54,15 +54,18 @@ PY
ok "content packs"
rm -f "$tmp"
say "GTK binary"
if [[ -x ./graduately ]]; then
./graduately --help >/dev/null
ok "graduately --help"
say "Answer checks"
if (cd android && ./gradlew -q :desktop:test); then
ok "Kotlin"
else
say "Building GTK binary"
make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 2)"
./graduately --help >/dev/null
ok "graduately --help after make"
bad "Kotlin answer checks"
fi
if command -v xcrun >/dev/null 2>&1; then
if ./scripts/test-answers-swift.sh >/dev/null; then
ok "Swift"
else
bad "Swift answer checks"
fi
fi
say "Published builds tip"

12
scripts/test-answers-swift.sh Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Shared answer-check cases (tests/answers.json) against the Swift apps' code.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/build/tests"
mkdir -p "$OUT"
python3 "$ROOT/scripts/build-content.py" --out "$OUT/content.json"
xcrun swiftc -O -o "$OUT/swift-answers" \
"$ROOT/ios/Graduately/Data/Answers.swift" "$ROOT/tests/swift/main.swift"
"$OUT/swift-answers" "$ROOT/tests/answers.json" "$OUT/content.json"