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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions apps/mobile/.eas/build/sideload-ios.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# The `sideload` profile in eas.json: a Release build for iPhone and iPad that
# no Apple team has signed, packaged as an IPA that a free Apple ID re-signs in
# Sideloadly or AltStore. EAS cannot export an unsigned IPA (every export
# method signs), so this archives with signing off and packs Payload/ itself.
# Each bundle is then signed ad hoc ("-") with the entitlements prebuild wrote,
# so a re-signer can read the App Group from the IPA; app.config.ts keeps
# nothing else in a release build without DEBATELAB_APPLE_ACCOUNT.
# See docs/development.md#sideloaded-ipa.
build:
name: Unsigned iOS device build for sideloading
steps:
- eas/checkout
- eas/install_node_modules
- eas/resolve_build_config
# Custom builds skip npm's eas-build-* hooks, so the patches run here:
# before prebuild, which copies the share extension's template, and again
# after it, because prebuild may reinstall node_modules.
- run:
name: Patch native modules before prebuild
command: npm run eas-build-post-install
- eas/prebuild
- run:
name: Patch native modules after prebuild
command: |
npm run eas-build-post-install
grep -l ALTAppGroups ios/DebateOwlShare/*.swift
- run:
name: Install pods
working_directory: ./ios
command: |
export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
pod install
- run:
name: Archive without signing
command: |
set -eo pipefail
mkdir -p ios/build/logs
LOG=ios/build/logs/xcodebuild.log
if ! xcodebuild archive \
-workspace ios/DebateOwl.xcworkspace \
-scheme DebateOwl \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-archivePath ios/build/DebateOwl.xcarchive \
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \
> "$LOG" 2>&1; then
grep -nE 'error:|\*\* ARCHIVE FAILED' "$LOG" | head -60 || true
tail -80 "$LOG"
exit 1
fi
tail -5 "$LOG"
- run:
name: Sign ad hoc and pack the IPA
command: |
set -eo pipefail
OUT=ios/build/Payload
rm -rf "$OUT" ios/build/*.ipa
mkdir -p "$OUT"
cp -R ios/build/DebateOwl.xcarchive/Products/Applications/DebateOwl.app "$OUT/"
APP="$OUT/DebateOwl.app"
sign() { codesign --force --sign - --timestamp=none --generate-entitlement-der "$@"; }
# Frameworks and dylibs first, then each extension, then the app.
find "$APP" -depth \( -name '*.framework' -o -name '*.dylib' \) -print0 | while IFS= read -r -d '' f; do sign "$f"; done
entitlements_for() {
case "$1" in
ExpoWidgetsTarget) echo ios/ExpoWidgetsTarget/ExpoWidgetsTarget.entitlements ;;
DebateOwlShare) echo ios/DebateOwlShare/ShareExtension.entitlements ;;
*) echo "No entitlements known for the extension $1; add it to sideload-ios.yml." >&2; return 1 ;;
esac
}
for x in "$APP"/PlugIns/*.appex; do
[ -e "$x" ] || continue
ENT=$(entitlements_for "$(basename "$x" .appex)")
sign --entitlements "$ENT" "$x"
done
sign --entitlements ios/DebateOwl/DebateOwl.entitlements "$APP"
echo "Extensions: $(ls "$APP/PlugIns" 2>/dev/null | tr '\n' ' ')"
for b in "$APP" "$APP"/PlugIns/*.appex; do
echo "== $(basename "$b")"
codesign -d --entitlements - --xml "$b" 2>/dev/null | plutil -p - || true
done
/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' -c 'Print :CFBundleShortVersionString' -c 'Print :CFBundleVersion' -c 'Print :MinimumOSVersion' "$APP/Info.plist"
(cd ios/build && zip -qry DebateOwl-sideload.ipa Payload)
ls -la ios/build/*.ipa
- eas/find_and_upload_build_artifacts
33 changes: 19 additions & 14 deletions apps/mobile/AGENTS.md

Large diffs are not rendered by default.

58 changes: 35 additions & 23 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins';
import { withAppIntents } from './plugins/with-app-intents';


// One config for Expo Go, the web export and the development build.
// DEBATELAB_BUILD=dev (set by the `development` profile in eas.json) adds the
// native modules Expo Go lacks. Without it the config is the one Expo Go has
// always used. DEBATELAB_APP_GROUP adds the timer's widget extension (its Live
// One config for Expo Go, the web export, the development build and the
// release build. DEBATELAB_BUILD=dev (set by the `development` profiles in
// eas.json) adds the native modules Expo Go lacks; DEBATELAB_BUILD=release
// (the `release-simulator` and `sideload` profiles) is the same app without
// the development client, with its JavaScript built in. Without either the
// config is the one Expo Go has always used. DEBATELAB_APP_GROUP adds the timer's widget extension (its Live
// Activity, Dynamic Island clock and lock-screen widget) and the share
// extension, which need the App Group `group.com.pistack.debateowl`, registered
// for `com.pistack.debateowl`, `com.pistack.debateowl.ExpoWidgetsTarget` and
Expand All @@ -24,24 +26,26 @@ import { withAppIntents } from './plugins/with-app-intents';
type PermissionStrings = { speechRecognition: string; microphone: string };

const build = process.env.DEBATELAB_BUILD;
/** A native build of the app's own: a development build or a release build. */
const native = build === 'dev' || build === 'release';
const appGroup = process.env.DEBATELAB_APP_GROUP;
// DEBATELAB_APPLE_ACCOUNT=1: the build is signed by DebateOwl's own Apple
// Developer team, whose App IDs carry the capabilities in docs/apple-platform.md
// (Sign in with Apple, Push, Time Sensitive Notifications, Associated Domains,
// In-App Purchase). Without it a development build keeps to what the borrowed
// signing profile allows.
const appleAccount = process.env.DEBATELAB_APPLE_ACCOUNT;
if (build !== undefined && build !== '' && build !== 'dev') {
throw new Error(`DEBATELAB_BUILD must be "dev" or unset, not "${build}".`);
if (build !== undefined && build !== '' && !native) {
throw new Error(`DEBATELAB_BUILD must be "dev", "release" or unset, not "${build}".`);
}
if (appleAccount !== undefined && appleAccount !== '' && appleAccount !== '1') {
throw new Error(`DEBATELAB_APPLE_ACCOUNT must be "1" or unset, not "${appleAccount}".`);
}
if (appleAccount === '1' && build !== 'dev') {
throw new Error('DEBATELAB_APPLE_ACCOUNT only applies to a development build (DEBATELAB_BUILD=dev).');
if (appleAccount === '1' && !native) {
throw new Error('DEBATELAB_APPLE_ACCOUNT only applies to a native build (DEBATELAB_BUILD=dev or release).');
}
if (appGroup && build !== 'dev') {
throw new Error('DEBATELAB_APP_GROUP only applies to a development build (DEBATELAB_BUILD=dev).');
if (appGroup && !native) {
throw new Error('DEBATELAB_APP_GROUP only applies to a native build (DEBATELAB_BUILD=dev or release).');
}

const bundleIdentifier = 'com.pistack.debateowl';
Expand Down Expand Up @@ -72,16 +76,21 @@ function iosLocale(strings: PermissionStrings) {
// installed, which adds it too; expo-apple-authentication's plugin, also
// applied on its own, adds Sign in with Apple. The borrowed signing profile
// has neither, so a development build drops both unless it is signed by
// DebateOwl's own account (DEBATELAB_APPLE_ACCOUNT).
const withoutAccountEntitlements: ConfigPlugin = (config) =>
withEntitlementsPlist(config, (mod) => {
delete mod.modResults['aps-environment'];
delete mod.modResults['com.apple.developer.applesignin'];
return mod;
});
// DebateOwl's own account (DEBATELAB_APPLE_ACCOUNT). A release build without
// that account is the sideloaded IPA, which a free Apple ID re-signs: a free
// team cannot have Associated Domains either, so it keeps only the App Group.
const withoutAccountEntitlements =
(opts: { associatedDomains: boolean }): ConfigPlugin =>
(config) =>
withEntitlementsPlist(config, (mod) => {
delete mod.modResults['aps-environment'];
delete mod.modResults['com.apple.developer.applesignin'];
if (!opts.associatedDomains) delete mod.modResults['com.apple.developer.associated-domains'];
return mod;
});

// Native config for the Current Debate widget, Home Screen quick actions and
// Spotlight. Called only for a development build (DEBATELAB_BUILD=dev), after the
// Spotlight. Called only for a native build (DEBATELAB_BUILD=dev or release), after the
// expo-widgets entry, so the Expo Go
// config never changes. Each part turns on only when it can work:
// - the widget needs the App Group, which is when app.config.ts adds expo-widgets;
Expand Down Expand Up @@ -182,7 +191,7 @@ export function withRound4(config: ExpoConfig, opts: { appGroup?: string; projec
}

// Round 6 (docs/apple-platform.md): Sign in with Apple and Google, the
// time-sensitive timer alert, Handoff and push, for development builds only,
// time-sensitive timer alert, Handoff and push, for native builds only,
// so the Expo Go config never changes. What needs DebateOwl's own Apple
// account waits for DEBATELAB_APPLE_ACCOUNT.
export function withApplePlatform(config: ExpoConfig, opts: { appleAccount: boolean }): ExpoConfig {
Expand All @@ -196,9 +205,11 @@ export function withApplePlatform(config: ExpoConfig, opts: { appleAccount: bool
};
config.extra = {
...config.extra,
// Google sign-in runs in any development build: the redirect comes back on the app's own scheme.
// Google sign-in runs in any native build: the redirect comes back on the app's own scheme.
googleSignIn: true,
signInWithApple: opts.appleAccount,
// Without the push entitlement iOS hands out no token, so the switch says so (src/lib/push.ts).
remotePush: opts.appleAccount,
};
if (!opts.appleAccount) return config;
config.ios = { ...config.ios, usesAppleSignIn: true };
Expand Down Expand Up @@ -268,7 +279,7 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => {
},
};

if (build !== 'dev') return config;
if (!native) return config;

const en = permissionStrings(projectRoot, 'en');
config.ios!.infoPlist!.CFBundleAllowMixedLocalizations = true;
Expand All @@ -281,8 +292,9 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => {
// once the provisioning profile has Associated Domains and the website serves
// apple-app-site-association; Expo Go cannot have them.
config.ios!.associatedDomains = ['applinks:debateowl.com', 'applinks:www.debateowl.com'];
// The development client only in a development build; a release build runs its own bundle.
if (build === 'dev') config.plugins!.push('expo-dev-client');
config.plugins!.push(
'expo-dev-client',
[
'expo-speech-recognition',
{ speechRecognitionPermission: en.speechRecognition, microphonePermission: en.microphone },
Expand Down Expand Up @@ -337,5 +349,5 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => {
if (appleAccount === '1') return withIntents;
// Registered before Expo applies the plugins list. Mods registered later act
// first, so this one runs after the plugins have written their entitlements.
return withoutAccountEntitlements(withIntents);
return withoutAccountEntitlements({ associatedDomains: build === 'dev' })(withIntents);
};
28 changes: 28 additions & 0 deletions apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,34 @@
"DEBATELAB_BUILD": "dev",
"DEBATELAB_APP_GROUP": "group.com.pistack.debateowl"
}
},
"release-simulator": {
"distribution": "internal",
"node": "22.23.2",
"ios": {
"simulator": true,
"buildConfiguration": "Release"
},
"env": {
"DEBATELAB_BUILD": "release",
"DEBATELAB_APP_GROUP": "group.com.pistack.debateowl"
}
},
"sideload": {
"distribution": "internal",
"withoutCredentials": true,
"node": "22.23.2",
"buildArtifactPaths": ["ios/build/logs/*"],
"ios": {
"config": "sideload-ios.yml",
"buildConfiguration": "Release",
"applicationArchivePath": "ios/build/*.ipa"
},
"env": {
"DEBATELAB_BUILD": "release",
"DEBATELAB_APP_GROUP": "group.com.pistack.debateowl",
"EXPO_PUBLIC_SUPABASE_ENV": "production"
}
}
}
}
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"start": "expo start --go",
"export:web": "expo export --platform web",
"typecheck": "tsc --noEmit && tsc --noEmit -p scripts",
"eas-build-post-install": "node scripts/patch-expo-widgets-bundle.mjs"
"eas-build-post-install": "node scripts/patch-expo-widgets-bundle.mjs && node scripts/patch-app-group-lookup.mjs"
},
"dependencies": {
"@debatelab/core": "*",
Expand Down
38 changes: 38 additions & 0 deletions apps/mobile/scripts/compiler-audit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { readdirSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';

const root = resolve(import.meta.dirname, '../../..');
const require = createRequire(resolve(root, 'package.json'));
const babel = require('@babel/core');
const compiler = require('babel-plugin-react-compiler');
const source = resolve(root, 'apps/mobile/src');
const files = readdirSync(source, { recursive: true }).filter((file) => file.endsWith('.tsx') && !file.endsWith('.web.tsx')).sort();
const skips = [];
let successes = 0;
for (const file of files) {
const filename = `apps/mobile/src/${file}`;
babel.transformSync(readFileSync(resolve(source, file), 'utf8'), {
filename,
configFile: false,
babelrc: false,
parserOpts: { sourceType: 'module', plugins: ['typescript', 'jsx'] },
plugins: [[compiler, {
target: '19',
logger: {
logEvent(_file, event) {
if (event.kind === 'CompileSuccess') successes += 1;
else if (event.kind === 'CompileError' || event.kind === 'CompileSkip') {
const detail = event.detail?.options ?? event.detail;
skips.push({
file: filename,
line: detail?.loc?.start?.line ?? detail?.details?.[0]?.loc?.start?.line ?? event.loc?.start?.line,
reason: detail?.reason ?? event.reason,
});
}
},
},
}]],
});
}
process.stdout.write(`${JSON.stringify({ files: files.length, successes, skips }, null, 2)}\n`);
65 changes: 65 additions & 0 deletions apps/mobile/scripts/patch-app-group-lookup.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* Lets the widgets, Live Activities and the share extension find their App
* Group in an IPA that AltStore re-signed with a free Apple ID. AltStore
* registers `group.com.pistack.debateowl` as `group.com.pistack.debateowl.<TEAMID>`
* and writes the new name into each bundle's Info.plist as `ALTAppGroups`;
* expo-widgets and expo-share-intent read only their own key, so they would
* look for a group the signature does not carry and the app would run without
* them. With this patch each lookup takes the first `ALTAppGroups` entry when
* there is one and its own key otherwise, so every other build is unchanged.
*
* EAS runs it after installing (`eas-build-post-install` in package.json; the
* `sideload` custom build calls that script itself, before and after
* prebuild, because prebuild copies the share extension's template). It is
* safe to run again. See docs/development.md#sideloaded-ipa.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';

const require = createRequire(import.meta.url);
const dir = (name) => path.dirname(require.resolve(`${name}/package.json`));
const altGroup = '(Bundle.main.object(forInfoDictionaryKey: "ALTAppGroups") as? [String])?.first';

const patches = [
{
file: path.join(dir('expo-widgets'), 'ios/WidgetsStorage.swift'),
from: 'Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String',
to: `${altGroup} ?? (Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String)`,
count: 1,
},
{
file: path.join(dir('expo-widgets'), 'ios/Widgets/TimelineProvider.swift'),
from: 'Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String',
to: `${altGroup} ?? (Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String)`,
count: 2,
},
{
file: path.join(dir('expo-share-intent'), 'ios/ExpoShareIntentModule.swift'),
from: 'Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier")\n as? String',
to: `${altGroup} ?? (Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier")\n as? String)`,
count: 1,
},
{
file: path.join(dir('expo-share-intent'), 'plugin/build/ios/ShareExtensionViewController.swift'),
from: 'let hostAppGroupIdentifier: String = "<GROUPIDENTIFIER>"',
to: `let hostAppGroupIdentifier: String = ${altGroup} ?? "<GROUPIDENTIFIER>"`,
count: 1,
},
];

for (const { file, from, to, count } of patches) {
const source = readFileSync(file, 'utf8');
const name = path.relative(path.dirname(path.dirname(file)), file);
if (source.includes('"ALTAppGroups"')) {
console.log(`${name}: already reads ALTAppGroups.`);
continue;
}
const found = source.split(from).length - 1;
if (found !== count) {
throw new Error(`${file}: expected ${count} App Group lookup(s), found ${found}; check whether this patch still fits.`);
}
writeFileSync(file, source.split(from).join(to));
console.log(`${name}: reads ALTAppGroups first.`);
}
Loading
Loading