feat: prepare iOS build and TestFlight CI (#66)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
137
.github/workflows/ios-ci.yml
vendored
Normal file
137
.github/workflows/ios-ci.yml
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
# iOS build gate for pull requests and main.
|
||||
#
|
||||
# Requires NO Apple/EAS secrets: it validates the Expo config, exports the iOS JS
|
||||
# bundle, generates the native project, installs CocoaPods, and compiles an
|
||||
# UNSIGNED iPhone Simulator target with xcodebuild. Signing/TestFlight lives in
|
||||
# publish-app-store.yml.
|
||||
#
|
||||
# Cheap platform-neutral checks (typecheck + unit tests) run first on Linux and
|
||||
# gate the costly macOS native build.
|
||||
|
||||
name: iOS CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "docs-site/**"
|
||||
- "distribution/**"
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "docs-site/**"
|
||||
- "distribution/**"
|
||||
|
||||
# Cancel superseded runs for the same ref (e.g. new push to an open PR).
|
||||
concurrency:
|
||||
group: ios-ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Typecheck and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
# Node >= 23.6 runs the TypeScript test files natively (type-stripping),
|
||||
# matching the local toolchain. No build step or extra deps required.
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies (deterministic)
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
ios-build:
|
||||
name: Unsigned iOS Simulator build
|
||||
needs: test
|
||||
# macos-15 ships Xcode 16.x, which React Native 0.81 / Expo SDK 54 require.
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
# No source-map upload from CI (no Sentry auth token here); keep the build hermetic.
|
||||
SENTRY_DISABLE_AUTO_UPLOAD: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies (deterministic)
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Validate Expo config and plugin resolution
|
||||
run: npx expo config --type introspect --json > expo-config.introspect.json
|
||||
|
||||
- name: Export iOS JavaScript bundle
|
||||
run: npx expo export --platform ios --output-dir dist
|
||||
|
||||
- name: Prebuild native iOS project
|
||||
run: npx expo prebuild --platform ios --no-install
|
||||
|
||||
- name: Install CocoaPods dependencies
|
||||
working-directory: ios
|
||||
run: pod install
|
||||
|
||||
- name: Resolve Xcode workspace and scheme
|
||||
id: xc
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
workspaces=(ios/*.xcworkspace)
|
||||
workspace="${workspaces[0]:-}"
|
||||
if [ -z "$workspace" ]; then
|
||||
echo "::error::No .xcworkspace was generated by expo prebuild."
|
||||
exit 1
|
||||
fi
|
||||
scheme=$(xcodebuild -workspace "$workspace" -list -json | node -e "
|
||||
const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
const all = (d.workspace && d.workspace.schemes) || [];
|
||||
const app = all.filter((s) => s !== 'Pods' && !s.startsWith('Pods-'));
|
||||
if (app.length === 0) { console.error('No application scheme found in workspace'); process.exit(1); }
|
||||
process.stdout.write(app[0]);
|
||||
")
|
||||
echo "workspace=$workspace" >> "$GITHUB_OUTPUT"
|
||||
echo "scheme=$scheme" >> "$GITHUB_OUTPUT"
|
||||
echo "Using workspace='$workspace' scheme='$scheme'"
|
||||
|
||||
- name: Build unsigned iPhone Simulator app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NSUnbufferedIO=YES xcodebuild \
|
||||
-workspace "${{ steps.xc.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xc.outputs.scheme }}" \
|
||||
-configuration Debug \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
-derivedDataPath ios/build \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
build 2>&1 | tee xcodebuild.log
|
||||
|
||||
- name: Upload xcodebuild log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ios-xcodebuild-log
|
||||
path: xcodebuild.log
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
355
.github/workflows/publish-app-store.yml
vendored
355
.github/workflows/publish-app-store.yml
vendored
@@ -1,45 +1,63 @@
|
||||
# STATUS: Validated structure — awaiting Apple Developer Program enrollment approval.
|
||||
# Once enrollment is approved, complete these steps and this workflow is production-ready:
|
||||
# Publish OpenCode for iOS to TestFlight with EAS Build + EAS Submit.
|
||||
#
|
||||
# REMAINING GAPS (must complete before first run):
|
||||
# 1. Update eas.json: replace REPLACE_WITH_APP_STORE_CONNECT_APP_ID and REPLACE_WITH_APPLE_TEAM_ID
|
||||
# (see eas.json.README.md for exact click paths in App Store Connect)
|
||||
# 2. Add GitHub secrets (Settings > Secrets and variables > Actions):
|
||||
# EAS_TOKEN Expo access token (expo.dev > Account > Access Tokens)
|
||||
# APPLE_APP_STORE_CONNECT_API_KEY_ID Key ID from App Store Connect > Users & Access > Integrations > App Store Connect API
|
||||
# APPLE_APP_STORE_CONNECT_ISSUER_ID Issuer ID from same page
|
||||
# APPLE_APP_STORE_CONNECT_API_KEY base64-encoded .p8 file (download at key creation — one time only)
|
||||
# 3. Run `eas login` locally and `eas build:configure` on first run to let EAS set up signing
|
||||
# 4. Manually upload first build to App Store Connect (required once to create the app record)
|
||||
# This workflow FAILS FAST (non-zero exit) instead of "succeeding by skipping":
|
||||
# a release with missing credentials or unfilled identifiers is a hard error, so a
|
||||
# green run always means a real build was produced and submitted.
|
||||
#
|
||||
# OPTIONAL secrets (crash reporting):
|
||||
# EXPO_PUBLIC_SENTRY_DSN SENTRY_AUTH_TOKEN SENTRY_ORG SENTRY_PROJECT
|
||||
# ── HUMAN GATE (one-time, after Apple Developer Program enrollment) ──────────────
|
||||
# Complete ALL of the following before releasing. Do NOT invent any of these IDs.
|
||||
#
|
||||
# Build strategy: EAS Build (Expo Application Services)
|
||||
# - No Mac runner needed; Expo hosts macOS workers with managed certificates.
|
||||
# - Cost: free tier (30 builds/month); upgrade to $19/month for unlimited/priority queue.
|
||||
# - See distribution/ios-enrollment-runbook.md for full enrollment steps.
|
||||
# - See eas.json.README.md for placeholder fill-in instructions.
|
||||
# - Alternative (self-hosted Mac runner): see commented section at bottom of this file.
|
||||
# 1. Link the app to an Expo project and add its UUID as the GitHub Actions
|
||||
# repository variable EAS_PROJECT_ID (see eas.json.README.md), then fill and
|
||||
# commit the eas.json placeholders:
|
||||
# submit.production.ios.ascAppId REPLACE_WITH_APP_STORE_CONNECT_APP_ID → numeric App Store Connect App ID
|
||||
# submit.production.ios.appleTeamId REPLACE_WITH_APPLE_TEAM_ID → 10-char Apple Team ID
|
||||
#
|
||||
# 2. Add GitHub Actions secrets (Settings → Secrets and variables → Actions):
|
||||
# EXPO_TOKEN Expo access token (expo.dev → Account settings → Access tokens)
|
||||
# APPLE_APP_STORE_CONNECT_API_KEY_ID ASC API Key ID (App Store Connect → Users and Access → Integrations → App Store Connect API)
|
||||
# APPLE_APP_STORE_CONNECT_ISSUER_ID ASC API Issuer ID (same page)
|
||||
# APPLE_APP_STORE_CONNECT_API_KEY base64 of the .p8 key file: `base64 -i AuthKey_XXXX.p8` (downloadable once)
|
||||
#
|
||||
# 3. Bootstrap iOS signing credentials on EAS once (creates the distribution cert +
|
||||
# provisioning profile so CI never needs to prompt):
|
||||
# eas login && eas build --platform ios --profile production
|
||||
#
|
||||
# Optional crash reporting belongs in the EAS `production` environment because the
|
||||
# iOS bundle is built on a remote EAS worker. Configure EXPO_PUBLIC_SENTRY_DSN,
|
||||
# SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT in Expo before releasing.
|
||||
#
|
||||
# Build number is managed remotely by EAS (eas.json: cli.appVersionSource=remote,
|
||||
# build.production.ios.autoIncrement=buildNumber). The workflow only injects the
|
||||
# EAS project linkage into its temporary runner copy of app.json.
|
||||
|
||||
name: Publish to App Store (TestFlight)
|
||||
|
||||
on:
|
||||
# One release ⇒ one build. Triggering only on `release: published` avoids the
|
||||
# duplicate build that a combined release+tag trigger would create.
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish-ios:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
# Serialize runs per release so a re-trigger cannot start a duplicate concurrent
|
||||
# build/submit. cancel-in-progress:false never kills an in-flight submission.
|
||||
concurrency:
|
||||
group: publish-app-store-${{ github.event.release.tag_name || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
testflight:
|
||||
name: EAS Build and submit to TestFlight
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
EAS_CLI_VERSION: "21.0.0"
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
EAS_PROJECT_ID: ${{ vars.EAS_PROJECT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -48,144 +66,167 @@ jobs:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Check Apple prerequisites
|
||||
id: check-apple
|
||||
run: |
|
||||
if [[ -n "${{ secrets.EAS_TOKEN }}" ]]; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::Apple Developer enrollment pending — EAS_TOKEN not set. Skipping iOS build."
|
||||
fi
|
||||
|
||||
# Install EAS CLI globally. Pin to a recent stable version.
|
||||
- name: Install EAS CLI
|
||||
if: steps.check-apple.outputs.proceed == 'true'
|
||||
run: npm install -g eas-cli@13
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check-apple.outputs.proceed == 'true'
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
# Bump ios.buildNumber to match github.run_number (monotonically increasing).
|
||||
# App Store Connect rejects duplicate build numbers for the same version string.
|
||||
- name: Bump ios.buildNumber in app.json
|
||||
if: steps.check-apple.outputs.proceed == 'true'
|
||||
run: |
|
||||
node -e "
|
||||
const f = 'app.json';
|
||||
const j = require('./' + f);
|
||||
j.expo.ios = j.expo.ios || {};
|
||||
j.expo.ios.buildNumber = String(${{ github.run_number }});
|
||||
require('fs').writeFileSync(f, JSON.stringify(j, null, 2) + '\n');
|
||||
"
|
||||
echo "buildNumber now: $(node -p "require('./app.json').expo.ios.buildNumber")"
|
||||
|
||||
# EAS Build: builds the IPA in Expo's cloud (macOS workers managed by Expo).
|
||||
# --non-interactive: no prompts, suitable for CI.
|
||||
# --platform ios: iOS only (Android is handled by publish-play-store.yml).
|
||||
# --profile production: uses the "production" profile in eas.json (created below if missing).
|
||||
- name: Build IPA via EAS
|
||||
if: steps.check-apple.outputs.proceed == 'true'
|
||||
- name: Preflight — verify credentials and identifiers (fail fast)
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EAS_TOKEN }}
|
||||
APPLE_APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APPLE_APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_ISSUER_ID }}
|
||||
APPLE_APP_STORE_CONNECT_API_KEY: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_ISSUER_ID }}
|
||||
ASC_KEY_B64: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail=0
|
||||
need() {
|
||||
if [ -z "${2:-}" ]; then
|
||||
echo "::error::Missing required secret: $1"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
need "EXPO_TOKEN" "${EXPO_TOKEN:-}"
|
||||
need "APPLE_APP_STORE_CONNECT_API_KEY_ID" "${ASC_KEY_ID:-}"
|
||||
need "APPLE_APP_STORE_CONNECT_ISSUER_ID" "${ASC_ISSUER_ID:-}"
|
||||
need "APPLE_APP_STORE_CONNECT_API_KEY" "${ASC_KEY_B64:-}"
|
||||
if [ -z "${EAS_PROJECT_ID:-}" ]; then
|
||||
echo "::error::Missing required repository variable: EAS_PROJECT_ID"
|
||||
fail=1
|
||||
elif ! printf '%s' "$EAS_PROJECT_ID" | grep -Eq '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'; then
|
||||
echo "::error::EAS_PROJECT_ID must be an Expo project UUID"
|
||||
fail=1
|
||||
fi
|
||||
asc_app_id=$(node -p "require('./eas.json').submit.production.ios.ascAppId || ''")
|
||||
team_id=$(node -p "require('./eas.json').submit.production.ios.appleTeamId || ''")
|
||||
case "$asc_app_id" in
|
||||
""|REPLACE_*) echo "::error::eas.json submit.production.ios.ascAppId is unset or still a placeholder"; fail=1 ;;
|
||||
*[!0-9]*) echo "::error::eas.json submit.production.ios.ascAppId must contain only digits"; fail=1 ;;
|
||||
esac
|
||||
case "$team_id" in
|
||||
""|REPLACE_*) echo "::error::eas.json submit.production.ios.appleTeamId is unset or still a placeholder"; fail=1 ;;
|
||||
esac
|
||||
if ! printf '%s' "$team_id" | grep -Eq '^[A-Z0-9]{10}$'; then
|
||||
echo "::error::eas.json submit.production.ios.appleTeamId must be a 10-character Apple Team ID"
|
||||
fail=1
|
||||
fi
|
||||
if [ -n "${ASC_KEY_ID:-}" ] && ! printf '%s' "$ASC_KEY_ID" | grep -Eq '^[A-Z0-9]{10}$'; then
|
||||
echo "::error::APPLE_APP_STORE_CONNECT_API_KEY_ID must be a 10-character key ID"
|
||||
fail=1
|
||||
fi
|
||||
if [ -n "${ASC_ISSUER_ID:-}" ] && ! printf '%s' "$ASC_ISSUER_ID" | grep -Eq '^[0-9a-fA-F-]{36}$'; then
|
||||
echo "::error::APPLE_APP_STORE_CONNECT_ISSUER_ID must be a UUID"
|
||||
fail=1
|
||||
fi
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "::error::BLOCKED: complete the one-time human-gated setup in this workflow's header (GitHub secrets + eas.json identifiers) before releasing. No build was started."
|
||||
exit 1
|
||||
fi
|
||||
echo "Preflight OK — all credentials and identifiers present."
|
||||
|
||||
- name: Install EAS CLI (exact pin)
|
||||
run: npm install -g eas-cli@"$EAS_CLI_VERSION"
|
||||
|
||||
- name: Install dependencies (deterministic)
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Configure EAS project linkage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const j = require('./app.json');
|
||||
j.expo.extra = { ...j.expo.extra, eas: { ...j.expo.extra?.eas, projectId: process.env.EAS_PROJECT_ID } };
|
||||
fs.writeFileSync('app.json', JSON.stringify(j, null, 2) + '\n');
|
||||
"
|
||||
test "$(node -p "require('./app.json').expo.extra.eas.projectId")" = "$EAS_PROJECT_ID"
|
||||
echo "Linked build to Expo project $EAS_PROJECT_ID."
|
||||
|
||||
- name: Configure App Store Connect API key
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_ISSUER_ID }}
|
||||
ASC_KEY_B64: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
key_path="$RUNNER_TEMP/asc_api_key.p8"
|
||||
printf '%s' "$ASC_KEY_B64" | base64 -d > "$key_path"
|
||||
if ! head -n1 "$key_path" | grep -q "BEGIN PRIVATE KEY"; then
|
||||
echo "::error::APPLE_APP_STORE_CONNECT_API_KEY did not base64-decode to a valid .p8 private key."
|
||||
exit 1
|
||||
fi
|
||||
chmod 600 "$key_path"
|
||||
export ASC_KEY_PATH="$key_path"
|
||||
# Expose ASC credentials to EAS Build for non-interactive signing management.
|
||||
{
|
||||
echo "EXPO_ASC_API_KEY_PATH=$key_path"
|
||||
echo "EXPO_ASC_KEY_ID=$ASC_KEY_ID"
|
||||
echo "EXPO_ASC_ISSUER_ID=$ASC_ISSUER_ID"
|
||||
echo "EXPO_APPLE_TEAM_ID=$(node -p "require('./eas.json').submit.production.ios.appleTeamId")"
|
||||
echo "EXPO_APPLE_TEAM_TYPE=COMPANY_OR_ORGANIZATION"
|
||||
} >> "$GITHUB_ENV"
|
||||
# EAS Submit reads the ASC key only from the eas.json submit profile (all three
|
||||
# fields required). Inject them here so no real key IDs are committed to the repo.
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const j = require('./eas.json');
|
||||
j.submit.production.ios.ascApiKeyPath = process.env.ASC_KEY_PATH;
|
||||
j.submit.production.ios.ascApiKeyId = process.env.ASC_KEY_ID;
|
||||
j.submit.production.ios.ascApiKeyIssuerId = process.env.ASC_ISSUER_ID;
|
||||
fs.writeFileSync('eas.json', JSON.stringify(j, null, 2) + '\n');
|
||||
"
|
||||
echo "ASC API key configured for EAS Build and EAS Submit."
|
||||
|
||||
- name: EAS Build (iOS, wait for completion)
|
||||
id: build
|
||||
run: |
|
||||
set -uo pipefail
|
||||
set +e
|
||||
eas build \
|
||||
--platform ios \
|
||||
--profile production \
|
||||
--non-interactive \
|
||||
--no-wait \
|
||||
--json \
|
||||
| tee eas-build-output.json
|
||||
BUILD_ID=$(cat eas-build-output.json | node -e "const d=require('fs').readFileSync('/dev/stdin','utf8');console.log(JSON.parse(d).id)")
|
||||
echo "EAS_BUILD_ID=$BUILD_ID" >> $GITHUB_ENV
|
||||
echo "Build ID: $BUILD_ID"
|
||||
--json > eas-build-output.json
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::error::eas build failed (exit $rc). See the eas-ios-build-metadata artifact."
|
||||
exit "$rc"
|
||||
fi
|
||||
# `eas build --json` prints a JSON ARRAY of completed builds. Select the exact
|
||||
# iOS build id deterministically — never rely on an ambiguous "latest".
|
||||
build_id=$(node -e "
|
||||
const a = JSON.parse(require('fs').readFileSync('eas-build-output.json', 'utf8'));
|
||||
if (!Array.isArray(a)) { console.error('Expected a JSON array from eas build --json'); process.exit(1); }
|
||||
const ios = a.filter((b) => String(b.platform).toUpperCase() === 'IOS');
|
||||
if (ios.length !== 1) { console.error('Expected exactly one iOS build, got ' + ios.length); process.exit(1); }
|
||||
const b = ios[0];
|
||||
if (b.status && String(b.status).toUpperCase() !== 'FINISHED') { console.error('iOS build did not finish: ' + b.status); process.exit(1); }
|
||||
if (!b.id) { console.error('Build object has no id'); process.exit(1); }
|
||||
process.stdout.write(b.id);
|
||||
")
|
||||
echo "build_id=$build_id" >> "$GITHUB_OUTPUT"
|
||||
echo "Selected EAS iOS build id: $build_id"
|
||||
|
||||
# Wait for the EAS build to complete (iOS builds typically take 15–25 minutes).
|
||||
- name: Wait for EAS build
|
||||
if: steps.check-apple.outputs.proceed == 'true'
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EAS_TOKEN }}
|
||||
run: |
|
||||
echo "Waiting for build $EAS_BUILD_ID to complete..."
|
||||
eas build:view "$EAS_BUILD_ID" --json --wait
|
||||
echo "Build complete."
|
||||
- name: Upload EAS build metadata
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: eas-ios-build-metadata
|
||||
path: eas-build-output.json
|
||||
retention-days: 30
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Submit to TestFlight via EAS Submit. Uses the same App Store Connect API key.
|
||||
# --latest: picks the most recent finished build for this app + platform.
|
||||
- name: Submit to TestFlight via EAS Submit
|
||||
if: steps.check-apple.outputs.proceed == 'true'
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EAS_TOKEN }}
|
||||
APPLE_APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APPLE_APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_ISSUER_ID }}
|
||||
APPLE_APP_STORE_CONNECT_API_KEY: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY }}
|
||||
- name: Submit exact build to TestFlight
|
||||
run: |
|
||||
set -euo pipefail
|
||||
eas submit \
|
||||
--platform ios \
|
||||
--id "$EAS_BUILD_ID" \
|
||||
--profile production \
|
||||
--id "${{ steps.build.outputs.build_id }}" \
|
||||
--non-interactive
|
||||
|
||||
# Upload release notes to TestFlight (what's new text for testers).
|
||||
# NOTE: EAS Submit does not yet support whatsNew natively; use fastlane pilot
|
||||
# or App Store Connect API directly if per-build release notes are needed.
|
||||
- name: Upload TestFlight release notes (informational)
|
||||
if: steps.check-apple.outputs.proceed == 'true'
|
||||
- name: TestFlight release notes (informational)
|
||||
if: always()
|
||||
run: |
|
||||
echo "TestFlight release notes for this build:"
|
||||
cat distribution/whatsnew-ios/release-notes-en-US.txt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ALTERNATIVE: Self-hosted Mac runner (macbook13-pro at 100.68.120.26)
|
||||
# ---------------------------------------------------------------------------
|
||||
# To use the Mac mini instead of EAS Build:
|
||||
# 1. SSH to macbook13-pro and set up GitHub self-hosted runner:
|
||||
# https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/adding-self-hosted-runners
|
||||
# 2. Change "runs-on: ubuntu-latest" above to "runs-on: self-hosted"
|
||||
# and add label "macos" for clarity.
|
||||
# 3. Replace the EAS Build + Submit steps with:
|
||||
#
|
||||
# - name: Install CocoaPods
|
||||
# run: sudo gem install cocoapods
|
||||
#
|
||||
# - name: Expo prebuild (iOS)
|
||||
# run: npx expo prebuild --platform ios --no-install
|
||||
#
|
||||
# - name: Install CocoaPods dependencies
|
||||
# working-directory: ios
|
||||
# run: pod install
|
||||
#
|
||||
# - name: Build IPA
|
||||
# run: |
|
||||
# xcodebuild -workspace ios/opencodemobile.xcworkspace \
|
||||
# -scheme opencodemobile \
|
||||
# -sdk iphoneos \
|
||||
# -configuration Release \
|
||||
# -archivePath $RUNNER_TEMP/opencodemobile.xcarchive \
|
||||
# archive \
|
||||
# CODE_SIGN_STYLE=Manual \
|
||||
# DEVELOPMENT_TEAM=${{ secrets.APPLE_TEAM_ID }} \
|
||||
# CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
# PROVISIONING_PROFILE_SPECIFIER="${{ secrets.IOS_PROVISIONING_PROFILE_NAME }}"
|
||||
#
|
||||
# - name: Export IPA
|
||||
# run: |
|
||||
# xcodebuild -exportArchive \
|
||||
# -archivePath $RUNNER_TEMP/opencodemobile.xcarchive \
|
||||
# -exportOptionsPlist ios/ExportOptions.plist \
|
||||
# -exportPath $RUNNER_TEMP/export
|
||||
#
|
||||
# - name: Upload to TestFlight (xcrun altool / notarytool)
|
||||
# run: |
|
||||
# xcrun altool --upload-app \
|
||||
# -f "$RUNNER_TEMP/export/opencodemobile.ipa" \
|
||||
# --type ios \
|
||||
# --apiKey "${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY_ID }}" \
|
||||
# --apiIssuer "${{ secrets.APPLE_APP_STORE_CONNECT_ISSUER_ID }}"
|
||||
#
|
||||
# Self-hosted runner cost: $0 compute (your hardware), but requires maintaining
|
||||
# a macOS machine with Xcode, certificates, and provisioning profiles.
|
||||
# EAS Build is strongly recommended for the first release.
|
||||
notes="distribution/whatsnew-ios/release-notes-en-US.txt"
|
||||
if [ -f "$notes" ]; then
|
||||
echo "TestFlight 'What to Test' notes for this release:"
|
||||
cat "$notes"
|
||||
else
|
||||
echo "No release notes file found at $notes"
|
||||
fi
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -220,28 +220,27 @@ These same secrets are set as GitHub Actions secrets on `dzianisv/opencode-mobil
|
||||
|
||||
**Do NOT store secrets in `.env` files committed to the repo.** `.env` is gitignored — local copy only.
|
||||
|
||||
## Chrome DevTools (Browser Automation)
|
||||
## VibeBrowser CLI (Browser Automation)
|
||||
|
||||
The project uses `@vibebrowser/chrome-devtools-mcp` from `github.com/dzianisv/chrome-devtools-mcp`. It runs an MCP server over HTTP/SSE, allowing multiple agents to connect remotely.
|
||||
Use `@vibebrowser/cli` against the authenticated remote browser relay. Do not
|
||||
register or use the retired `chrome-devtools` MCP server in Copilot.
|
||||
|
||||
**No `--remote-debugging-port` needed.** The daemon discovers Chrome via `--autoConnect` (reads `DevToolsActivePort` file). Port is discovered automatically.
|
||||
Keep the relay URL outside the repository:
|
||||
|
||||
**Driving an authenticated session:** The daemon connects to your *real* Chrome profile (authenticated). To act on a logged-in page, use `list_pages` → `select_page` on the existing tab. Do **not** pass `isolatedContext` to `new_page` — that opens a cookieless context that cannot see your login. (Verified: the MCP code already uses `browser.defaultBrowserContext()`; logged-out sessions come from misuse, a profile/`--user-data-dir` mismatch, or Google's anti-automation block — not a tool bug.)
|
||||
|
||||
**Local Copilot CLI config** (in `.github/copilot-mcp.json` or IDE MCP settings):
|
||||
```json
|
||||
{
|
||||
"chrome-devtools": {
|
||||
"type": "remote",
|
||||
"url": "http://localhost:9333/mcp",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```bash
|
||||
export VIBEBROWSER_REMOTE_URL='wss://relay.api.vibebrowser.app/<session-id>'
|
||||
npx -y @vibebrowser/cli@0.2.12 \
|
||||
--remote "$VIBEBROWSER_REMOTE_URL" --json status
|
||||
```
|
||||
|
||||
**Starting the daemon:**
|
||||
List tabs first, then pass `--page-id` on every command so automation does not
|
||||
switch or disturb the user's active tab:
|
||||
|
||||
```bash
|
||||
chrome-devtools start --autoConnect --port 9333
|
||||
npx -y @vibebrowser/cli@0.2.12 \
|
||||
--remote "$VIBEBROWSER_REMOTE_URL" --json tabs
|
||||
npx -y @vibebrowser/cli@0.2.12 \
|
||||
--remote "$VIBEBROWSER_REMOTE_URL" --page-id <id> --json snapshot
|
||||
```
|
||||
|
||||
## GitHub Auth
|
||||
|
||||
1
app.json
1
app.json
@@ -25,6 +25,7 @@
|
||||
"NSMicrophoneUsageDescription": "OpenCode uses the microphone to capture your voice when using speech-to-text input.",
|
||||
"NSPhotoLibraryUsageDescription": "OpenCode can attach images from your photo library to messages to your AI coding agent.",
|
||||
"NSCameraUsageDescription": "OpenCode can capture images with your camera and attach them to messages to your AI coding agent.",
|
||||
"NSLocalNetworkUsageDescription": "OpenCode uses your local network to connect to self-hosted OpenCode servers running on your LAN.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true,
|
||||
"NSAllowsArbitraryLoadsInWebContent": false
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../../src/lib/notifications"
|
||||
import type { Category } from "../../src/lib/notifications"
|
||||
import { hasTelemetryConsent, setTelemetryConsent } from "../../src/lib/telemetry"
|
||||
import { PRIVACY_POLICY_URL } from "../../src/lib/links"
|
||||
|
||||
function SettingRow({
|
||||
icon,
|
||||
@@ -73,14 +74,26 @@ export default function SettingsScreen() {
|
||||
const { settings, hasBiometrics, updateSettings, lock } = useAuth()
|
||||
const { notifications, setNotification } = useSettings()
|
||||
const [osGranted, setOsGranted] = useState<boolean | null>(null)
|
||||
const [telemetryUpdating, setTelemetryUpdating] = useState(false)
|
||||
|
||||
// Telemetry consent: hasTelemetryConsent() returns null (unknown), true, or false.
|
||||
// We initialise local state from in-memory value; updates call setTelemetryConsent().
|
||||
const [crashReporting, setCrashReporting] = useState<boolean>(hasTelemetryConsent() ?? false)
|
||||
|
||||
const handleCrashReportingToggle = useCallback(async (value: boolean) => {
|
||||
setCrashReporting(value)
|
||||
setTelemetryUpdating(true)
|
||||
try {
|
||||
await setTelemetryConsent(value)
|
||||
setCrashReporting(value)
|
||||
} catch {
|
||||
setCrashReporting(hasTelemetryConsent() ?? false)
|
||||
Alert.alert(
|
||||
"Privacy Setting Not Saved",
|
||||
"Crash reporting is off for this session, but your choice could not be saved. Please try again.",
|
||||
)
|
||||
} finally {
|
||||
setTelemetryUpdating(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check OS permission state on first toggle attempt
|
||||
@@ -193,6 +206,7 @@ export default function SettingsScreen() {
|
||||
<Switch
|
||||
value={crashReporting}
|
||||
onValueChange={handleCrashReportingToggle}
|
||||
disabled={telemetryUpdating}
|
||||
trackColor={{ false: "#767577", true: "#22c55e" }}
|
||||
/>
|
||||
}
|
||||
@@ -202,7 +216,7 @@ export default function SettingsScreen() {
|
||||
label="Privacy Policy"
|
||||
description="What data we collect and how"
|
||||
isDark={isDark}
|
||||
onPress={() => Linking.openURL("https://agentlabs.cc/opencode/privacy")}
|
||||
onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}
|
||||
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function EditConnectionScreen() {
|
||||
url.trim(),
|
||||
username.trim() && password ? { username: username.trim(), password } : undefined,
|
||||
)
|
||||
captureDiagnostic(report, result.error ? new Error(result.error) : undefined)
|
||||
captureDiagnostic(report)
|
||||
setIsTesting(false)
|
||||
|
||||
Alert.alert("Connection Failed", `${report.summary}\n\n(${result.error || "no detail"})`, [
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function AddConnectionScreen() {
|
||||
serverUrl,
|
||||
username.trim() && password ? { username: username.trim(), password } : undefined,
|
||||
)
|
||||
captureDiagnostic(report, result.error ? new Error(result.error) : undefined)
|
||||
captureDiagnostic(report)
|
||||
setIsConnecting(false)
|
||||
Alert.alert(
|
||||
"Connection Failed",
|
||||
|
||||
BIN
assets/icon.png
BIN
assets/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 28 KiB |
@@ -171,7 +171,7 @@ OpenCode Mobile does NOT collect any of the following:
|
||||
- Browsing history, search history
|
||||
- Sensitive info
|
||||
- User content (code, prompts, AI responses are not sent to our servers)
|
||||
- Identifiers (User ID, Device ID — Sentry uses an installation-scoped anonymous ID, see below)
|
||||
- User ID (the app has no accounts or user identity)
|
||||
|
||||
### Data Linked to You: None
|
||||
|
||||
@@ -179,13 +179,14 @@ OpenCode Mobile does NOT collect any of the following:
|
||||
|
||||
| Data Type | Category | Purpose | Optional? |
|
||||
|---|---|---|---|
|
||||
| Crash Data | Diagnostics | App functionality | No — always on (see note) |
|
||||
| Performance Data | Diagnostics | App functionality | No — always on (see note) |
|
||||
| Other Diagnostic Data | Diagnostics | App functionality | No |
|
||||
| Crash Data | Diagnostics | App functionality | Yes — explicit opt-in |
|
||||
| Performance Data | Diagnostics | App functionality | Yes — explicit opt-in |
|
||||
| Other Diagnostic Data | Diagnostics | App functionality | Yes — explicit opt-in |
|
||||
| Device ID | Identifiers | App functionality | Yes — Sentry installation ID with explicit opt-in |
|
||||
|
||||
**Explanation**: Sentry crash reporting sends device model, OS version, app version, and stack traces. Sentry assigns an anonymous installation ID (not linked to any Apple ID or personal information). No user-generated content (code, prompts, responses) is ever sent.
|
||||
|
||||
**Sentry opt-in status**: As of v0.2.3, Sentry is **always on** when a DSN is configured. If you add a settings toggle for Sentry consent (planned), change Optional? to "Yes" and add a note that users who decline are in the "Data Not Collected" category. In App Store Connect, once opt-in is implemented, this section can be removed or marked optional.
|
||||
**Sentry opt-in status**: Crash reporting is off by default. The first-launch consent prompt and Settings → Privacy toggle control it. Declining does not initialize Sentry; turning it off later closes the active SDK and stops new event capture.
|
||||
|
||||
**"Are you or your third-party partners using this data to track users?"**: No
|
||||
|
||||
@@ -276,36 +277,28 @@ All icons and screenshots must be provided before submitting for review.
|
||||
|
||||
The 1024×1024 icon must NOT have rounded corners (Apple applies them). No transparency.
|
||||
|
||||
Current status: `assets/icon.json` is a placeholder — **real PNG required before submission**.
|
||||
Current status: **Ready.** `assets/icon.png` is 1024×1024.
|
||||
|
||||
### iPhone Screenshots (REQUIRED)
|
||||
|
||||
Minimum 1 screenshot per device class. Recommended: 3–5 showing key flows.
|
||||
|
||||
| Device | Resolution | Size name in App Store Connect |
|
||||
|---|---|---|
|
||||
| iPhone 6.7" (iPhone 16 Pro Max / 15 Plus) | 1320×2868 or 1290×2796 | 6.7" Super Retina XDR Display |
|
||||
| iPhone 6.5" (iPhone 14 Plus / 11 Pro Max) | 1242×2688 | 6.5" Super Retina XDR Display |
|
||||
| iPhone 5.5" (iPhone 8 Plus) | 1242×2208 | 5.5" Retina HD Display |
|
||||
| Device | Resolution | Size name in App Store Connect | Status |
|
||||
|---|---|---|---|
|
||||
| iPhone 6.7" (iPhone 16 Pro Max / 15 Plus) | 1320×2868 or 1290×2796 | 6.7" Super Retina XDR Display | Placeholder set exists; recapture from current iOS build |
|
||||
| iPhone 6.5" (iPhone 14 Plus / 11 Pro Max) | 1242×2688 | 6.5" Super Retina XDR Display | Placeholder set exists; recapture from current iOS build |
|
||||
| iPhone 5.5" (iPhone 8 Plus) | 1242×2208 | 5.5" Retina HD Display | Optional |
|
||||
|
||||
Note: As of 2024, Apple only requires 6.7" and 6.5" for new submissions. 5.5" is optional but recommended for coverage.
|
||||
|
||||
Suggested screenshot subjects:
|
||||
1. Connection setup screen (add server URL)
|
||||
2. Active chat session — streaming AI response
|
||||
3. File diff view — seeing a code change
|
||||
4. Tool approval dialog
|
||||
5. Session list / multi-session view
|
||||
6. Biometric unlock (if possible to screenshot without triggering auth)
|
||||
|
||||
### iPad Screenshots (REQUIRED for Universal apps)
|
||||
|
||||
Since `supportsTablet: true`, iPad screenshots are required.
|
||||
|
||||
| Device | Resolution | Size name in App Store Connect |
|
||||
|---|---|---|
|
||||
| iPad 12.9" (iPad Pro 6th gen) | 2048×2732 | 12.9" iPad Pro (6th gen) |
|
||||
| iPad 11" (iPad Pro M4) | 1668×2388 | 11" iPad Pro (M4) |
|
||||
| Device | Resolution | Size name in App Store Connect | Status |
|
||||
|---|---|---|---|
|
||||
| iPad 12.9" (iPad Pro 6th gen) | 2048×2732 | 12.9" iPad Pro (6th gen) | Placeholder set exists; recapture from current iOS build |
|
||||
| iPad 11" (iPad Pro M4) | 1668×2388 | 11" iPad Pro (M4) | Optional |
|
||||
|
||||
Minimum 1 per device class required. iPad screenshots can be the same content as iPhone.
|
||||
|
||||
@@ -335,11 +328,11 @@ To use: you need opencode running somewhere accessible (local Wi-Fi, Tailscale,
|
||||
## Pending Before First Submission
|
||||
|
||||
- [ ] Apple Developer Program enrollment approved (D-U-N-S 142059652, VIBE TECHNOLOGIES LLC)
|
||||
- [ ] App Store Connect app record created (bundle ID: ai.opencode.mobile)
|
||||
- [ ] App icon 1024×1024 PNG (no alpha, no rounded corners)
|
||||
- [ ] iPhone screenshots (6.7" minimum; 6.5" strongly recommended)
|
||||
- [ ] iPad screenshots (12.9" minimum)
|
||||
- [ ] Privacy policy live at https://dzianisv.github.io/opencode-mobile/privacy/
|
||||
- [ ] App Store Connect app record created (bundle ID: cc.agentlabs.opencode)
|
||||
- [x] App icon 1024×1024 PNG (no alpha, no rounded corners)
|
||||
- [ ] Capture current iPhone screenshots (6.7" minimum; 6.5" strongly recommended)
|
||||
- [ ] Capture current iPad screenshots (12.9" minimum)
|
||||
- [x] Privacy policy live at https://dzianisv.github.io/opencode-mobile/privacy/
|
||||
- [ ] App Store Connect API key created (for CI — Key ID, Issuer ID, .p8 file)
|
||||
- [ ] Apple Distribution certificate + provisioning profile (or use EAS managed signing)
|
||||
- [ ] Export compliance answered (No to custom encryption)
|
||||
|
||||
@@ -110,10 +110,10 @@ While waiting for Apple's verification call and approval:
|
||||
|
||||
- [x] Prepare App Store listing copy → `distribution/app-store-listing.md`
|
||||
- [x] Write CI workflow (draft) → `.github/workflows/publish-app-store.yml`
|
||||
- [ ] Create app icon 1024×1024 PNG
|
||||
- [ ] Capture iPhone screenshots (use iOS Simulator in Xcode on any Mac)
|
||||
- [ ] Capture iPad screenshots
|
||||
- [ ] Write/publish privacy policy at https://dzianisv.github.io/opencode-mobile/privacy/
|
||||
- [x] Create app icon 1024×1024 PNG
|
||||
- [ ] Replace placeholder iPhone screenshots with captures from the current iOS Simulator build
|
||||
- [ ] Replace placeholder iPad screenshots with captures from the current iOS Simulator build
|
||||
- [x] Write/publish privacy policy at https://dzianisv.github.io/opencode-mobile/privacy/
|
||||
- [ ] Set up EAS account at https://expo.dev/ (free tier, log in with Expo account)
|
||||
- [ ] Add iOS config patches to `app.json` (done in this PR)
|
||||
- [ ] Run `npx expo prebuild --platform ios` on a Mac to validate the Xcode project
|
||||
@@ -127,8 +127,8 @@ While waiting for Apple's verification call and approval:
|
||||
- Platform: iOS
|
||||
- Name: `OpenCode`
|
||||
- Primary Language: English (U.S.)
|
||||
- Bundle ID: `ai.opencode.mobile` — register this explicit App ID first at https://developer.apple.com/account/resources/identifiers/
|
||||
- SKU: `ai.opencode.mobile` (can match bundle ID)
|
||||
- Bundle ID: `cc.agentlabs.opencode` — register this explicit App ID first at https://developer.apple.com/account/resources/identifiers/
|
||||
- SKU: `cc.agentlabs.opencode` (can match bundle ID)
|
||||
|
||||
2. Configure App ID capabilities needed:
|
||||
- Push Notifications (for `expo-notifications`)
|
||||
@@ -141,7 +141,15 @@ While waiting for Apple's verification call and approval:
|
||||
- Note: Key ID and Issuer ID
|
||||
- Base64-encode the .p8 and store in GitHub secret `APPLE_APP_STORE_CONNECT_API_KEY`
|
||||
|
||||
4. Create an internal TestFlight group and add yourself as tester
|
||||
4. Run `eas init` once, then add the generated `extra.eas.projectId` UUID as the GitHub Actions repository variable `EAS_PROJECT_ID`.
|
||||
|
||||
5. Configure the EAS `production` environment for optional crash reporting:
|
||||
- `EXPO_PUBLIC_SENTRY_DSN`
|
||||
- `SENTRY_AUTH_TOKEN` (secret visibility)
|
||||
- `SENTRY_ORG`
|
||||
- `SENTRY_PROJECT`
|
||||
|
||||
6. Create an internal TestFlight group and add yourself as tester
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
<ul>
|
||||
<li>Open the app → <strong>Settings</strong> → <strong>Privacy</strong> →
|
||||
<strong>Crash reporting</strong> toggle.</li>
|
||||
<li>When the toggle is off, Sentry is never initialised and no data leaves your device.</li>
|
||||
<li>If you decline, Sentry is never initialised. If you turn reporting off later, the active SDK is closed and no new events are captured.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Third-Party Services</h2>
|
||||
|
||||
@@ -55,7 +55,7 @@ URL scrubbing: before any event is sent to Sentry, our code strips all server UR
|
||||
Crash reporting is **opt-in and off by default**. On first launch you will see a consent prompt. You can change this at any time:
|
||||
|
||||
- Open the app → **Settings** → **Privacy** → **Crash reporting** toggle.
|
||||
- When the toggle is off, Sentry is never initialised and no data leaves your device.
|
||||
- If you decline, Sentry is never initialised. If you turn reporting off later, the active SDK is closed and no new events are captured.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -224,6 +224,17 @@ footer a{margin:0 8px}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>iPhone & iPad <span class="badge">In development</span></h2>
|
||||
<p class="muted">
|
||||
Native iOS build and TestFlight automation are underway. There is no iOS download yet; availability
|
||||
will be announced only after a real TestFlight build is verified.
|
||||
</p>
|
||||
<div class="cta">
|
||||
<a class="btn" href="../ios/">Follow iOS progress</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
"name": "Is there an iOS app?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "No. OpenCode Mobile is Android only. There is no iOS or iPadOS build."
|
||||
"text": "Not for download yet. The iPhone and iPad build is in active development, including native build CI and TestFlight automation."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -392,7 +392,7 @@ OPENCODE_SERVER_PASSWORD=yourpassword opencode serve --hostname 0.0.0.0 --port 4
|
||||
</details>
|
||||
<details>
|
||||
<summary>Is there an iOS app?</summary>
|
||||
<p>No. OpenCode Mobile is Android only. There is no iOS or iPadOS build. More on the <a href="ios/">iOS / iPhone page</a>.</p>
|
||||
<p>Not for download yet. The iPhone and iPad build is in active development, including native build CI and TestFlight automation. Follow the <a href="ios/">iOS / iPhone progress page</a>.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Where do my API keys live?</summary>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenCode for iOS / iPhone — Is There an App? (Honest Answer)</title>
|
||||
<meta name="description" content="Is there an OpenCode app for iOS or iPhone? Honestly: not yet. Here's the current Android app, why iOS isn't available, the roadmap, and how to follow for updates." />
|
||||
<title>OpenCode for iOS / iPhone — TestFlight Work in Progress</title>
|
||||
<meta name="description" content="OpenCode Mobile for iPhone and iPad is in active development. Follow the native build, CI, and TestFlight launch work while Android remains available today." />
|
||||
<meta name="keywords" content="opencode ios, opencode iphone, opencode ipad, opencode ios app, AI coding agent iphone, opencode for apple" />
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
<link rel="canonical" href="https://dzianisv.github.io/opencode-mobile/ios/" />
|
||||
@@ -13,15 +13,15 @@
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:site_name" content="OpenCode Mobile" />
|
||||
<meta property="og:title" content="OpenCode for iOS / iPhone — Is There an App? (Honest Answer)" />
|
||||
<meta property="og:description" content="Is there an OpenCode app for iOS or iPhone? Honestly, not yet. Here's the current Android app, why iOS isn't available, the roadmap, and how to follow for updates." />
|
||||
<meta property="og:title" content="OpenCode for iOS / iPhone — TestFlight Work in Progress" />
|
||||
<meta property="og:description" content="The native iPhone and iPad build is being prepared now. Follow the CI and TestFlight launch work." />
|
||||
<meta property="og:url" content="https://dzianisv.github.io/opencode-mobile/ios/" />
|
||||
<meta property="og:image" content="https://dzianisv.github.io/opencode-mobile/og.png" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="OpenCode for iOS / iPhone — Is There an App? (Honest Answer)" />
|
||||
<meta name="twitter:description" content="Is there an OpenCode app for iOS or iPhone? Honestly, not yet. Here's the Android app, why, and the roadmap." />
|
||||
<meta name="twitter:title" content="OpenCode for iOS / iPhone — TestFlight Work in Progress" />
|
||||
<meta name="twitter:description" content="The native iPhone and iPad build is being prepared now. Follow the CI and TestFlight launch work." />
|
||||
<meta name="twitter:image" content="https://dzianisv.github.io/opencode-mobile/og.png" />
|
||||
|
||||
<!-- Structured data: Breadcrumb -->
|
||||
@@ -45,22 +45,22 @@
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is there an OpenCode app for iOS or iPhone?",
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "Not yet. OpenCode Mobile is currently Android only. There is no iOS, iPhone, or iPadOS build, and none is published on the App Store. The only official mobile app today is the Android client." }
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "Not for download yet. The shared React Native app is configured for iPhone and iPad, and native build plus TestFlight automation is in active development. No iOS build is published on TestFlight or the App Store today." }
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Why is there no OpenCode iOS app?",
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "It is a small, volunteer-driven open-source project. Building started on Android, and an iOS release also needs a paid Apple Developer account plus App Store review. The team would rather ship one platform well than two poorly. An iOS port is possible but not yet committed." }
|
||||
"name": "What remains before the OpenCode iOS beta?",
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "The generated native project and CI must pass on macOS, then Apple Developer and App Store Connect credentials must be configured so the first build can be submitted to TestFlight." }
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Can I use OpenCode on my iPhone right now in any way?",
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "There is no native iOS client. Because opencode runs as a server you control, you can reach its web interface from a mobile browser on iOS, but it is not optimized for phones. For a proper mobile experience today, the Android app is the supported option." }
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "There is no downloadable native iOS build yet. Because opencode runs as a server you control, you can reach its web interface from a mobile browser on iOS, but it is not optimized for phones. Android remains the supported native option until TestFlight opens." }
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How will I know when an iOS app is available?",
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "Watch or star the project on GitHub. Releases and any iOS news are announced there. You can also open or upvote a feature request to signal demand." }
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "Follow GitHub issue 65 for the implementation checklist, CI status, and TestFlight milestone. Releases will be announced from the repository." }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -168,15 +168,15 @@ footer a{margin:0 8px}
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<h1>OpenCode for iOS / iPhone</h1>
|
||||
<p class="lead"><strong>The honest answer: there isn't an iOS app yet.</strong> OpenCode Mobile is currently <strong>Android only</strong> — there is no iPhone or iPad build, and nothing on the App Store. If you landed here hoping for an iOS download, we'd rather tell you straight than waste your time.</p>
|
||||
<p>Here's the full picture: what exists today, why iOS isn't available yet, what your options are right now, and how to be the first to know if that changes.</p>
|
||||
<p class="lead"><strong>The iOS build is now in active development.</strong> OpenCode Mobile is configured for iPhone and iPad, with native build and TestFlight CI work underway. It is <strong>not downloadable yet</strong> and nothing is published on the App Store.</p>
|
||||
<p>This page tracks what is already complete, what still needs macOS and Apple access, and where to follow the launch.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="today">
|
||||
<div class="wrap">
|
||||
<h2>What exists today</h2>
|
||||
<p><a href="../">OpenCode Mobile</a> is a free, open-source (MIT) Android client for the <a href="https://github.com/sst/opencode">opencode</a> AI coding agent. It's live now — v0.4.3 — installable via F-Droid or a direct APK. It connects your phone to an opencode server you run yourself, so your code and AI provider keys stay on your own machine, with no telemetry by default.</p>
|
||||
<p><a href="../">OpenCode Mobile</a> is a free, open-source (MIT) client for the <a href="https://github.com/sst/opencode">opencode</a> AI coding agent. Android is installable today through F-Droid or a direct APK. The same React Native product is now configured for iOS with bundle ID <code>cc.agentlabs.opencode</code>, an App Store icon and metadata, local-network permission, and automated build work.</p>
|
||||
<div class="note">
|
||||
<p><strong>If you have an Android phone</strong> (or a spare one), it works today. See the <a href="../guide/">setup guide</a> or learn <a href="../opencode-on-phone/">how to use opencode on your phone</a>.</p>
|
||||
</div>
|
||||
@@ -185,13 +185,13 @@ footer a{margin:0 8px}
|
||||
|
||||
<section id="why">
|
||||
<div class="wrap">
|
||||
<h2>Why there's no iOS app (yet)</h2>
|
||||
<h2>What remains before TestFlight</h2>
|
||||
<ul class="tips">
|
||||
<li><strong>It's a small, volunteer open-source project.</strong> Development started on Android, and maintaining a quality second platform is real, ongoing work.</li>
|
||||
<li><strong>iOS has extra overhead.</strong> Shipping to iPhone needs a paid Apple Developer account and App Store review — a higher bar than Android's open distribution (F-Droid, direct APK).</li>
|
||||
<li><strong>Focus beats half-finished.</strong> The goal is to do one platform well rather than ship two mediocre apps. An iOS port is technically feasible but not yet committed.</li>
|
||||
<li><strong>Native macOS verification.</strong> The generated Xcode project must build and launch in iPhone and iPad Simulators, then the critical connection and chat flow must be exercised.</li>
|
||||
<li><strong>Apple configuration.</strong> The Apple Developer membership, App Store Connect app record, team ID, and API credentials must be active.</li>
|
||||
<li><strong>TestFlight proof.</strong> Release CI must build and submit the exact signed archive, and the build must appear for internal testers before we call iOS available.</li>
|
||||
</ul>
|
||||
<p class="muted">No vaporware promises here. If an iOS build happens, it'll be announced on GitHub — not pre-sold on a landing page.</p>
|
||||
<p class="muted">The public checklist and implementation live in <a href="https://github.com/dzianisv/opencode-mobile/issues/65">GitHub issue #65</a>. Availability will be claimed only after a real TestFlight install is verified.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -201,19 +201,19 @@ footer a{margin:0 8px}
|
||||
<ul class="tips">
|
||||
<li><strong>Use an Android device.</strong> Even an inexpensive or spare Android phone runs the full app today.</li>
|
||||
<li><strong>Use the opencode web/desktop interface.</strong> Since opencode runs as a server you control, you can reach it from a desktop or — unoptimized — from a mobile browser. It isn't a phone-native experience, but it works.</li>
|
||||
<li><strong>Register your interest.</strong> Open or upvote a feature request on GitHub so the demand for iOS is visible.</li>
|
||||
<li><strong>Follow the build.</strong> Track <a href="https://github.com/dzianisv/opencode-mobile/issues/65">issue #65</a> for native build, CI, and TestFlight progress.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="roadmap">
|
||||
<div class="wrap">
|
||||
<h2>Roadmap & how to follow</h2>
|
||||
<p>There's no committed iOS release date. The most reliable way to hear about it — and every other release — is straight from the repo:</p>
|
||||
<h2>Launch progress & how to follow</h2>
|
||||
<p>Implementation is committed; a release date is not. The remaining Mac and Apple-gated work is tracked in public:</p>
|
||||
<div class="cta">
|
||||
<a class="btn btn-primary" href="https://github.com/dzianisv/opencode-mobile">Star / watch on GitHub</a>
|
||||
<a class="btn" href="https://github.com/dzianisv/opencode-mobile/releases">View releases</a>
|
||||
<a class="btn" href="https://github.com/dzianisv/opencode-mobile/issues">Request iOS support</a>
|
||||
<a class="btn" href="https://github.com/dzianisv/opencode-mobile/issues/65">Follow iOS implementation</a>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:14px">Watching the repo notifies you of new releases, including any future iOS news.</p>
|
||||
</div>
|
||||
@@ -224,15 +224,15 @@ footer a{margin:0 8px}
|
||||
<h2>Frequently asked questions</h2>
|
||||
<details>
|
||||
<summary>Is there an OpenCode app for iOS or iPhone?</summary>
|
||||
<p>Not yet. OpenCode Mobile is Android only. There is no iOS, iPhone, or iPadOS build, and none on the App Store.</p>
|
||||
<p>Not for download yet. The iPhone and iPad build is in active development, but there is no verified TestFlight or App Store release today.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Can I use OpenCode on my iPhone at all right now?</summary>
|
||||
<p>There's no native iOS client. Because opencode runs as a server you control, you can open its web interface from a mobile browser, but it isn't optimized for phones. For a proper mobile experience, the Android app is the supported option.</p>
|
||||
<p>There's no downloadable native iOS build yet. Because opencode runs as a server you control, you can open its web interface from a mobile browser, but it isn't optimized for phones. Android remains the supported native option until TestFlight opens.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Will an iOS app ever come?</summary>
|
||||
<p>Possibly. It's technically feasible but not committed. Demand on GitHub helps; any news will be announced there.</p>
|
||||
<summary>Is the iOS app committed?</summary>
|
||||
<p>Yes. Native build, CI, and TestFlight work is active in <a href="https://github.com/dzianisv/opencode-mobile/issues/65">issue #65</a>. The launch still depends on macOS verification and Apple credentials.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Is the Android app free and private?</summary>
|
||||
|
||||
@@ -325,7 +325,7 @@ footer a{margin:0 8px}
|
||||
</details>
|
||||
<details>
|
||||
<summary>Does it work on iPhone?</summary>
|
||||
<p>Not yet. OpenCode Mobile is Android-only today. iOS is on the roadmap.</p>
|
||||
<p>Not for download yet. The iPhone and iPad build is in active development; follow the <a href="../ios/">iOS progress page</a> for native build and TestFlight status.</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
# Apple App Store — opencode-mobile
|
||||
|
||||
Operational doc for shipping `ai.opencode.mobile` to Apple App Store under VIBE TECHNOLOGIES, LLC.
|
||||
Operational doc for shipping `cc.agentlabs.opencode` to Apple App Store under VIBE TECHNOLOGIES, LLC.
|
||||
|
||||
For full company facts (D-U-N-S, address, governor) see `~/.agents/skills/vibetechnologies-llc/SKILL.md`.
|
||||
|
||||
---
|
||||
|
||||
## Account state (as of 2026-05-24)
|
||||
## Account state
|
||||
|
||||
The Apple account state below was last recorded on 2026-05-24. Re-verify it in the
|
||||
Apple Developer portal before running the release workflow; this Linux runner has no
|
||||
Apple or EAS credentials and cannot confirm enrollment status.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Apple ID email | `support@agentlabs.cc` (per decision 2026-05-24) |
|
||||
| Apple Developer Program | ❌ **not enrolled — user signing up now** |
|
||||
| Apple Developer Program | ⚠️ Last recorded as not enrolled; verify current status |
|
||||
| D-U-N-S (for org enrollment) | 142059652 |
|
||||
| Enrollment fee | $99/year (not yet paid) |
|
||||
| Identity verification call | ⏸ pending after enrollment submitted (Apple calls within 2-7 business days) |
|
||||
| App Store Connect record | ⏸ created after enrollment |
|
||||
| TestFlight | ⏸ available after enrollment |
|
||||
| App Store production | ⏸ after TestFlight + Apple review |
|
||||
| Enrollment fee | $99/year |
|
||||
| Identity verification call | ⚠️ Verify current status |
|
||||
| App Store Connect record | ⚠️ No app ID is configured in `eas.json` |
|
||||
| TestFlight | ⏸ No verified build yet |
|
||||
| App Store production | ⏸ After TestFlight + Apple review |
|
||||
|
||||
### Bundle identity
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Bundle identifier | `ai.opencode.mobile` (same as Android — same brand) |
|
||||
| Bundle identifier | `cc.agentlabs.opencode` (same as Android) |
|
||||
| Apple Team ID | ⏸ assigned at enrollment |
|
||||
| App Store Connect App ID | ⏸ assigned on first app creation |
|
||||
|
||||
@@ -57,14 +61,14 @@ Because the answer is "No", no ERN (Encryption Registration Number) is required
|
||||
## What's already done
|
||||
|
||||
1. ✅ iOS section of `app.json` patched:
|
||||
- `ios.buildNumber`: "1" (CI auto-bumps)
|
||||
- `ios.buildNumber`: "1" (initial value; EAS manages production build numbers remotely)
|
||||
- `ios.entitlements.aps-environment`: "production" (push notifications)
|
||||
- `ios.infoPlist.NSAppTransportSecurity.NSAllowsArbitraryLoads`: true (required — connects to user self-hosted opencode servers over HTTP on LAN)
|
||||
- Usage strings: NSFaceIDUsageDescription, NSSpeechRecognitionUsageDescription, NSMicrophoneUsageDescription, NSPhotoLibraryUsageDescription, NSCameraUsageDescription
|
||||
- Usage strings: NSFaceIDUsageDescription, NSSpeechRecognitionUsageDescription, NSMicrophoneUsageDescription, NSPhotoLibraryUsageDescription, NSCameraUsageDescription, NSLocalNetworkUsageDescription
|
||||
- Plugin registrations completed for `expo-notifications`, `expo-image-picker`, `expo-speech-recognition` (were missing — would have caused native iOS setup to silently skip)
|
||||
2. ✅ EAS Build config: `eas.json` with development/preview/production profiles (2 placeholders for App ID + Team ID)
|
||||
3. ✅ Build strategy chosen: **EAS Build** (Expo cloud, free tier 30 builds/mo, managed certs, EAS Submit handles TestFlight upload)
|
||||
4. ✅ CI workflow draft: `.github/workflows/publish-app-store.yml` (DRAFT — needs Apple secrets before enabling)
|
||||
4. ✅ CI workflows: `.github/workflows/ios-ci.yml` validates unsigned Simulator builds; `.github/workflows/publish-app-store.yml` fails fast until Apple/EAS setup is complete
|
||||
5. ✅ Listing copy drafted: `distribution/app-store-listing.md`
|
||||
6. ✅ Enrollment runbook: `distribution/ios-enrollment-runbook.md` (pre-filled with all VIBE TECHNOLOGIES, LLC fields)
|
||||
7. ✅ Release notes scaffold: `distribution/whatsnew-ios/release-notes-en-US.txt`
|
||||
@@ -78,28 +82,28 @@ Because the answer is "No", no ERN (Encryption Registration Number) is required
|
||||
| 1 | Sign in / create Apple ID for `support@agentlabs.cc` w/ 2FA | User | 🔴 user action required |
|
||||
| 2 | Enroll in Apple Developer Program ($99) | User | 🔴 user action required |
|
||||
| 3 | Pass Apple verification call | User | 🔴 user action required |
|
||||
| 4 | App icon — 1024×1024 PNG, opaque (no alpha) | ✅ Done | `assets/icon-appstore.png` (flattened from Android-produced `assets/icon.png`) |
|
||||
| 5 | iPhone screenshots 6.7" (1290×2796) + 6.5" (1242×2688) | ✅ Done | `distribution/app-store-graphics/iphone-67/{01,02,03}.png` + `iphone-65/` — 3 mockup screens: connection, chat, diff |
|
||||
| 6 | iPad screenshots 12.9" (2048×2732) | ✅ Done | `distribution/app-store-graphics/ipad-129/{01,02}.png` — 2 mockup screens |
|
||||
| 4 | App icon — 1024×1024 PNG, opaque (no alpha) | ✅ Done | `assets/icon.png` is RGB with no alpha channel |
|
||||
| 5 | iPhone screenshots 6.7" (1290×2796) + 6.5" (1242×2688) | Mac | 🔴 Placeholder mockups exist; recapture the current app in Simulator |
|
||||
| 6 | iPad screenshots 12.9" (2048×2732) | Mac | 🔴 Placeholder mockups exist; recapture the current app in Simulator |
|
||||
| 7 | Privacy policy — live at https://dzianisv.github.io/opencode-mobile/privacy/ | ✅ done | Live & verified (HTTP 200) on gh-pages. Content handled by Android agent (`distribution/privacy-policy.{md,html}`). iOS-specific ATT / nutrition label addendum written in `distribution/app-store-listing.md`. |
|
||||
| 8 | Privacy nutrition label (App Tracking + Data Collection) | ✅ Done | Updated in `distribution/app-store-listing.md` — ATT explicitly noted (not used), Sentry opt-in status documented |
|
||||
| 9 | Export compliance | ✅ Done | `ITSAppUsesNonExemptEncryption: false` added to `app.json`. Answers + rationale in this doc (see Export Compliance section above) and `distribution/app-store-listing.md`. |
|
||||
| 10 | ATS justification in App Review notes | ✅ Done | Full justification text in `distribution/app-store-listing.md` under "App Review Notes — ATS Justification" |
|
||||
| 11 | Reviewer test instructions | ✅ Done | Updated with correct command (`opencode serve --hostname 0.0.0.0`) in `distribution/app-store-listing.md` |
|
||||
| 12 | GitHub secrets: `EAS_TOKEN`, `APPLE_APP_STORE_CONNECT_API_KEY_ID`, `APPLE_APP_STORE_CONNECT_ISSUER_ID`, `APPLE_APP_STORE_CONNECT_API_KEY` (base64 .p8) | User | 🟡 post-enrollment — see `.github/workflows/publish-app-store.yml` header |
|
||||
| 12 | GitHub variable `EAS_PROJECT_ID`; secrets: `EXPO_TOKEN`, `APPLE_APP_STORE_CONNECT_API_KEY_ID`, `APPLE_APP_STORE_CONNECT_ISSUER_ID`, `APPLE_APP_STORE_CONNECT_API_KEY` (base64 .p8) | User | 🟡 post-enrollment — run `eas init`, then see `.github/workflows/publish-app-store.yml` |
|
||||
| 13 | Update `eas.json` placeholders: `ascAppId` + `appleTeamId` | User | 🟡 post-enrollment — see `eas.json.README.md` for click paths |
|
||||
| 14 | CI workflow validated | ✅ Done | `.github/workflows/publish-app-store.yml` structure verified; comment header updated with remaining gaps |
|
||||
| 14 | CI workflow validated | CI | 🟡 Linux checks pass; PR must prove the macOS Simulator build |
|
||||
| 15 | TestFlight release notes | ✅ Done | `distribution/whatsnew-ios/release-notes-en-US.txt` — polished, 1658 chars (limit 4000) |
|
||||
|
||||
---
|
||||
|
||||
## Publishing process (after enrollment + assets ready)
|
||||
|
||||
1. (manual) Sign in to App Store Connect, create app with bundle id `ai.opencode.mobile`.
|
||||
1. (manual) Sign in to App Store Connect, create app with bundle id `cc.agentlabs.opencode`.
|
||||
2. (manual) Generate App Store Connect API key (App Manager role) → download `.p8` → base64 encode → add as GitHub secret.
|
||||
3. (manual) Update `eas.json` placeholders (Team ID, ASC App ID).
|
||||
4. (manual) `eas login` + `eas build:configure` for first-time setup (managed signing).
|
||||
5. (automated) `git tag v0.2.x && git push --tags` → CI calls EAS Build → EAS Submit → IPA lands in TestFlight.
|
||||
4. (manual) `eas login` + `eas init` + `eas build:configure`; add the generated project UUID as repository variable `EAS_PROJECT_ID`.
|
||||
5. (automated) Publish a GitHub Release for the version tag → CI calls EAS Build → EAS Submit → IPA lands in TestFlight.
|
||||
6. (manual, first time) Add internal testers in App Store Connect → distribute via TestFlight.
|
||||
7. (manual) After internal testing OK → submit for App Store review (production).
|
||||
8. Apple review typically 24-48h. 90% of submissions reviewed within 24h.
|
||||
@@ -138,7 +142,8 @@ Upgrade to EAS $19/mo only if free-tier queue (10-30 min wait) becomes a problem
|
||||
|
||||
- `app.json` — iOS config (patched 2026-05-24)
|
||||
- `eas.json` — EAS build profiles (2 placeholders)
|
||||
- `.github/workflows/publish-app-store.yml` — DRAFT CI
|
||||
- `.github/workflows/ios-ci.yml` — PR/main unsigned iOS Simulator build gate
|
||||
- `.github/workflows/publish-app-store.yml` — fail-fast TestFlight release CI
|
||||
- `distribution/app-store-listing.md` — listing copy + answers
|
||||
- `distribution/ios-enrollment-runbook.md` — enrollment runbook
|
||||
- `distribution/whatsnew-ios/release-notes-en-US.txt` — release notes
|
||||
|
||||
@@ -152,7 +152,7 @@ When the opencode AI agent requests a file-access permission, the notification b
|
||||
**Files:** `app.json:29`, `app.json:38-39`
|
||||
**Description:** Both platforms allow HTTP connections, which is required for local/LAN servers. This is intentional and correct for the use case. However, neither the Play Store listing, App Store listing, nor a privacy policy document currently explains that HTTP connections may be made to user-provided servers. Google Play's Data Safety section and Apple's App Privacy report will flag arbitrary network access if not documented.
|
||||
**Remediation:**
|
||||
1. Update the privacy policy at `agentlabs.cc/opencode/privacy` to explain that the app connects to user-configured server addresses that may use HTTP.
|
||||
1. Update the canonical privacy policy at `https://dzianisv.github.io/opencode-mobile/privacy/` to explain that the app connects to user-configured server addresses that may use HTTP.
|
||||
2. In Play Store Data Safety: disclose "Other app performance data" collected (crash reports via Sentry — opt-in).
|
||||
**Status:** Open
|
||||
|
||||
|
||||
8
eas.json
8
eas.json
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 13.0.0"
|
||||
"version": ">= 21.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
@@ -17,9 +18,10 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": false,
|
||||
"environment": "production",
|
||||
"ios": {
|
||||
"distribution": "store"
|
||||
"distribution": "store",
|
||||
"autoIncrement": "buildNumber"
|
||||
},
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
|
||||
@@ -62,20 +62,23 @@ Alternatively, in App Store Connect:
|
||||
|
||||
| Field | Value | Notes |
|
||||
|---|---|---|
|
||||
| `appleId` | `appstore@agentlabs.cc` | The Apple ID used for App Store Connect login — update if different |
|
||||
| `appleId` | `support@agentlabs.cc` | The Apple ID used for App Store Connect login — update if different |
|
||||
| `distribution` (production ios) | `store` | Correct for App Store / TestFlight submissions |
|
||||
| `buildType` (production android) | `app-bundle` | Correct for Play Store AAB submissions |
|
||||
| `autoIncrement` | `false` | Build number is bumped by the CI workflow (github.run_number), not EAS |
|
||||
| `cli.version` | `>= 13.0.0` | Requires EAS CLI 13 or later; CI installs `eas-cli@13` |
|
||||
| `autoIncrement` | `buildNumber` | EAS increments the iOS build number remotely for every production build |
|
||||
| `appVersionSource` | `remote` | EAS is the source of truth for store build numbers |
|
||||
| `cli.version` | `>= 21.0.0` | CI installs the exact supported release, `eas-cli@21.0.0` |
|
||||
|
||||
---
|
||||
|
||||
## After filling in the placeholders
|
||||
|
||||
1. Commit the updated `eas.json` to the repo.
|
||||
2. Add the GitHub Actions secrets (see `.github/workflows/publish-app-store.yml` header for the exact list).
|
||||
3. Tag a release: `git tag v0.2.3 && git push --tags`
|
||||
4. The CI workflow will build the IPA via EAS and submit it to TestFlight automatically.
|
||||
1. Run `eas init` once to create/link the Expo project.
|
||||
2. Copy the generated `extra.eas.projectId` UUID and add it as the GitHub Actions repository variable `EAS_PROJECT_ID`. The release workflow injects it into `app.json` only on the runner.
|
||||
3. Commit the updated `eas.json` to the repo.
|
||||
4. Add the `EXPO_TOKEN` and App Store Connect API GitHub Actions secrets (see `.github/workflows/publish-app-store.yml` for the exact list).
|
||||
5. Publish a GitHub Release for the version tag (or manually dispatch the App Store workflow).
|
||||
6. The release event triggers CI to build the IPA via EAS and submit that exact build to TestFlight.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Modal, Linking } from "react-native"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { PRIVACY_POLICY_URL } from "../lib/links"
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
@@ -45,9 +46,7 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) {
|
||||
</View>
|
||||
|
||||
{/* Privacy policy link */}
|
||||
<TouchableOpacity
|
||||
onPress={() => Linking.openURL("https://agentlabs.cc/opencode/privacy")}
|
||||
>
|
||||
<TouchableOpacity onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
|
||||
<Text style={styles.privacyLink}>Read our full privacy policy</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
1
src/lib/links.ts
Normal file
1
src/lib/links.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const PRIVACY_POLICY_URL = "https://dzianisv.github.io/opencode-mobile/privacy/"
|
||||
@@ -232,7 +232,10 @@ export function createClient(config: ClientConfig) {
|
||||
try {
|
||||
yield JSON.parse(data)
|
||||
} catch (err) {
|
||||
console.warn("[SSE] Failed to parse event:", data.slice(0, 200), err)
|
||||
console.warn("[SSE] Failed to parse event", {
|
||||
length: data.length,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,18 @@ import * as Sentry from "@sentry/react-native"
|
||||
import appJson from "../../app.json"
|
||||
import { log } from "./logbuffer"
|
||||
import type { DiagnosticReport } from "./diagnostics"
|
||||
import { scrubUrl, scrubString, scrubObject } from "./scrub"
|
||||
|
||||
const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN
|
||||
const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown"
|
||||
|
||||
let enabled = false
|
||||
let handlersInstalled = false
|
||||
|
||||
export function initSentry() {
|
||||
if (enabled) return
|
||||
if (!DSN) {
|
||||
log.info("sentry", "no DSN configured — telemetry disabled")
|
||||
installGlobalHandlers(false)
|
||||
installGlobalHandlers()
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -49,10 +50,12 @@ export function initSentry() {
|
||||
return scrubEvent(event)
|
||||
},
|
||||
beforeBreadcrumb(crumb) {
|
||||
// Console output can contain malformed server payloads, prompts, or code.
|
||||
if (crumb.category === "console") return null
|
||||
if (crumb.data && typeof crumb.data === "object") {
|
||||
crumb.data = scrubObject(crumb.data as Record<string, unknown>)
|
||||
crumb.data = redactObject(crumb.data as Record<string, unknown>)
|
||||
}
|
||||
if (typeof crumb.message === "string") crumb.message = scrubString(crumb.message)
|
||||
if (typeof crumb.message === "string") crumb.message = redactString(crumb.message)
|
||||
return crumb
|
||||
},
|
||||
})
|
||||
@@ -62,7 +65,14 @@ export function initSentry() {
|
||||
} catch (e) {
|
||||
log.warn("sentry", "init failed", String(e))
|
||||
}
|
||||
installGlobalHandlers(enabled)
|
||||
installGlobalHandlers()
|
||||
}
|
||||
|
||||
export async function disableSentry() {
|
||||
if (!enabled) return
|
||||
enabled = false
|
||||
await Sentry.close()
|
||||
log.info("sentry", "disabled by user")
|
||||
}
|
||||
|
||||
// Install belt-and-braces global handlers. The Sentry RN SDK already wires
|
||||
@@ -72,7 +82,10 @@ export function initSentry() {
|
||||
// shared diagnostic report) even when Sentry is disabled.
|
||||
// * Telemetry-disabled builds still leave a breadcrumb that something blew
|
||||
// up, which is invaluable when triaging a user-shared report offline.
|
||||
function installGlobalHandlers(sentryEnabled: boolean) {
|
||||
function installGlobalHandlers() {
|
||||
if (handlersInstalled) return
|
||||
handlersInstalled = true
|
||||
|
||||
type GlobalErrorUtils = {
|
||||
getGlobalHandler?: () => (err: unknown, isFatal?: boolean) => void
|
||||
setGlobalHandler?: (handler: (err: unknown, isFatal?: boolean) => void) => void
|
||||
@@ -83,7 +96,7 @@ function installGlobalHandlers(sentryEnabled: boolean) {
|
||||
errorUtils.setGlobalHandler((err: unknown, isFatal?: boolean) => {
|
||||
const error = toError(err)
|
||||
log.error("crash", isFatal ? "FATAL" : "non-fatal", error.message, error.stack ?? "")
|
||||
if (sentryEnabled) {
|
||||
if (enabled) {
|
||||
Sentry.captureException(error, (scope) => {
|
||||
scope.setLevel(isFatal ? "fatal" : "error")
|
||||
scope.setTag("crash.source", "js-global")
|
||||
@@ -104,7 +117,7 @@ function installGlobalHandlers(sentryEnabled: boolean) {
|
||||
g.onunhandledrejection = (event) => {
|
||||
const error = toError(event?.reason)
|
||||
log.error("crash", "unhandled-rejection", error.message, error.stack ?? "")
|
||||
if (sentryEnabled) {
|
||||
if (enabled) {
|
||||
Sentry.captureException(error, (scope) => {
|
||||
scope.setLevel("error")
|
||||
scope.setTag("crash.source", "promise-rejection")
|
||||
@@ -130,24 +143,63 @@ function toError(value: unknown): Error {
|
||||
export { scrubUrl } from "./scrub"
|
||||
|
||||
function scrubEvent<T extends Sentry.Event>(event: T): T {
|
||||
if (event.request?.url) event.request.url = scrubUrl(event.request.url)
|
||||
if (event.message) event.message = scrubString(event.message)
|
||||
if (event.request?.url) event.request.url = "<redacted-url>"
|
||||
if (event.message) event.message = redactString(event.message)
|
||||
if (event.exception?.values) {
|
||||
for (const ex of event.exception.values) {
|
||||
if (ex.value) ex.value = scrubString(ex.value)
|
||||
if (ex.value) ex.value = redactString(ex.value)
|
||||
}
|
||||
}
|
||||
if (event.breadcrumbs) {
|
||||
event.breadcrumbs = event.breadcrumbs.filter((crumb) => crumb.category !== "console")
|
||||
for (const crumb of event.breadcrumbs) {
|
||||
if (typeof crumb.message === "string") crumb.message = scrubString(crumb.message)
|
||||
if (typeof crumb.message === "string") crumb.message = redactString(crumb.message)
|
||||
if (crumb.data && typeof crumb.data === "object") {
|
||||
crumb.data = scrubObject(crumb.data as Record<string, unknown>)
|
||||
crumb.data = redactObject(crumb.data as Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
function redactString(value: string): string {
|
||||
return value.replace(/https?:\/\/[^\s)\]}"']+/gi, "<redacted-url>")
|
||||
}
|
||||
|
||||
function redactObject(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const redacted: Record<string, unknown> = {}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (
|
||||
/^(?:id|.*Id|.*ID|url|host|hostname|port|address|server|serverUrl|target|endpoint|authorization|auth|token|password|secret|apiKey|username|cookie)$/i.test(
|
||||
key,
|
||||
)
|
||||
) {
|
||||
redacted[key] = "<redacted>"
|
||||
continue
|
||||
}
|
||||
if (typeof item === "string") {
|
||||
redacted[key] = redactString(item)
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(item)) {
|
||||
redacted[key] = item.map((entry) =>
|
||||
typeof entry === "string"
|
||||
? redactString(entry)
|
||||
: entry && typeof entry === "object"
|
||||
? redactObject(entry as Record<string, unknown>)
|
||||
: entry,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
redacted[key] = redactObject(item as Record<string, unknown>)
|
||||
continue
|
||||
}
|
||||
redacted[key] = item
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
// --- Helpers exposed to the rest of the app ------------------------------
|
||||
|
||||
export type Breadcrumb = {
|
||||
@@ -183,18 +235,14 @@ export function captureException(
|
||||
})
|
||||
}
|
||||
|
||||
export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown) {
|
||||
export function captureDiagnostic(report: DiagnosticReport) {
|
||||
log.info("sentry", "capture", report.classification, enabled ? "(uploading)" : "(local only)")
|
||||
if (!enabled) return
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("connect.classification", report.classification)
|
||||
scope.setTag("connect.scheme", report.scheme ?? "n/a")
|
||||
scope.setContext("connection", {
|
||||
url: scrubUrl(report.url),
|
||||
host: report.host,
|
||||
port: report.port,
|
||||
isHostname: report.isHostname,
|
||||
summary: report.summary,
|
||||
targetType: report.isHostname ? "hostname" : "ip-address",
|
||||
})
|
||||
scope.setContext("probes", {
|
||||
attempts: report.attempts.map((a) => ({
|
||||
@@ -202,13 +250,10 @@ export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown)
|
||||
ok: a.ok,
|
||||
status: a.status,
|
||||
durationMs: a.durationMs,
|
||||
error: a.error,
|
||||
cause: a.errorCause,
|
||||
})),
|
||||
})
|
||||
scope.setContext("device", report.device)
|
||||
const err = rawError instanceof Error ? rawError : new Error(`connect ${report.classification}: ${report.summary}`)
|
||||
Sentry.captureException(err)
|
||||
Sentry.captureException(new Error(`connect ${report.classification}`))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,13 +20,14 @@
|
||||
*/
|
||||
|
||||
import * as SecureStore from "expo-secure-store"
|
||||
import { initSentry, sentryEnabled } from "./sentry"
|
||||
import { disableSentry, initSentry, sentryEnabled } from "./sentry"
|
||||
|
||||
const CONSENT_KEY = "opencode_telemetry_consent"
|
||||
|
||||
export type ConsentState = "granted" | "denied" | "unknown"
|
||||
|
||||
let _resolved: boolean | null = null // null = unknown, true = granted, false = denied
|
||||
let transition = Promise.resolve()
|
||||
|
||||
/**
|
||||
* Load persisted consent from SecureStore.
|
||||
@@ -47,9 +48,8 @@ export async function loadTelemetryConsent(): Promise<ConsentState> {
|
||||
_resolved = null
|
||||
return "unknown"
|
||||
} catch {
|
||||
// SecureStore unavailable — don't clobber a previously resolved in-memory state.
|
||||
// Return unknown so the caller can surface the modal.
|
||||
return "unknown"
|
||||
_resolved = false
|
||||
return "denied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,14 +67,26 @@ export function hasTelemetryConsent(): boolean | null {
|
||||
* Persist the user's consent decision and, if granted and Sentry is not yet
|
||||
* running, initialise it immediately.
|
||||
*/
|
||||
export async function setTelemetryConsent(granted: boolean): Promise<void> {
|
||||
_resolved = granted
|
||||
export function setTelemetryConsent(granted: boolean): Promise<void> {
|
||||
const next = transition.then(() => applyTelemetryConsent(granted))
|
||||
transition = next.catch(() => undefined)
|
||||
return next
|
||||
}
|
||||
|
||||
async function applyTelemetryConsent(granted: boolean): Promise<void> {
|
||||
if (granted) {
|
||||
await SecureStore.setItemAsync(CONSENT_KEY, "granted")
|
||||
_resolved = true
|
||||
if (!sentryEnabled()) initSentry()
|
||||
return
|
||||
}
|
||||
|
||||
_resolved = false
|
||||
await disableSentry()
|
||||
try {
|
||||
await SecureStore.setItemAsync(CONSENT_KEY, granted ? "granted" : "denied")
|
||||
} catch {
|
||||
// Best-effort persist — in-memory state is still correct for this session.
|
||||
}
|
||||
if (granted && !sentryEnabled()) {
|
||||
initSentry()
|
||||
await SecureStore.setItemAsync(CONSENT_KEY, "denied")
|
||||
} catch (error) {
|
||||
await SecureStore.deleteItemAsync(CONSENT_KEY)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user