From 76f0f08ecc43b6c18e44f7290e99b9c6a1b45b71 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 08:58:33 +0200 Subject: [PATCH] feat(ios): add AltStore/SideStore IPA distribution Add nightly iOS build job to tauri-build.yml using tauri ios build --no-sign, upload the unsigned IPA + altstore-source.json to the release, and document the install flow in the README. --- .github/scripts/update-altstore-source.mjs | 59 ++++++++++++++++ .github/workflows/tauri-build.yml | 78 ++++++++++++++++++++++ README.md | 14 ++++ altstore-source.json | 31 +++++++++ 4 files changed, 182 insertions(+) create mode 100644 .github/scripts/update-altstore-source.mjs create mode 100644 altstore-source.json diff --git a/.github/scripts/update-altstore-source.mjs b/.github/scripts/update-altstore-source.mjs new file mode 100644 index 000000000..ebdc5a25c --- /dev/null +++ b/.github/scripts/update-altstore-source.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/* oxlint-disable no-console */ + +// Updates altstore-source.json with a new version entry pointing at a release +// IPA asset, then prints the updated file. Used by .github/workflows/tauri-build.yml. +// +// Usage: update-altstore-source.mjs [description] +// Env: none + +import { readFileSync, writeFileSync } from 'node:fs'; +import process from 'node:process'; + +const [sourcePath, version, build, sizeStr, downloadURL, date, description] = process.argv.slice(2); + +if (!sourcePath || !version || !build || !sizeStr || !downloadURL || !date) { + console.error( + 'Usage: update-altstore-source.mjs [description]' + ); + process.exit(1); +} + +const size = Number(sizeStr); +if (!Number.isInteger(size) || size <= 0) { + console.error(`ipa-size must be a positive integer (got: ${sizeStr})`); + process.exit(1); +} + +const source = JSON.parse(readFileSync(sourcePath, 'utf8')); +const app = source.apps?.[0]; +if (!app) { + console.error('Source JSON has no apps[0] to update.'); + process.exit(1); +} + +// Strip marketplaceID: SideStore rejects PAL-marked sources. +delete app.marketplaceID; + +const entry = { + version, + buildVersion: build, + date, + size, + downloadURL, + localizedDescription: description ?? `v${version}`, +}; + +// Replace an existing entry for this version, otherwise prepend as latest. +const versions = Array.isArray(app.versions) ? app.versions : []; +const idx = versions.findIndex((v) => v.version === version); +if (idx >= 0) versions[idx] = entry; +else versions.unshift(entry); + +// Keep only the most recent 20 versions in the manifest. +app.versions = versions.slice(0, 20); + +writeFileSync(sourcePath, `${JSON.stringify(source, null, 2)}\n`); +console.log( + `Updated ${sourcePath}: ${app.bundleIdentifier} v${version} (${build}) -> ${downloadURL}` +); diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index a9d79903f..ea1db6110 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -369,6 +369,84 @@ jobs: gh release upload "$TAG" "$f" --clobber done + ios: + name: Build iOS (AltStore/SideStore) + needs: setup-release + runs-on: macos-latest + timeout-minutes: 60 + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + env: + TAG: ${{ needs.setup-release.outputs.tag }} + VERSION: ${{ needs.setup-release.outputs.version }} + NODE_OPTIONS: --max-old-space-size=8192 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447e83dd # v6.0.2 + with: + ref: ${{ needs.setup-release.outputs.ref }} + persist-credentials: false + + - name: Setup app + uses: ./.github/actions/setup + + - name: Setup Rust + uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable + with: + toolchain: stable + targets: aarch64-apple-ios + + - name: Cache Rust build + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: src-tauri + + - name: Stamp release version into tauri.conf.json + shell: bash + run: node .github/scripts/set-tauri-version.mjs "$VERSION" + + - name: Build unsigned IPA + id: ipa + shell: bash + run: | + pnpm tauri ios build --no-sign --ci + IPA=$(find src-tauri/gen/apple/build -name '*.ipa' -type f | head -1) + [ -n "$IPA" ] || { echo "IPA not found"; ls -R src-tauri/gen/apple/build 2>/dev/null || true; exit 1; } + echo "size=$(stat -f%z "$IPA")" >> "$GITHUB_OUTPUT" + echo "path=$IPA" >> "$GITHUB_OUTPUT" + echo "name=$(basename "$IPA")" >> "$GITHUB_OUTPUT" + + - name: Attest iOS bundle + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: ${{ steps.ipa.outputs.path }} + + - name: Attach IPA to release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "$TAG" "${{ steps.ipa.outputs.path }}" --clobber + + - name: Update AltStore/SideStore source + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + DOWNLOAD_URL="https://github.com/$REPO/releases/download/$TAG/${{ steps.ipa.outputs.name }}" + DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + if gh release download "$TAG" --pattern 'altstore-source.json' --dir . --clobber 2>/dev/null; then + echo "Using existing altstore-source.json from release $TAG" + else + echo "Using repo altstore-source.json" + fi + node .github/scripts/update-altstore-source.mjs \ + altstore-source.json "$VERSION" "$VERSION" "${{ steps.ipa.outputs.size }}" "$DOWNLOAD_URL" "$DATE" "Sable $VERSION" + gh release upload "$TAG" altstore-source.json --clobber + updater-manifest: name: Publish updater manifest needs: [setup-release, build, android] diff --git a/README.md b/README.md index 1c44efcf1..1246d7b64 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,20 @@ The stable web app is available at [app.sable.moe](https://app.sable.moe/) and t You can also download our desktop app for Windows and Linux from [releases](https://github.com/SableClient/Sable/releases/latest). Release artifacts include build attestations, and desktop installations update automatically. +## iOS (AltStore / SideStore) + +Sable iOS builds are distributed as unsigned IPAs through [AltStore](https://altstore.io) and [SideStore](https://sidestore.io). The nightly build publishes both the IPA and an `altstore-source.json` manifest to the [`nightly` GitHub release](https://github.com/SableClient/Sable/releases/tag/nightly). + +To install: + +1. Set up [AltStore Classic](https://faq.altstore.io/altstore-classic/altserver) or [SideStore](https://docs.sidestore.io) on your device. +2. Add the Sable source: + - AltStore: `altstore://source?url=https://github.com/SableClient/Sable/releases/download/nightly/altstore-source.json` + - SideStore: `sidestore://source?url=https://github.com/SableClient/Sable/releases/download/nightly/altstore-source.json` +3. Install Sable from the source. The IPA is unsigned; AltStore/SideStore re-sign it with your personal development certificate at install time, so apps refresh every 7 days (the standard free-account limitation). + +iOS builds are produced by the `ios` job in [`tauri-build.yml`](.github/workflows/tauri-build.yml) and track the same `dev`/`v*` triggers as desktop builds. + ## Self-hosting You have a few options for self hosting, you can: 1. Run the prebuilt docker container. diff --git a/altstore-source.json b/altstore-source.json new file mode 100644 index 000000000..bb715c6b2 --- /dev/null +++ b/altstore-source.json @@ -0,0 +1,31 @@ +{ + "name": "Sable", + "subtitle": "Sable for iOS", + "description": "Sable iOS nightly builds for AltStore and SideStore.", + "iconURL": "https://raw.githubusercontent.com/SableClient/Sable/dev/src-tauri/icons/icon.png", + "website": "https://github.com/SableClient/Sable", + "tintColor": "#5CA399", + "featuredApps": ["moe.sable.client"], + "apps": [ + { + "name": "Sable", + "bundleIdentifier": "moe.sable.client", + "developerName": "SableClient", + "subtitle": "Sable iOS (nightly)", + "localizedDescription": "Sable for iOS. Nightly builds from dev; unsigned, resigned at install by AltStore/SideStore.", + "iconURL": "https://raw.githubusercontent.com/SableClient/Sable/dev/src-tauri/icons/icon.png", + "tintColor": "#5CA399", + "category": "social", + "screenshots": [], + "versions": [], + "appPermissions": { + "entitlements": ["aps-environment"], + "privacy": { + "NSCameraUsageDescription": "Sable needs camera access for video calls.", + "NSMicrophoneUsageDescription": "Sable needs microphone access for voice and video calls." + } + } + } + ], + "news": [] +}