Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/scripts/update-altstore-source.mjs
Original file line number Diff line number Diff line change
@@ -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 <source.json> <version> <build> <ipa-size> <downloadURL> <date> [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 <source.json> <version> <build> <ipa-size> <downloadURL> <date> [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}`
);
78 changes: 78 additions & 0 deletions .github/workflows/tauri-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions altstore-source.json
Original file line number Diff line number Diff line change
@@ -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": []
}
Loading