Add iOS App Store build automation (#3451)

This commit is contained in:
Steven Enamakel
2026-06-06 18:03:07 -04:00
committed by GitHub
parent f60def53a4
commit c19b68a94b
31 changed files with 2262 additions and 166 deletions
+300
View File
@@ -0,0 +1,300 @@
---
name: iOS App Store
on:
workflow_dispatch:
inputs:
ref:
description: Git ref to build. Leave empty for the selected branch.
required: false
type: string
default: ""
build_number:
description: CFBundleVersion. Leave empty to use the GitHub run number.
required: false
type: string
default: ""
upload_to_app_store_connect:
description: Upload the exported IPA to App Store Connect/TestFlight.
required: true
type: boolean
default: true
permissions:
contents: read
concurrency:
group: ios-appstore-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-upload:
name: Build and upload iOS IPA
runs-on: macos-26
environment: App Development Production
env:
APP_IDENTIFIER: com.tinyhumansai.openhuman
EXPORT_DIR: app/src-tauri-mobile/gen/apple/build/appstore-export
KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_TEAM_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 1
submodules: false
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.93.0"
targets: aarch64-apple-ios,aarch64-apple-ios-sim
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: |
. -> target
app/src-tauri-mobile -> target
packages/tauri-plugin-ptt -> target
cache-on-failure: true
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "pnpm"
- name: Set up Ruby for Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
- name: Verify Xcode SDK
run: |
xcodebuild -version
xcodebuild -showsdks
- name: Install Fastlane
run: gem install fastlane multi_json -N
- name: Install dependencies
run: bash scripts/ci-cancel-aware.sh pnpm install --frozen-lockfile
- name: Validate signing secrets
shell: bash
env:
CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }}
PROFILE_BASE64: ${{ secrets.IOS_APPSTORE_PROVISIONING_PROFILE_BASE64 }}
run: |
set -euo pipefail
: "${TEAM_ID:?Missing secret APPLE_TEAM_ID}"
: "${KEYCHAIN_PASSWORD:?Missing secret IOS_KEYCHAIN_PASSWORD}"
: "${CERTIFICATE_BASE64:?Missing secret IOS_DISTRIBUTION_CERTIFICATE_BASE64}"
: "${CERTIFICATE_PASSWORD:?Missing secret IOS_DISTRIBUTION_CERTIFICATE_PASSWORD}"
: "${PROFILE_BASE64:?Missing secret IOS_APPSTORE_PROVISIONING_PROFILE_BASE64}"
- name: Import App Store signing certificate
shell: bash
env:
CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/openhuman-ios-signing.keychain-db"
CERT_PATH="$RUNNER_TEMP/ios_distribution.p12"
echo "$CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" \
-P "$CERTIFICATE_PASSWORD" \
-A \
-t cert \
-f pkcs12 \
-k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security default-keychain -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Install App Store provisioning profile
id: profile
shell: bash
env:
PROFILE_BASE64: ${{ secrets.IOS_APPSTORE_PROVISIONING_PROFILE_BASE64 }}
run: |
set -euo pipefail
PROFILE_PATH="$RUNNER_TEMP/openhuman_appstore.mobileprovision"
PROFILE_PLIST="$RUNNER_TEMP/openhuman_appstore.plist"
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
echo "$PROFILE_BASE64" | base64 --decode > "$PROFILE_PATH"
security cms -D -i "$PROFILE_PATH" > "$PROFILE_PLIST"
PROFILE_UUID="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$PROFILE_PLIST")"
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$PROFILE_PLIST")"
PROFILE_APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' "$PROFILE_PLIST")"
cp "$PROFILE_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision"
echo "uuid=$PROFILE_UUID" >> "$GITHUB_OUTPUT"
echo "name=$PROFILE_NAME" >> "$GITHUB_OUTPUT"
echo "[ios-appstore] installed provisioning profile name=$PROFILE_NAME uuid=$PROFILE_UUID app_id=$PROFILE_APP_ID"
- name: Resolve build metadata
id: metadata
shell: bash
run: |
set -euo pipefail
VERSION="$(node -p "require('./app/src-tauri-mobile/tauri.conf.json').version")"
BUILD_NUMBER="${{ inputs.build_number }}"
if [[ -z "$BUILD_NUMBER" ]]; then
BUILD_NUMBER="${GITHUB_RUN_NUMBER}"
fi
[[ "$BUILD_NUMBER" =~ ^[0-9A-Za-z.-]+$ ]] || {
echo "Invalid build_number: $BUILD_NUMBER"
exit 1
}
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
echo "[ios-appstore] version=$VERSION build_number=$BUILD_NUMBER"
- name: Build web assets and generate iOS project
shell: bash
env:
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
run: |
set -euo pipefail
bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app run build:app
TEAM_ID="$TEAM_ID" bash scripts/ios-init.sh
mkdir -p app/src-tauri-mobile/gen/apple/assets
rsync -a --delete app/dist/ app/src-tauri-mobile/gen/apple/assets/
- name: Archive iOS app
shell: bash
env:
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
BUILD_NUMBER: ${{ steps.metadata.outputs.build_number }}
MARKETING_VERSION: ${{ steps.metadata.outputs.version }}
PROFILE_NAME: ${{ steps.profile.outputs.name }}
run: |
set -euo pipefail
ARCHIVE_PATH="app/src-tauri-mobile/gen/apple/build/openhuman-mobile_iOS.xcarchive"
INFO_PLIST="app/src-tauri-mobile/gen/apple/openhuman-mobile_iOS/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $MARKETING_VERSION" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $MARKETING_VERSION" "$INFO_PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $BUILD_NUMBER" "$INFO_PLIST"
xcodebuild \
-workspace app/src-tauri-mobile/gen/apple/openhuman-mobile.xcodeproj/project.xcworkspace \
-scheme openhuman-mobile_iOS \
-configuration release \
-sdk iphoneos \
-destination "generic/platform=iOS" \
-archivePath "$ARCHIVE_PATH" \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER="$PROFILE_NAME" \
MARKETING_VERSION="$MARKETING_VERSION" \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
archive
- name: Export IPA
id: export
shell: bash
env:
PROFILE_NAME: ${{ steps.profile.outputs.name }}
run: |
set -euo pipefail
ARCHIVE_PATH="app/src-tauri-mobile/gen/apple/build/openhuman-mobile_iOS.xcarchive"
EXPORT_OPTIONS="$RUNNER_TEMP/openhuman-export-options.plist"
mkdir -p "$EXPORT_DIR"
cat > "$EXPORT_OPTIONS" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>manual</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>provisioningProfiles</key>
<dict>
<key>$APP_IDENTIFIER</key>
<string>$PROFILE_NAME</string>
</dict>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
PLIST
xcodebuild \
-exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
-allowProvisioningUpdates
IPA_PATH="$(find "$EXPORT_DIR" -maxdepth 1 -name '*.ipa' -print -quit)"
test -n "$IPA_PATH"
echo "ipa_path=$IPA_PATH" >> "$GITHUB_OUTPUT"
echo "[ios-appstore] exported IPA at $IPA_PATH"
- name: Upload IPA to App Store Connect
if: inputs.upload_to_app_store_connect
shell: bash
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
IPA_PATH: ${{ steps.export.outputs.ipa_path }}
run: |
set -euo pipefail
: "${ASC_KEY_ID:?Missing secret APP_STORE_CONNECT_API_KEY_ID}"
: "${ASC_ISSUER_ID:?Missing secret APP_STORE_CONNECT_ISSUER_ID}"
: "${ASC_KEY_BASE64:?Missing secret APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64}"
ASC_KEY_PATH="$RUNNER_TEMP/AuthKey_${ASC_KEY_ID}.p8"
ASC_API_KEY_JSON="$RUNNER_TEMP/appstoreconnect-api-key.json"
echo "$ASC_KEY_BASE64" | base64 --decode > "$ASC_KEY_PATH"
export ASC_KEY_PATH
ruby -rjson -e 'puts JSON.pretty_generate({
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key: File.read(ENV.fetch("ASC_KEY_PATH")),
duration: 1200,
in_house: false
})' > "$ASC_API_KEY_JSON"
fastlane deliver \
--api_key_path "$ASC_API_KEY_JSON" \
--app_identifier "$APP_IDENTIFIER" \
--ipa "$IPA_PATH" \
--platform ios \
--skip_metadata true \
--skip_screenshots true \
--skip_app_version_update true \
--run_precheck_before_submit false \
--submit_for_review false \
--force true
- name: Upload IPA artifact
if: always() && steps.export.outputs.ipa_path != ''
uses: actions/upload-artifact@v4
with:
name: openhuman-ios-ipa
path: ${{ steps.export.outputs.ipa_path }}
if-no-files-found: error
- name: Upload archive dSYMs
if: always()
uses: actions/upload-artifact@v4
with:
name: openhuman-ios-dsyms
path: app/src-tauri-mobile/gen/apple/build/openhuman-mobile_iOS.xcarchive/dSYMs
if-no-files-found: warn
+9
View File
@@ -90,6 +90,8 @@ tauri.key
tauri.key.pub
/target/
src-tauri/target/
app/src-tauri-mobile/gen/
app/src-tauri-mobile/target/
.target-codex/
workflow
@@ -113,3 +115,10 @@ test-map.md
.claude/skills/
.codex-tmp
*.enc
# Apple signing and App Store Connect credentials
AuthKey_*.p8
*.mobileprovision
*.certSigningRequest
*.p12
distribution.cer
+585 -143
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -24,7 +24,7 @@ tauri-build = { version = "2", features = [] }
# Stock upstream Tauri — no vendored CEF runtime. The mobile host renders via
# WKWebView (iOS) / WebView (the Tauri default), not Chromium. CSP and the
# React app are identical to desktop; only the host process is different.
tauri = { version = "2.10", default-features = false, features = [
tauri = { version = "=2.10.3", default-features = false, features = [
"common-controls-v6",
"devtools",
"unstable",
+1 -7
View File
@@ -11,7 +11,7 @@ compile_error!(
"openhuman-mobile only supports iOS and Android. Use app/src-tauri for desktop."
);
use tauri::{AppHandle, Manager, Runtime};
use tauri::{AppHandle, Runtime};
/// Tauri command: terminate the app cleanly. Used by the Settings page
/// "Sign out / forget device" flow when the user wants to back out of a
@@ -34,12 +34,6 @@ pub fn run() {
// See packages/tauri-plugin-ptt/src/lib.rs.
.plugin(tauri_plugin_ptt::init())
.invoke_handler(tauri::generate_handler![app_quit])
.setup(|app| {
if let Some(main) = app.get_webview_window("main") {
let _ = main.show();
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running mobile tauri application");
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenHuman",
"version": "0.57.18",
"identifier": "com.openhuman.app",
"identifier": "com.tinyhumansai.openhuman",
"build": {
"beforeDevCommand": "pnpm --filter openhuman-app run dev",
"devUrl": "http://localhost:1420",
+64 -12
View File
@@ -26,17 +26,17 @@ Run the helper script from the repo root. It calls `tauri ios init` with the cor
bash scripts/ios-init.sh
```
`tauri ios init` scaffolds `app/src-tauri/gen/apple/`. That directory is **gitignored** (it contains bundle-identifier-specific Xcode project files that differ per developer account).
`tauri ios init` scaffolds `app/src-tauri-mobile/gen/apple/`. That directory is **gitignored** (it contains bundle-identifier-specific Xcode project files that differ per developer account).
### Info.plist privacy keys
`tauri ios init` creates a generated `Info.plist` at:
```
app/src-tauri/gen/apple/<bundle-id>_iOS/Info.plist
app/src-tauri-mobile/gen/apple/<bundle-id>_iOS/Info.plist
```
You must copy the three privacy keys from `app/src-tauri/Info.ios.plist` into that generated file before building:
`scripts/ios-init.sh` injects these privacy keys into the generated plist:
```xml
<key>NSCameraUsageDescription</key>
@@ -49,10 +49,6 @@ You must copy the three privacy keys from `app/src-tauri/Info.ios.plist` into th
<string>OpenHuman uses on-device speech recognition to transcribe your voice messages.</string>
```
**Option A (recommended for now):** Manual copy after each `tauri ios init` run.
**Option B (automate in a follow-up PR):** Set the `bundle.iOS.template` key in `app/src-tauri/tauri.conf.json` to point at a hand-crafted `Info.plist` template once Tauri v2 stabilises its iOS template pipeline. Until that happens, Option A is simpler and less brittle.
---
## Development workflow
@@ -81,6 +77,63 @@ pnpm tauri:ios:build
---
## App Store Connect delivery
`.github/workflows/ios-appstore.yml` builds a signed `iphoneos` archive, exports an IPA, uploads the IPA to App Store Connect/TestFlight with `altool`, and stores the IPA + dSYMs as GitHub Actions artifacts.
Run it from GitHub Actions > iOS App Store. Inputs:
- `ref` -- optional git ref to build.
- `build_number` -- optional `CFBundleVersion`; defaults to `github.run_number`.
- `upload_to_app_store_connect` -- set `false` for a signed archive/export dry run.
Required GitHub environment: `App-Store`.
Required secrets:
- `APPLE_TEAM_ID` -- Apple Developer Team ID.
- `IOS_KEYCHAIN_PASSWORD` -- temporary CI keychain password.
- `IOS_DISTRIBUTION_CERTIFICATE_BASE64` -- base64-encoded `.p12` Apple Distribution certificate.
- `IOS_DISTRIBUTION_CERTIFICATE_PASSWORD` -- password for that `.p12`.
- `IOS_APPSTORE_PROVISIONING_PROFILE_BASE64` -- base64-encoded App Store provisioning profile for `com.tinyhumansai.openhuman`.
- `APP_STORE_CONNECT_API_KEY_ID` -- App Store Connect API key ID.
- `APP_STORE_CONNECT_ISSUER_ID` -- App Store Connect issuer ID.
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64` -- base64-encoded `AuthKey_<key id>.p8`.
Local encoding helpers:
```bash
base64 -i ios_distribution.p12 | pbcopy
base64 -i OpenHuman_AppStore.mobileprovision | pbcopy
base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy
```
The workflow uploads a build to App Store Connect. It does not submit the build for App Review; that remains a deliberate App Store Connect action.
### Local upload script
After downloading an App Store provisioning profile and App Store Connect API key, you can build/export/upload from this Mac:
```bash
TEAM_ID=XXXXXXXXXX \
IOS_APPSTORE_PROVISIONING_PROFILE_PATH=/path/to/OpenHuman_AppStore.mobileprovision \
ASC_KEY_ID=XXXXXXXXXX \
ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
ASC_KEY_PATH=/path/to/AuthKey_XXXXXXXXXX.p8 \
UPLOAD=1 \
scripts/ios-appstore-upload.sh
```
Use `UPLOAD=0` to stop after IPA export.
### Updating without a new App Store build
iOS cannot self-update native code or replace the installed app binary outside App Store/TestFlight distribution. For OpenHuman, this means changes to Rust, Tauri plugins, native permissions, bundled frontend code, or the app shell need a new reviewed build.
Safe server-side updates include model/provider configuration, feature flags, prompt/content changes, remote data, and backend behavior that the shipped client already knows how to render. Be conservative with remote JavaScript or plugin-style features: Apple allows some software/content delivered outside the binary under specific rules, but it must stay within App Review limits and must not expose native platform APIs without permission.
---
## Pairing flow
```
@@ -109,6 +162,7 @@ Desktop iOS
```
Transport selection (handled by `TransportManager`):
1. LAN HTTP -- fast, zero-latency, requires same network.
2. Socket.io tunnel -- E2E encrypted via XChaCha20-Poly1305 over X25519 key agreement.
3. Cloud HTTP -- fallback when LAN and tunnel are unreachable.
@@ -130,20 +184,18 @@ Transport selection (handled by `TransportManager`):
- Single backend instance only (no multi-region failover).
- No APNs push notifications -- app must be foregrounded for real-time delivery.
- Event-driven pairing detection on the desktop side uses 2-second polling until an SSE/socket event bridge lands.
- Xcode signing must be set manually in the generated project (no CI automation yet).
---
## CI
The `.github/workflows/ios-compile.yml` workflow runs on every PR that touches iOS-related paths. It provides:
The `.github/workflows/ios-compile.yml` workflow runs as an iOS compile sanity check. It provides:
- **Hard gate:** `cargo check` on the host target for `app/src-tauri` and `packages/tauri-plugin-ptt`.
- **Hard gate:** `cargo check` on the iOS target for `app/src-tauri-mobile` and a host-target check for `packages/tauri-plugin-ptt`.
- **Hard gate:** TypeScript compile (`pnpm compile`).
- **Hard gate:** iOS-related Vitest suites.
- **Soft gate (`continue-on-error: true`):** `cargo check --target aarch64-apple-ios` -- this catches gross API breakage but may fail on third-party C deps that need full Xcode. Failures are flagged but do not block merge.
Full iOS builds (simulator + device) require macOS runners with Xcode installed. This is tracked as a follow-up to this PR.
Full signed App Store builds run through `.github/workflows/ios-appstore.yml`.
---
+24
View File
@@ -0,0 +1,24 @@
# App Review Information Draft
Fastlane uploads `fastlane/metadata/review_information/` automatically when that directory exists. Keep draft review details outside the Fastlane metadata tree until the real review contact email and phone number are ready.
## Contact
- First name: Steven
- Last name: Enamakel
- Email: TODO
- Phone: TODO
## Notes
OpenHuman for iPhone is a companion app for the OpenHuman desktop runtime.
To review the app:
1. Install and open the submitted iOS build.
2. Install and open the OpenHuman desktop app on macOS, Windows, or Linux.
3. In the desktop app, open Settings > Devices and choose Pair phone.
4. Use the iPhone app to scan the pairing QR code.
5. After pairing, test text chat and push-to-talk voice input from the iPhone.
The iOS app requires camera permission for QR pairing and microphone permission for push-to-talk voice input. It does not function as a standalone assistant without a paired OpenHuman desktop runtime.
+104
View File
@@ -0,0 +1,104 @@
# App Store Listing
This is the prepared product-page kit for the OpenHuman iOS companion app.
## App Store Connect Fields
- App name: `OpenHuman`
- Subtitle: `AI companion for your desktop`
- Bundle ID: `com.tinyhumansai.openhuman`
- SKU: `com.tinyhumansai.openhuman`
- Primary category: `Productivity`
- Secondary category: `Utilities`
- Copyright: `2026 Tiny Humans AI`
- Support URL: `https://tinyhumans.ai/openhuman`
- Marketing URL: `https://tinyhumans.ai/openhuman`
- Privacy Policy URL: `https://tinyhumans.gitbook.io/openhuman/legal/privacy-policy`
Metadata files live in `fastlane/metadata/en-US/`.
## Description
Use `fastlane/metadata/en-US/description.txt`.
## Keywords
Use `fastlane/metadata/en-US/keywords.txt`.
## App Icon
The App Store icon is already included in the iOS build:
- `app/src-tauri-mobile/icons/store/appstore.png`
- `app/src-tauri-mobile/icons/ios/AppIcon.appiconset/1024.png`
Both are 1024 x 1024 PNG assets.
## Screenshots
Generate the 6.9-inch iPhone screenshot set:
```bash
pnpm --dir app exec node ../scripts/ios-appstore-assets.mjs
```
Upload the generated files from:
```text
fastlane/screenshots/en-US/
```
Use these in App Store Connect under the iPhone 6.9-inch display screenshot slot.
You can also push the generated screenshots with Fastlane:
```bash
ASC_KEY_ID=9KD934428C \
ASC_ISSUER_ID=69a6de8b-cc07-47e3-e053-5b8c7c11a4d1 \
ASC_KEY_PATH=/Users/enamakel/Downloads/AuthKey_9KD934428C.p8 \
ASC_APP_VERSION=1.0 \
fastlane ios push_screenshots
```
Metadata can be pushed with the App Store Connect API helper:
```bash
ASC_KEY_ID=9KD934428C \
ASC_ISSUER_ID=69a6de8b-cc07-47e3-e053-5b8c7c11a4d1 \
ASC_KEY_PATH=/Users/enamakel/Downloads/AuthKey_9KD934428C.p8 \
ASC_APP_ID=6761229174 \
scripts/ios-appstore-metadata.mjs
```
## App Review Notes
Use `fastlane/metadata/review_information/notes.txt`.
Before submitting, replace the TODO contact fields in:
- `fastlane/metadata/review_information/email_address.txt`
- `fastlane/metadata/review_information/phone_number.txt`
## Privacy Answers Draft
Use this as the starting point for App Privacy in App Store Connect:
- Camera: used to scan the desktop pairing QR code.
- Microphone: used for push-to-talk voice messages.
- Speech recognition: used to transcribe voice messages.
- Identifiers/session data: pairing/session data may be used to connect the phone to the paired OpenHuman desktop runtime.
- User content: messages and voice transcripts are sent to the paired OpenHuman runtime to provide assistant responses.
Before submitting, make sure the App Privacy answers match the production backend/runtime behavior.
## Current Apple Requirements Checked
Apple currently requires one to ten screenshots for each platform localization, accepts `.jpeg`, `.jpg`, and `.png`, and allows high-resolution iPhone screenshots to scale down to smaller sizes. Apple lists the 6.9-inch portrait sizes as accepted high-resolution iPhone screenshot sizes.
Apple currently limits:
- App name: 30 characters
- Subtitle: 30 characters
- Promotional text: 170 characters
- Description: 4000 characters
- Keywords: 100 bytes
+47
View File
@@ -0,0 +1,47 @@
# OpenHuman iOS Privacy Policy Draft
Last updated: June 6, 2026
OpenHuman for iPhone is a companion app for the OpenHuman desktop runtime. It lets you pair your phone with your desktop OpenHuman app, send text messages, and use push-to-talk voice input.
The App Store Connect listing uses the published TinyHumans privacy policy:
https://tinyhumans.gitbook.io/openhuman/legal/privacy-policy
This draft is retained as iOS-specific reference material for review notes and privacy-answer preparation.
## Data The App Uses
OpenHuman for iPhone may process:
- Pairing data, such as a short-lived QR pairing token and device connection identifiers.
- Messages you send to your paired OpenHuman desktop runtime.
- Voice transcripts created from push-to-talk input.
- Basic connection state needed to route requests to the paired desktop runtime.
## Permissions
The iOS app requests:
- Camera access to scan the desktop pairing QR code.
- Microphone access for push-to-talk voice messages.
- Speech recognition access to transcribe push-to-talk messages.
## How Data Is Used
Data is used to:
- Connect your phone to your paired OpenHuman desktop runtime.
- Send your messages and voice transcripts to the paired runtime.
- Receive assistant responses from the paired runtime.
- Maintain connection health while the app is in use.
## Desktop Runtime
The iPhone app is not a standalone assistant. Your long-term workspace, memory, configuration, and integrations are managed by your OpenHuman desktop setup. The exact data handling behavior depends on how you configure OpenHuman desktop, including whether you use managed services, local providers, or custom providers.
## Contact
For support, visit:
https://tinyhumans.ai/openhuman
+3
View File
@@ -0,0 +1,3 @@
app_identifier("com.tinyhumansai.openhuman")
apple_id("6761229174")
team_id("V76768QVFE")
+57
View File
@@ -0,0 +1,57 @@
default_platform(:ios)
platform :ios do
desc "Push OpenHuman iOS App Store screenshots without uploading a binary"
lane :push_screenshots do
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_PATH"),
duration: 1200,
in_house: false
)
deliver(
api_key: api_key,
app_identifier: "com.tinyhumansai.openhuman",
app_version: ENV.fetch("ASC_APP_VERSION", "1.0"),
platform: "ios",
metadata_path: "fastlane/metadata",
screenshots_path: "fastlane/screenshots",
skip_binary_upload: true,
skip_metadata: true,
skip_app_version_update: true,
overwrite_screenshots: true,
submit_for_review: false,
run_precheck_before_submit: false,
force: true
)
end
desc "Push OpenHuman iOS App Store metadata and screenshots without uploading a binary"
lane :push_metadata do
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_PATH"),
duration: 1200,
in_house: false
)
deliver(
api_key: api_key,
app_identifier: "com.tinyhumansai.openhuman",
app_version: ENV.fetch("ASC_APP_VERSION", "1.0"),
platform: "ios",
metadata_path: "fastlane/metadata",
screenshots_path: "fastlane/screenshots",
skip_binary_upload: true,
skip_metadata: ENV["ASC_FASTLANE_SKIP_METADATA"] == "1",
skip_app_version_update: true,
overwrite_screenshots: true,
submit_for_review: false,
run_precheck_before_submit: false,
force: true
)
end
end
+40
View File
@@ -0,0 +1,40 @@
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
## iOS
### ios push_screenshots
```sh
[bundle exec] fastlane ios push_screenshots
```
Push OpenHuman iOS App Store screenshots without uploading a binary
### ios push_metadata
```sh
[bundle exec] fastlane ios push_metadata
```
Push OpenHuman iOS App Store metadata and screenshots without uploading a binary
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
+1
View File
@@ -0,0 +1 @@
2026 Tiny Humans AI
+29
View File
@@ -0,0 +1,29 @@
OpenHuman for iPhone is the mobile companion for the OpenHuman desktop app.
Pair your phone with OpenHuman on your computer, then keep the conversation going from your pocket. Ask questions, send quick voice notes, and stay connected to the same assistant that understands your desktop context, memory, and connected tools.
What you can do:
- Pair securely with your OpenHuman desktop app by scanning a QR code
- Chat by text from your iPhone
- Use push-to-talk voice input for quick messages
- Hear spoken replies when voice mode is enabled
- Keep your assistant anchored to the desktop core that owns your memory and integrations
OpenHuman is built for people who want an AI assistant that can remember, reason, and work with the tools they already use. The iPhone app is intentionally lightweight: it is a remote control and conversation surface for your desktop OpenHuman runtime.
Privacy and control:
- The mobile app connects to your paired desktop session
- Pairing uses a short-lived QR code
- Your long-term workspace, memory, and integration state remain managed by your OpenHuman desktop setup
- Camera access is used to scan the pairing QR code
- Microphone access is used only for push-to-talk voice messages
Requirements:
- OpenHuman desktop app
- A paired desktop session
- Network or tunnel access between your phone and the desktop runtime
OpenHuman is in active development. Feedback is welcome through the OpenHuman support and community channels.
+1
View File
@@ -0,0 +1 @@
AI assistant,voice chat,desktop companion,memory,agents,productivity,automation,tools
@@ -0,0 +1 @@
https://tinyhumans.ai/openhuman
+1
View File
@@ -0,0 +1 @@
OpenHuman
+1
View File
@@ -0,0 +1 @@
https://tinyhumans.gitbook.io/openhuman/legal/privacy-policy
@@ -0,0 +1 @@
Pair with OpenHuman on desktop to chat by voice or text from your phone while memory, tools, and integrations stay anchored to your computer.
@@ -0,0 +1 @@
Initial iPhone companion release for pairing with OpenHuman desktop, sending text messages, and using push-to-talk voice input.
+1
View File
@@ -0,0 +1 @@
AI companion for your desktop
+1
View File
@@ -0,0 +1 @@
https://tinyhumans.ai/openhuman
Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+5
View File
@@ -9,6 +9,11 @@ const COMMANDS: &[&str] = &[
];
fn main() {
std::env::set_var(
"IPHONEOS_DEPLOYMENT_TARGET",
std::env::var("IPHONEOS_DEPLOYMENT_TARGET").unwrap_or_else(|_| "16.0".to_string()),
);
tauri_plugin::Builder::new(COMMANDS)
.ios_path("ios")
.try_build()
+476
View File
@@ -0,0 +1,476 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const requireFromApp = createRequire(path.join(rootDir, "app/package.json"));
const { chromium } = requireFromApp("@playwright/test");
const outDir = path.join(rootDir, "fastlane/screenshots/en-US");
const width = 1320;
const height = 2868;
async function pngDataUri(relativePath) {
const buffer = await readFile(path.join(rootDir, relativePath));
return `data:image/png;base64,${buffer.toString("base64")}`;
}
function escapeHtml(value) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function shell(content) {
return `
<div class="phone">
<div class="status"><span>9:41</span><span>5G</span></div>
${content}
<div class="home"></div>
</div>
`;
}
function pairScreen() {
return shell(`
<section class="pair">
<div class="qr-mark">
<div></div><div></div><div></div><span></span>
</div>
<h2>Pair with your desktop</h2>
<p>Scan the QR code from OpenHuman on your computer and connect your phone in seconds.</p>
<button>Scan QR code</button>
<ol>
<li>Open OpenHuman on desktop</li>
<li>Go to Settings > Devices</li>
<li>Tap Pair phone to show QR</li>
</ol>
</section>
`);
}
function chatScreen() {
return shell(`
<section class="chat">
<header>
<small>Connected to</small>
<strong>Desktop</strong>
</header>
<div class="avatar">
<div class="face">
<span></span><span></span><i></i>
</div>
</div>
<div class="messages">
<p class="assistant">I found the latest thread context from your desktop workspace.</p>
<p class="user">Summarize what needs my attention.</p>
<p class="assistant">You have two follow-ups, one meeting note, and a draft ready to send.</p>
</div>
<footer>
<div class="mic"></div>
<div class="input">Type a message...</div>
<div class="send"></div>
</footer>
</section>
`);
}
function privacyScreen() {
return shell(`
<section class="privacy">
<div class="lock">
<span></span>
</div>
<h2>Anchored to your desktop</h2>
<p>Your phone is a companion surface. Memory, tools, and integrations stay with the OpenHuman runtime you paired.</p>
<div class="rows">
<div><strong>Short-lived QR pairing</strong><span>Connect intentionally</span></div>
<div><strong>Push-to-talk voice</strong><span>Speak when you choose</span></div>
<div><strong>Desktop-owned context</strong><span>Use the assistant you already set up</span></div>
</div>
</section>
`);
}
function html({ title, kicker, body, iconUri, wordmarkUri }) {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
* { box-sizing: border-box; }
html, body { margin: 0; width: ${width}px; height: ${height}px; overflow: hidden; }
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #10131a;
color: white;
}
.shot {
position: relative;
width: ${width}px;
height: ${height}px;
padding: 122px 92px 108px;
background:
radial-gradient(circle at 20% 14%, rgba(74, 131, 221, 0.42), transparent 34%),
linear-gradient(160deg, #121720 0%, #111827 48%, #18231f 100%);
}
.brand { display: flex; align-items: center; gap: 22px; height: 82px; }
.brand img.icon { width: 82px; height: 82px; border-radius: 22px; }
.brand img.wordmark { width: 330px; height: auto; }
.copy { margin-top: 104px; width: 100%; }
.kicker {
color: #9abff9;
font-size: 34px;
font-weight: 700;
letter-spacing: 0;
margin-bottom: 26px;
}
h1 {
font-family: "Cabinet Grotesk", Inter, -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 86px;
line-height: 0.96;
letter-spacing: 0;
margin: 0;
max-width: 1000px;
}
.subtitle {
margin-top: 34px;
color: rgba(255, 255, 255, 0.76);
font-size: 38px;
line-height: 1.3;
max-width: 930px;
}
.phone {
position: absolute;
left: 50%;
bottom: 94px;
transform: translateX(-50%);
width: 780px;
height: 1510px;
border-radius: 88px;
background: #0f1117;
border: 18px solid #202735;
box-shadow: 0 52px 120px rgba(0, 0, 0, 0.48);
overflow: hidden;
}
.status {
height: 78px;
padding: 24px 54px 0;
display: flex;
justify-content: space-between;
font-size: 24px;
color: rgba(255, 255, 255, 0.76);
}
.home {
position: absolute;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
width: 210px;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.42);
}
.pair, .privacy {
min-height: calc(100% - 78px);
padding: 230px 58px 110px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.qr-mark {
width: 154px;
height: 154px;
border-radius: 34px;
background: #4a83dd;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 28px;
box-shadow: 0 22px 54px rgba(74, 131, 221, 0.36);
}
.qr-mark div, .qr-mark span {
background: white;
border-radius: 12px;
opacity: 0.92;
}
.pair h2, .privacy h2 {
font-size: 48px;
line-height: 1.06;
margin: 54px 0 20px;
letter-spacing: 0;
}
.pair p, .privacy p {
margin: 0;
font-size: 27px;
line-height: 1.42;
color: rgba(255, 255, 255, 0.62);
}
.pair button {
margin-top: 70px;
width: 100%;
height: 96px;
border: 0;
border-radius: 26px;
background: #4a83dd;
color: white;
font-size: 30px;
font-weight: 700;
}
.pair ol {
margin: 78px 0 0;
padding: 0;
list-style: none;
width: 100%;
text-align: left;
display: grid;
gap: 22px;
}
.pair li {
padding: 26px 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.78);
font-size: 25px;
}
.chat header {
height: 112px;
padding: 16px 34px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
justify-content: center;
}
.chat small {
color: rgba(255, 255, 255, 0.42);
font-size: 20px;
text-transform: uppercase;
}
.chat strong { margin-top: 8px; font-size: 28px; }
.avatar {
height: 500px;
display: flex;
align-items: center;
justify-content: center;
}
.face {
width: 330px;
height: 330px;
border-radius: 50%;
background: linear-gradient(145deg, #8bb4f4, #4a83dd 58%, #6cbf9a);
position: relative;
box-shadow: 0 26px 76px rgba(74, 131, 221, 0.42);
}
.face span {
position: absolute;
top: 120px;
width: 38px;
height: 48px;
border-radius: 50%;
background: #10131a;
}
.face span:first-child { left: 98px; }
.face span:nth-child(2) { right: 98px; }
.face i {
position: absolute;
left: 50%;
bottom: 86px;
transform: translateX(-50%);
width: 108px;
height: 34px;
border-radius: 0 0 80px 80px;
border-bottom: 16px solid #10131a;
}
.messages {
padding: 0 34px;
display: grid;
gap: 20px;
}
.messages p {
margin: 0;
padding: 24px 26px;
border-radius: 28px;
font-size: 25px;
line-height: 1.32;
}
.messages .assistant {
background: rgba(255, 255, 255, 0.09);
color: rgba(255, 255, 255, 0.82);
justify-self: start;
max-width: 570px;
}
.messages .user {
background: #4a83dd;
justify-self: end;
max-width: 520px;
}
.chat footer {
position: absolute;
left: 0;
right: 0;
bottom: 54px;
height: 116px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 22px 30px;
display: flex;
align-items: center;
gap: 18px;
}
.mic, .send {
width: 70px;
height: 70px;
border-radius: 22px;
background: #4a83dd;
}
.mic::before {
content: "";
display: block;
width: 22px;
height: 34px;
margin: 16px auto 0;
border-radius: 999px;
border: 5px solid white;
}
.send::before {
content: "";
display: block;
width: 24px;
height: 24px;
margin: 23px auto 0;
border-top: 6px solid white;
border-right: 6px solid white;
transform: rotate(45deg);
}
.input {
flex: 1;
height: 70px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.36);
font-size: 24px;
display: flex;
align-items: center;
padding: 0 24px;
}
.lock {
width: 164px;
height: 164px;
border-radius: 40px;
background: #6cbf9a;
position: relative;
box-shadow: 0 22px 54px rgba(108, 191, 154, 0.3);
}
.lock::before {
content: "";
position: absolute;
left: 46px;
top: 36px;
width: 72px;
height: 62px;
border: 14px solid white;
border-bottom: 0;
border-radius: 42px 42px 0 0;
}
.lock span {
position: absolute;
left: 38px;
bottom: 34px;
width: 88px;
height: 70px;
border-radius: 16px;
background: white;
}
.rows {
margin-top: 78px;
width: 100%;
display: grid;
gap: 20px;
}
.rows div {
text-align: left;
padding: 26px 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.08);
}
.rows strong {
display: block;
font-size: 27px;
margin-bottom: 8px;
}
.rows span {
color: rgba(255, 255, 255, 0.56);
font-size: 23px;
}
</style>
</head>
<body>
<main class="shot">
<div class="brand">
<img class="icon" src="${iconUri}" alt="">
<img class="wordmark" src="${wordmarkUri}" alt="">
</div>
<section class="copy">
<div class="kicker">${escapeHtml(kicker)}</div>
<h1>${escapeHtml(title)}</h1>
<p class="subtitle">${escapeHtml(body)}</p>
</section>
${body.includes("QR") ? pairScreen() : title.includes("voice") ? chatScreen() : privacyScreen()}
</main>
</body>
</html>`;
}
const shots = [
{
file: "iPhone_6_9_01_pair_with_desktop.png",
kicker: "OpenHuman for iPhone",
title: "Pair with your desktop",
body: "Scan the QR code from OpenHuman on your computer and connect your phone to the assistant you already trust.",
},
{
file: "iPhone_6_9_02_chat_and_voice.png",
kicker: "Text and push-to-talk",
title: "Chat by text or voice",
body: "Send quick messages, dictate thoughts, and get spoken replies while your desktop runtime does the work.",
},
{
file: "iPhone_6_9_03_desktop_anchored.png",
kicker: "Desktop-owned context",
title: "Your memory stays anchored",
body: "Use your paired OpenHuman setup for memory, tools, and integrations without turning the phone into a separate workspace.",
},
];
await mkdir(outDir, { recursive: true });
const iconUri = await pngDataUri(
"app/src-tauri-mobile/icons/store/appstore.png",
);
const wordmarkUri = await pngDataUri(
"app/public/brand/OpenhumanLogo+wordmark-White.png",
);
const browser = await chromium.launch();
try {
const page = await browser.newPage({
viewport: { width, height },
deviceScaleFactor: 1,
});
for (const shot of shots) {
await page.setContent(html({ ...shot, iconUri, wordmarkUri }), {
waitUntil: "load",
});
const destination = path.join(outDir, shot.file);
await page.screenshot({ path: destination, fullPage: false });
console.log(
`[ios-appstore-assets] wrote ${path.relative(rootDir, destination)}`,
);
}
} finally {
await browser.close();
}
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env node
import crypto from "node:crypto";
import { readFile, readdir, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const metadataDir = path.join(rootDir, "fastlane/metadata/en-US");
const screenshotDir = path.join(rootDir, "fastlane/screenshots/en-US");
const apiBase = "https://api.appstoreconnect.apple.com/v1";
const appId = process.env.ASC_APP_ID || "6761229174";
const locale = process.env.ASC_LOCALE || "en-US";
const platform = process.env.ASC_PLATFORM || "IOS";
const screenshotDisplayType =
process.env.ASC_SCREENSHOT_DISPLAY_TYPE || "APP_IPHONE_67";
const versionString =
process.env.ASC_VERSION_STRING ||
JSON.parse(await readFile(path.join(rootDir, "app/package.json"), "utf8"))
.version;
const keyId = process.env.ASC_KEY_ID;
const issuerId = process.env.ASC_ISSUER_ID;
const keyPath = process.env.ASC_KEY_PATH;
if (!keyId || !issuerId || !keyPath) {
throw new Error("ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_PATH are required.");
}
function base64Url(input) {
return Buffer.from(input)
.toString("base64")
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
async function createJwt() {
const privateKey = await readFile(keyPath, "utf8");
const now = Math.floor(Date.now() / 1000);
const header = { alg: "ES256", kid: keyId, typ: "JWT" };
const payload = {
iss: issuerId,
aud: "appstoreconnect-v1",
iat: now,
exp: now + 15 * 60,
};
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(payload))}`;
const signature = crypto.sign("sha256", Buffer.from(signingInput), {
key: privateKey,
dsaEncoding: "ieee-p1363",
});
return `${signingInput}.${base64Url(signature)}`;
}
const jwt = await createJwt();
async function request(
method,
resourcePath,
body,
{ raw = false, headers = {} } = {},
) {
const res = await fetch(`${apiBase}${resourcePath}`, {
method,
headers: {
Authorization: `Bearer ${jwt}`,
Accept: "application/json",
...(body && !raw ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? (raw ? body : JSON.stringify(body)) : undefined,
});
const text = await res.text();
const payload = text ? JSON.parse(text) : null;
if (!res.ok) {
const message =
payload?.errors
?.map((e) => `${e.status} ${e.code}: ${e.detail}`)
.join("\n") || text;
throw new Error(`${method} ${resourcePath} failed: ${message}`);
}
return payload;
}
async function uploadOperation(operation, fileBuffer) {
const headers = Object.fromEntries(
(operation.requestHeaders || []).map((h) => [h.name, h.value]),
);
const offset = Number(operation.offset || 0);
const length = Number(operation.length || fileBuffer.length);
const chunk = fileBuffer.subarray(offset, offset + length);
const res = await fetch(operation.url, {
method: operation.method,
headers,
body: chunk,
});
if (!res.ok) {
throw new Error(
`asset upload failed: ${res.status} ${res.statusText} ${await res.text()}`,
);
}
}
async function textFile(name) {
return (await readFile(path.join(metadataDir, name), "utf8")).trim();
}
async function firstPage(resourcePath) {
const payload = await request("GET", resourcePath);
return payload.data || [];
}
async function getOrCreateAppInfoLocalization() {
const appInfos = await firstPage(`/apps/${appId}/appInfos?limit=10`);
if (!appInfos.length) {
throw new Error(`No appInfos found for app ${appId}.`);
}
const appInfo = appInfos[0];
const existing = await firstPage(
`/appInfos/${appInfo.id}/appInfoLocalizations?limit=50`,
);
const match = existing.find((item) => item.attributes?.locale === locale);
if (match) return match;
const created = await request("POST", "/appInfoLocalizations", {
data: {
type: "appInfoLocalizations",
attributes: { locale, name: await textFile("name.txt") },
relationships: {
appInfo: { data: { type: "appInfos", id: appInfo.id } },
},
},
});
return created.data;
}
async function updateAppInfoLocalization() {
const localization = await getOrCreateAppInfoLocalization();
await request("PATCH", `/appInfoLocalizations/${localization.id}`, {
data: {
type: "appInfoLocalizations",
id: localization.id,
attributes: {
name: await textFile("name.txt"),
subtitle: await textFile("subtitle.txt"),
privacyPolicyUrl: await textFile("privacy_url.txt"),
},
},
});
console.log(
`[ios-appstore-metadata] updated app info localization ${locale}`,
);
}
async function getOrCreateAppStoreVersion() {
const versions = await firstPage(
`/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=20`,
);
const editableStates = new Set([
"PREPARE_FOR_SUBMISSION",
"DEVELOPER_REJECTED",
"REJECTED",
"METADATA_REJECTED",
"INVALID_BINARY",
]);
const existing =
versions.find((v) => v.attributes?.versionString === versionString) ||
versions.find((v) => editableStates.has(v.attributes?.appStoreState));
if (existing) return existing;
const created = await request("POST", "/appStoreVersions", {
data: {
type: "appStoreVersions",
attributes: {
platform,
versionString,
copyright: await textFile("copyright.txt"),
},
relationships: {
app: { data: { type: "apps", id: appId } },
},
},
});
console.log(
`[ios-appstore-metadata] created ${platform} version ${versionString}`,
);
return created.data;
}
async function getOrCreateVersionLocalization(version) {
const existing = await firstPage(
`/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`,
);
const match = existing.find((item) => item.attributes?.locale === locale);
if (match) return match;
const created = await request("POST", "/appStoreVersionLocalizations", {
data: {
type: "appStoreVersionLocalizations",
attributes: { locale },
relationships: {
appStoreVersion: { data: { type: "appStoreVersions", id: version.id } },
},
},
});
return created.data;
}
async function updateVersionLocalization() {
const version = await getOrCreateAppStoreVersion();
const localization = await getOrCreateVersionLocalization(version);
await request("PATCH", `/appStoreVersionLocalizations/${localization.id}`, {
data: {
type: "appStoreVersionLocalizations",
id: localization.id,
attributes: {
description: await textFile("description.txt"),
keywords: await textFile("keywords.txt"),
marketingUrl: await textFile("marketing_url.txt"),
promotionalText: await textFile("promotional_text.txt"),
supportUrl: await textFile("support_url.txt"),
},
},
});
console.log(
`[ios-appstore-metadata] updated version localization ${locale} for ${version.attributes.versionString}`,
);
return localization;
}
async function deleteExistingScreenshotSet(localization) {
const sets = await firstPage(
`/appStoreVersionLocalizations/${localization.id}/appScreenshotSets?filter[screenshotDisplayType]=${screenshotDisplayType}&limit=50&include=appScreenshots`,
);
for (const set of sets) {
await request("DELETE", `/appScreenshotSets/${set.id}`);
console.log(
`[ios-appstore-metadata] deleted existing screenshot set ${set.id}`,
);
}
}
async function createScreenshotSet(localization) {
const created = await request("POST", "/appScreenshotSets", {
data: {
type: "appScreenshotSets",
attributes: { screenshotDisplayType },
relationships: {
appStoreVersionLocalization: {
data: { type: "appStoreVersionLocalizations", id: localization.id },
},
},
},
});
console.log(
`[ios-appstore-metadata] created screenshot set ${screenshotDisplayType}`,
);
return created.data;
}
async function uploadScreenshot(set, filePath) {
const fileName = path.basename(filePath);
const fileBuffer = await readFile(filePath);
const fileInfo = await stat(filePath);
const checksum = crypto.createHash("md5").update(fileBuffer).digest("hex");
const reservation = await request("POST", "/appScreenshots", {
data: {
type: "appScreenshots",
attributes: { fileName, fileSize: fileInfo.size },
relationships: {
appScreenshotSet: { data: { type: "appScreenshotSets", id: set.id } },
},
},
});
const operations = reservation.data.attributes.uploadOperations || [];
for (const operation of operations) {
await uploadOperation(operation, fileBuffer);
}
await request("PATCH", `/appScreenshots/${reservation.data.id}`, {
data: {
type: "appScreenshots",
id: reservation.data.id,
attributes: {
uploaded: true,
sourceFileChecksum: checksum,
},
},
});
console.log(`[ios-appstore-metadata] uploaded ${fileName}`);
}
async function uploadScreenshots(localization) {
const files = (await readdir(screenshotDir))
.filter((file) => file.endsWith(".png"))
.sort()
.map((file) => path.join(screenshotDir, file));
if (!files.length) {
throw new Error(`No screenshots found in ${screenshotDir}`);
}
await deleteExistingScreenshotSet(localization);
const set = await createScreenshotSet(localization);
for (const file of files) {
await uploadScreenshot(set, file);
}
}
await updateAppInfoLocalization();
const versionLocalization = await updateVersionLocalization();
await uploadScreenshots(versionLocalization);
console.log(
"[ios-appstore-metadata] metadata and screenshots submitted to App Store Connect.",
);
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# Build, export, and optionally upload the OpenHuman iOS IPA to App Store Connect.
#
# Required local inputs:
# TEAM_ID=XXXXXXXXXX
# IOS_APPSTORE_PROVISIONING_PROFILE_PATH=/path/to/profile.mobileprovision
#
# Required only when UPLOAD=1:
# ASC_KEY_ID=XXXXXXXXXX
# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# ASC_KEY_PATH=/path/to/AuthKey_XXXXXXXXXX.p8
#
# Optional:
# BUILD_NUMBER=123
# UPLOAD=0|1 (default: 0)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
APP_IDENTIFIER="com.tinyhumansai.openhuman"
MOBILE_DIR="$REPO_ROOT/app/src-tauri-mobile"
APPLE_DIR="$MOBILE_DIR/gen/apple"
ARCHIVE_PATH="$APPLE_DIR/build/openhuman-mobile_iOS.xcarchive"
EXPORT_DIR="$APPLE_DIR/build/appstore-export"
PROFILE_PATH="${IOS_APPSTORE_PROVISIONING_PROFILE_PATH:-}"
TEAM_ID="${TEAM_ID:-${APPLE_DEVELOPMENT_TEAM:-}}"
UPLOAD="${UPLOAD:-0}"
BUILD_NUMBER="${BUILD_NUMBER:-$(date -u +%Y%m%d%H%M)}"
MARKETING_VERSION="$(node -p "require('./app/src-tauri-mobile/tauri.conf.json').version")"
die() {
echo "[ios-appstore] ERROR: $*" >&2
exit 1
}
[[ -n "$TEAM_ID" ]] || die "TEAM_ID is required"
[[ -n "$PROFILE_PATH" ]] || die "IOS_APPSTORE_PROVISIONING_PROFILE_PATH is required"
[[ -f "$PROFILE_PATH" ]] || die "provisioning profile not found: $PROFILE_PATH"
if [[ "$UPLOAD" == "1" ]]; then
[[ -n "${ASC_KEY_ID:-}" ]] || die "ASC_KEY_ID is required when UPLOAD=1"
[[ -n "${ASC_ISSUER_ID:-}" ]] || die "ASC_ISSUER_ID is required when UPLOAD=1"
[[ -n "${ASC_KEY_PATH:-}" ]] || die "ASC_KEY_PATH is required when UPLOAD=1"
[[ -f "$ASC_KEY_PATH" ]] || die "App Store Connect key not found: $ASC_KEY_PATH"
fi
PROFILE_PLIST="$(mktemp -t openhuman-appstore-profile.XXXXXX.plist)"
security cms -D -i "$PROFILE_PATH" > "$PROFILE_PLIST"
PROFILE_UUID="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$PROFILE_PLIST")"
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$PROFILE_PLIST")"
PROFILE_APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' "$PROFILE_PLIST")"
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$PROFILE_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision"
echo "[ios-appstore] team_id=$TEAM_ID"
echo "[ios-appstore] app_identifier=$APP_IDENTIFIER"
echo "[ios-appstore] profile_name=$PROFILE_NAME"
echo "[ios-appstore] profile_uuid=$PROFILE_UUID"
echo "[ios-appstore] profile_app_id=$PROFILE_APP_ID"
echo "[ios-appstore] version=$MARKETING_VERSION build=$BUILD_NUMBER"
echo "[ios-appstore] installed signing identities:"
security find-identity -v -p codesigning | sed 's/^/[ios-appstore] /'
echo "[ios-appstore] building web assets"
bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app run build:app
echo "[ios-appstore] generating iOS Xcode project"
TEAM_ID="$TEAM_ID" APPLE_DEVELOPMENT_TEAM="$TEAM_ID" bash scripts/ios-init.sh
mkdir -p "$APPLE_DIR/assets"
rsync -a --delete app/dist/ "$APPLE_DIR/assets/"
INFO_PLIST="$APPLE_DIR/openhuman-mobile_iOS/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $MARKETING_VERSION" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $MARKETING_VERSION" "$INFO_PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $BUILD_NUMBER" "$INFO_PLIST"
echo "[ios-appstore] archiving iphoneos app"
xcodebuild \
-workspace "$APPLE_DIR/openhuman-mobile.xcodeproj/project.xcworkspace" \
-scheme openhuman-mobile_iOS \
-configuration release \
-sdk iphoneos \
-destination "generic/platform=iOS" \
-archivePath "$ARCHIVE_PATH" \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER="$PROFILE_NAME" \
MARKETING_VERSION="$MARKETING_VERSION" \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
archive
EXPORT_OPTIONS="$(mktemp -t openhuman-export-options.XXXXXX.plist)"
mkdir -p "$EXPORT_DIR"
cat > "$EXPORT_OPTIONS" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>manual</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>provisioningProfiles</key>
<dict>
<key>$APP_IDENTIFIER</key>
<string>$PROFILE_NAME</string>
</dict>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
PLIST
echo "[ios-appstore] exporting IPA"
xcodebuild \
-exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
-allowProvisioningUpdates
IPA_PATH="$(find "$EXPORT_DIR" -maxdepth 1 -name '*.ipa' -print -quit)"
[[ -n "$IPA_PATH" ]] || die "IPA export completed but no .ipa was found in $EXPORT_DIR"
echo "[ios-appstore] IPA ready: $IPA_PATH"
if [[ "$UPLOAD" != "1" ]]; then
echo "[ios-appstore] UPLOAD=0, stopping before App Store Connect upload."
exit 0
fi
ASC_KEY_DIR="$(mktemp -d -t openhuman-asc-keys.XXXXXX)"
cp "$ASC_KEY_PATH" "$ASC_KEY_DIR/AuthKey_${ASC_KEY_ID}.p8"
echo "[ios-appstore] uploading IPA to App Store Connect"
API_PRIVATE_KEYS_DIR="$ASC_KEY_DIR" \
xcrun altool --upload-app \
--type ios \
--file "$IPA_PATH" \
--apiKey "$ASC_KEY_ID" \
--apiIssuer "$ASC_ISSUER_ID"
echo "[ios-appstore] upload submitted. Apple will process the build before it appears in App Store Connect."
+37 -2
View File
@@ -39,7 +39,12 @@ if [[ -z "$TEAM_ID" ]]; then
exit 1
fi
# Keep regeneration deterministic. Previous local builds can leave archives,
# copied libraries, and patched Xcode project state under gen/apple/.
rm -rf "$MOBILE_DIR/gen/apple"
npx --package=@tauri-apps/cli@^2 tauri ios init \
--ci \
-c "{\"bundle\":{\"iOS\":{\"developmentTeam\":\"$TEAM_ID\"}}}"
# Overwrite the placeholder AppIcon set Tauri generates with the real
@@ -58,14 +63,44 @@ fi
# barcode scanner (camera) is mandatory for QR pairing; mic + speech are
# needed by the PTT plugin. Without these, iOS will hard-crash the app on
# first use of each API.
INFO_PLIST=$(find "$MOBILE_DIR/gen/apple" -name "Info.plist" -path "*openhuman-mobile_iOS*" 2>/dev/null | head -1)
if [[ -n "$INFO_PLIST" ]]; then
INFO_PLIST="$MOBILE_DIR/gen/apple/openhuman-mobile_iOS/Info.plist"
if [[ -f "$INFO_PLIST" ]]; then
echo "[ios-init] injecting privacy keys → $INFO_PLIST"
/usr/libexec/PlistBuddy -c "Add :NSCameraUsageDescription string 'OpenHuman uses the camera to scan the pairing QR code from your desktop.'" "$INFO_PLIST" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :NSMicrophoneUsageDescription string 'OpenHuman uses the microphone for push-to-talk voice messages.'" "$INFO_PLIST" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :NSSpeechRecognitionUsageDescription string 'OpenHuman uses on-device speech recognition to transcribe your voice messages.'" "$INFO_PLIST" 2>/dev/null || true
fi
# The generated Xcode build phase runs `npm run -- tauri ...` from gen/apple.
# In this repo that resolves the app-level package script and makes Tauri look
# for app/src-tauri/gen/apple (desktop) instead of app/src-tauri-mobile/gen/apple.
# Tauri's `ios xcode-script` also loses access to the installed iOS simulator
# Rust std in Xcode's script environment. Build the mobile staticlib directly
# from the mobile crate root and copy it to the location the Xcode target links.
PROJECT_YML="$MOBILE_DIR/gen/apple/project.yml"
PBXPROJ="$MOBILE_DIR/gen/apple/openhuman-mobile.xcodeproj/project.pbxproj"
NEW_SCRIPT='cd ../.. && PATH=$HOME/.cargo/bin:$PATH RUSTUP_TOOLCHAIN=${RUSTUP_TOOLCHAIN:-1.93.0-aarch64-apple-darwin} IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET:-16.0} RUST_TARGET=aarch64-apple-ios && case "${SDK_NAME:-}" in iphonesimulator*) RUST_TARGET=aarch64-apple-ios-sim ;; esac && cargo build --package openhuman-mobile --manifest-path Cargo.toml --target "$RUST_TARGET" --features=tauri/custom-protocol --lib --release && mkdir -p "gen/apple/Externals/arm64/${CONFIGURATION:-release}" && cp "target/$RUST_TARGET/release/libopenhuman_mobile.a" "gen/apple/Externals/arm64/${CONFIGURATION:-release}/libapp.a"'
patch_xcode_script() {
local file="$1"
NEW_SCRIPT="$NEW_SCRIPT" perl -0pi -e 's#(- script: )[^\n]*(?:tauri ios xcode-script|cargo build --package openhuman-mobile)[^\n]*#$1$ENV{NEW_SCRIPT}#g' "$file"
NEW_SCRIPT="$NEW_SCRIPT" perl -pi -e '
if (/shellScript = / && /(tauri ios xcode-script|cargo build --package openhuman-mobile)/) {
my $script = $ENV{NEW_SCRIPT};
$script =~ s/\\/\\\\/g;
$script =~ s/"/\\"/g;
$_ = "\t\t\tshellScript = \"$script\";\n";
}
' "$file"
}
if [[ -f "$PROJECT_YML" ]]; then
echo "[ios-init] patching mobile Rust build phase → $PROJECT_YML"
patch_xcode_script "$PROJECT_YML"
fi
if [[ -f "$PBXPROJ" ]]; then
echo "[ios-init] patching mobile Rust build phase → $PBXPROJ"
patch_xcode_script "$PBXPROJ"
fi
echo ""
echo "[ios-init] Done. Next steps:"
echo ""