From ff8dd26a2debaa1e9e95234b13aba64aceb6fca4 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:56:31 +0000 Subject: [PATCH 1/9] Add the development build's native modules expo-dev-client, expo-speech-recognition and expo-widgets, installed with `npx expo install` for SDK 57. The permission prompts for dictation are under mobile.permissions in all three catalogs. With expo-dev-client a direct dependency, `expo start` would default to the development client, so the start script now passes --go to keep the Expo Go run as it was. knip ignores the speech and widget modules: the app reaches them by native module name, not by import. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 5 +- knip.json | 4 +- package-lock.json | 138 +++++++++++++++++++++++++++++++++++++++ src/locales/en.json | 4 ++ src/locales/es.json | 4 ++ src/locales/hu.json | 4 ++ 6 files changed, 157 insertions(+), 2 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index db9201929..f422a6f74 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "main": "expo-router/entry", "scripts": { - "start": "expo start", + "start": "expo start --go", "export:web": "expo export --platform web", "typecheck": "tsc --noEmit && tsc --noEmit -p scripts" }, @@ -21,6 +21,7 @@ "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", + "expo-dev-client": "~57.0.20", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-haptics": "~57.0.3", @@ -32,10 +33,12 @@ "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", "expo-sharing": "~57.0.22", + "expo-speech-recognition": "^57.1.1", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", "expo-web-browser": "~57.0.3", + "expo-widgets": "~57.0.24", "i18next": "^23.16.8", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/knip.json b/knip.json index 76cb483c0..a50b8d57a 100644 --- a/knip.json +++ b/knip.json @@ -50,7 +50,9 @@ "scripts/**/*.ts" ], "ignoreDependencies": [ - "expo-updates" + "expo-updates", + "expo-speech-recognition", + "expo-widgets" ] } }, diff --git a/package-lock.json b/package-lock.json index b7a6f6c45..6b3bbe924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -130,6 +130,7 @@ "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", + "expo-dev-client": "~57.0.20", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-haptics": "~57.0.3", @@ -141,10 +142,12 @@ "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", "expo-sharing": "~57.0.22", + "expo-speech-recognition": "^57.1.1", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", "expo-web-browser": "~57.0.3", + "expo-widgets": "~57.0.24", "i18next": "^23.16.8", "react": "19.2.3", "react-dom": "19.2.3", @@ -602,6 +605,59 @@ "expo": "*" } }, + "apps/mobile/node_modules/expo-dev-client": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.20.tgz", + "integrity": "sha512-ko5FzWsUPuG4aiQfmqxQA5yAdmal6SSICB70KJLChQYzWo+XDoTpXRgEpzQSr1Ku8KyKW0lkEa48xbnKSgd1xQ==", + "license": "MIT", + "dependencies": { + "expo-dev-launcher": "~57.0.21", + "expo-dev-menu": "~57.0.20", + "expo-dev-menu-interface": "~57.0.0", + "expo-manifests": "~57.0.2", + "expo-updates-interface": "~57.0.2" + }, + "peerDependencies": { + "expo": "*" + } + }, + "apps/mobile/node_modules/expo-dev-launcher": { + "version": "57.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-57.0.21.tgz", + "integrity": "sha512-dXa1bhCEtSXn4y34rydBvArmoZtcLK7zY0FN3x1JFPTGh81OXUGZWhyzLCAI9nhI23L/MTy7cGCeNyeJmC1ITw==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^57.0.2", + "expo-dev-menu": "~57.0.20", + "expo-manifests": "~57.0.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "apps/mobile/node_modules/expo-dev-menu": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-57.0.20.tgz", + "integrity": "sha512-xpf7Rkj++sBA285GqWVJTtl5j/TychD980GKqWA5lVFKM41+xUNmyPMOfO18heAKvX9i620ZHVoHiBGD+6pCdA==", + "license": "MIT", + "dependencies": { + "expo-dev-menu-interface": "~57.0.0" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "apps/mobile/node_modules/expo-dev-menu-interface": { + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-57.0.0.tgz", + "integrity": "sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "apps/mobile/node_modules/expo-file-system": { "version": "57.0.7", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", @@ -694,6 +750,18 @@ "react": "*" } }, + "apps/mobile/node_modules/expo-manifests": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.2.tgz", + "integrity": "sha512-pPIIcKR73OShwlWMg8H90WRzKUd2krDdKlKAwuQV1FtUwE7H3hQEFw0ePFQY9yFoGMF/77ryYwNKfI5umMcCog==", + "license": "MIT", + "dependencies": { + "expo-json-utils": "~57.0.2" + }, + "peerDependencies": { + "expo": "*" + } + }, "apps/mobile/node_modules/expo-modules-core": { "version": "57.0.21", "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.21.tgz", @@ -831,6 +899,17 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-speech-recognition": { + "version": "57.1.1", + "resolved": "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-57.1.1.tgz", + "integrity": "sha512-e1RalFEvc7yui9Jnf1NiEYHT0jSPeH4hbMTafj1yjvcKN7HhZ7VrQigGUhBDB9AnDNjtPugbyPuCIjwHBBOCyw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-splash-screen": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.9.tgz", @@ -892,6 +971,15 @@ } } }, + "apps/mobile/node_modules/expo-updates-interface": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.2.tgz", + "integrity": "sha512-x3SA5toiHZEkW+5oeNdUM1Rvt6M3ExegQsSwS3Z1+S9gn9utGYTGBkb8MBQivhjIZiRlQYDIhauxA8kySQEWtQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "apps/mobile/node_modules/expo-web-browser": { "version": "57.0.3", "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.3.tgz", @@ -902,6 +990,50 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-widgets": { + "version": "57.0.24", + "resolved": "https://registry.npmjs.org/expo-widgets/-/expo-widgets-57.0.24.tgz", + "integrity": "sha512-jwSh1xk01Nw+ACwzjgxR2hK4HhJmA8mGSH6OKxiMgsplvsGBi1QHVCqPL5rXctN723ktEqZ1318DDC5m3R2u8Q==", + "license": "MIT", + "dependencies": { + "@expo/plist": "^0.8.1", + "@expo/ui": "~57.0.23" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "apps/mobile/node_modules/expo-widgets/node_modules/@expo/ui": { + "version": "57.0.23", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.23.tgz", + "integrity": "sha512-5QW1uzs8cGBlBKQvzNLDmmAeTCiJn4HVCYC2tddyOtXnXsUKGY7Rvgw6otr9Q2rPcOY5mC34wJIspViamvptww==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.1.0", + "vaul": "^1.1.2" + }, + "peerDependencies": { + "@babel/core": "*", + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-worklets": "*" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-worklets": { + "optional": true + } + } + }, "apps/mobile/node_modules/expo/node_modules/@expo/cli": { "version": "57.0.28", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.28.tgz", @@ -12061,6 +12193,12 @@ "node": ">=12.0.0" } }, + "node_modules/expo-json-utils": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.2.tgz", + "integrity": "sha512-9kYZECLKE3pYZQs+kt3+ggY83Lfryog9/UEo3HJKGcHUZeKKtlFBIPFjI4LzREubsvjP7AsvG0FdEX9EquIoAQ==", + "license": "MIT" + }, "node_modules/expo-modules-autolinking": { "version": "57.0.14", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.14.tgz", diff --git a/src/locales/en.json b/src/locales/en.json index b995e085e..2343a3af6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2619,6 +2619,10 @@ "discardTitle": "Start a new map?", "discardDesc": "Your unsaved map will be replaced by the starter map.", "startNew": "Start new" + }, + "permissions": { + "speechRecognition": "DebateLab turns your voice into text when you dictate a question, a Crossfire answer or a Sparring speech. Apple processes the audio to recognize it.", + "microphone": "DebateLab uses the microphone while you dictate a question, a Crossfire answer or a Sparring speech." } } } diff --git a/src/locales/es.json b/src/locales/es.json index 99eb6035b..6fc4c55ec 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2619,6 +2619,10 @@ "discardTitle": "¿Empezar un mapa nuevo?", "discardDesc": "Tu mapa sin guardar se sustituirá por el mapa inicial.", "startNew": "Empezar nuevo" + }, + "permissions": { + "speechRecognition": "DebateLab convierte tu voz en texto cuando dictas una pregunta, una respuesta de Interrogatorio o un discurso de Sparring. Apple procesa el audio para reconocerlo.", + "microphone": "DebateLab usa el micrófono mientras dictas una pregunta, una respuesta de Interrogatorio o un discurso de Sparring." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index aa83b303b..57ab5794b 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2619,6 +2619,10 @@ "discardTitle": "Új térképet kezdesz?", "discardDesc": "A mentetlen térképed helyére a kezdő térkép kerül.", "startNew": "Új kezdése" + }, + "permissions": { + "speechRecognition": "A DebateLab szöveggé alakítja a hangodat, amikor kérdést, keresztkérdezési választ vagy vitapárbaj-beszédet diktálsz. A hangot az Apple dolgozza fel a felismeréshez.", + "microphone": "A DebateLab a mikrofont használja, amíg kérdést, keresztkérdezési választ vagy vitapárbaj-beszédet diktálsz." } } } From cf3c52e03d25945d52bdf13fbf2aabba5df8f151 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:56:41 +0000 Subject: [PATCH 2/9] Move the app config to app.config.ts with a development-build flag Without DEBATELAB_BUILD the config is exactly the old app.json, so Expo Go and the web export are unchanged (`expo config --json` differs only in _internal's config path). DEBATELAB_BUILD=dev adds expo-dev-client, speech recognition with en usage strings and es/hu InfoPlist.strings from the catalogs. DEBATELAB_APP_GROUP also adds the expo-widgets extension and its App Group, and drops the push entitlement expo-widgets adds even with push off, since the profiles we can sign with today carry no push. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/app.config.ts | 130 ++++++++++++++++++++++++++++++ apps/mobile/app.json | 47 ----------- apps/mobile/scripts/tsconfig.json | 2 +- 3 files changed, 131 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/app.config.ts delete mode 100644 apps/mobile/app.json diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts new file mode 100644 index 000000000..247e3d281 --- /dev/null +++ b/apps/mobile/app.config.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { ConfigContext, ExpoConfig } from 'expo/config'; +import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins'; + +// 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 lock-screen timer's widget +// extension, which needs an App Group: set it only when the provisioning +// profile carries that group. See docs/development.md#development-build. + +type PermissionStrings = { speechRecognition: string; microphone: string }; + +const build = process.env.DEBATELAB_BUILD; +const appGroup = process.env.DEBATELAB_APP_GROUP; +if (build !== undefined && build !== '' && build !== 'dev') { + throw new Error(`DEBATELAB_BUILD must be "dev" or unset, not "${build}".`); +} +if (appGroup && build !== 'dev') { + throw new Error('DEBATELAB_APP_GROUP only applies to a development build (DEBATELAB_BUILD=dev).'); +} + +const bundleIdentifier = 'com.adam.debatelab'; + +function permissionStrings(projectRoot: string, language: 'en' | 'es' | 'hu'): PermissionStrings { + const file = path.join(projectRoot, '../../src/locales', `${language}.json`); + return JSON.parse(readFileSync(file, 'utf8')).mobile.permissions; +} + +// Prebuild writes these into InfoPlist.strings as `KEY = "value";` without escaping. +const stringsValue = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + +function iosLocale(strings: PermissionStrings) { + return { + ios: { + NSSpeechRecognitionUsageDescription: stringsValue(strings.speechRecognition), + NSMicrophoneUsageDescription: stringsValue(strings.microphone), + }, + }; +} + +// expo-widgets adds the push entitlement even with its push option off, and a +// profile without push cannot sign it. +const withoutPushEntitlement: ConfigPlugin = (config) => + withEntitlementsPlist(config, (mod) => { + delete mod.modResults['aps-environment']; + return mod; + }); + +export default ({ projectRoot }: ConfigContext): ExpoConfig => { + const config: ExpoConfig = { + name: 'DebateLab', + slug: 'debatelab', + version: '1.0.0', + orientation: 'default', + icon: './assets/images/icon.png', + scheme: 'debatelab', + userInterfaceStyle: 'automatic', + ios: { + bundleIdentifier, + supportsTablet: true, + infoPlist: { + CADisableMinimumFrameDurationOnPhone: true, + }, + }, + android: { + adaptiveIcon: { + backgroundColor: '#E6F4FE', + foregroundImage: './assets/images/android-icon-foreground.png', + backgroundImage: './assets/images/android-icon-background.png', + monochromeImage: './assets/images/android-icon-monochrome.png', + }, + predictiveBackGestureEnabled: false, + }, + web: { + output: 'static', + favicon: './assets/images/favicon.png', + }, + plugins: [ + 'expo-router', + [ + 'expo-splash-screen', + { + backgroundColor: '#eef4fa', + image: './assets/images/splash-icon.png', + imageWidth: 76, + dark: { backgroundColor: '#0f172a' }, + }, + ], + ], + experiments: { + typedRoutes: true, + reactCompiler: true, + }, + }; + + if (build !== 'dev') return config; + + const en = permissionStrings(projectRoot, 'en'); + config.ios!.infoPlist!.CFBundleAllowMixedLocalizations = true; + // English is the base Info.plist (from the plugin below); these translate it. + config.locales = { + es: iosLocale(permissionStrings(projectRoot, 'es')), + hu: iosLocale(permissionStrings(projectRoot, 'hu')), + }; + config.plugins!.push( + 'expo-dev-client', + [ + 'expo-speech-recognition', + { speechRecognitionPermission: en.speechRecognition, microphonePermission: en.microphone }, + ], + ); + if (appGroup) { + config.plugins!.push([ + 'expo-widgets', + { + bundleIdentifier: `${bundleIdentifier}.ExpoWidgetsTarget`, + groupIdentifier: appGroup, + enablePushNotifications: false, + widgets: [], + }, + ]); + // Registered before Expo applies the plugins list. Mods registered later act + // first, so this one runs after expo-widgets has written its entitlements. + return withoutPushEntitlement(config); + } + return config; +}; diff --git a/apps/mobile/app.json b/apps/mobile/app.json deleted file mode 100644 index c68036b1a..000000000 --- a/apps/mobile/app.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "expo": { - "name": "DebateLab", - "slug": "debatelab", - "version": "1.0.0", - "orientation": "default", - "icon": "./assets/images/icon.png", - "scheme": "debatelab", - "userInterfaceStyle": "automatic", - "ios": { - "bundleIdentifier": "com.adam.debatelab", - "supportsTablet": true, - "infoPlist": { - "CADisableMinimumFrameDurationOnPhone": true - } - }, - "android": { - "adaptiveIcon": { - "backgroundColor": "#E6F4FE", - "foregroundImage": "./assets/images/android-icon-foreground.png", - "backgroundImage": "./assets/images/android-icon-background.png", - "monochromeImage": "./assets/images/android-icon-monochrome.png" - }, - "predictiveBackGestureEnabled": false - }, - "web": { - "output": "static", - "favicon": "./assets/images/favicon.png" - }, - "plugins": [ - "expo-router", - [ - "expo-splash-screen", - { - "backgroundColor": "#eef4fa", - "image": "./assets/images/splash-icon.png", - "imageWidth": 76, - "dark": { "backgroundColor": "#0f172a" } - } - ] - ], - "experiments": { - "typedRoutes": true, - "reactCompiler": true - } - } -} diff --git a/apps/mobile/scripts/tsconfig.json b/apps/mobile/scripts/tsconfig.json index 2d090c4c3..ca3a5811c 100644 --- a/apps/mobile/scripts/tsconfig.json +++ b/apps/mobile/scripts/tsconfig.json @@ -5,5 +5,5 @@ "lib": ["DOM", "ESNext"], "types": ["node"] }, - "include": ["*.ts"] + "include": ["*.ts", "../app.config.ts"] } From 22c842ffc4f3b883643a9d8324f9a90d899f1b40 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:57:19 +0000 Subject: [PATCH 3/9] Add the EAS development profile with local credentials One profile: an internal-distribution development client built with DEBATELAB_BUILD=dev, signed from a credentials.json that is written from the secrets on build day and never committed (gitignored with the credentials/ folder). Node is pinned to the version the repo uses. No preview or production profile yet: each would be one more build to pay for. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/.gitignore | 4 ++++ apps/mobile/eas.json | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 apps/mobile/eas.json diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index 4b00baf34..90416c312 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -41,3 +41,7 @@ example # generated native folders /ios /android + +# EAS local credentials, written on build day and deleted after +credentials.json +credentials/ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 000000000..3867fcac2 --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,17 @@ +{ + "cli": { + "version": ">= 24.0.0", + "appVersionSource": "local" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "credentialsSource": "local", + "node": "22.23.2", + "env": { + "DEBATELAB_BUILD": "dev" + } + } + } +} From 2ce8357b5366a71285a9d29a6298274c0e764f6e Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:58:16 +0000 Subject: [PATCH 4/9] Report which native capabilities the running binary has nativeCapabilities() says whether speech recognition and a Live Activity are available. In Expo Go it answers false without looking any native module up; elsewhere it uses requireOptionalNativeModule, and counts a Live Activity only when expo-widgets has its App Group container. Nothing calls it yet: the voice and lock-screen timer PRs will. Co-Authored-By: Claude Opus 5.5 --- .../src/lib/native-capabilities.test.ts | 51 +++++++++++++++++++ apps/mobile/src/lib/native-capabilities.ts | 29 +++++++++++ 2 files changed, 80 insertions(+) create mode 100644 apps/mobile/src/lib/native-capabilities.test.ts create mode 100644 apps/mobile/src/lib/native-capabilities.ts diff --git a/apps/mobile/src/lib/native-capabilities.test.ts b/apps/mobile/src/lib/native-capabilities.test.ts new file mode 100644 index 000000000..5e9d1432c --- /dev/null +++ b/apps/mobile/src/lib/native-capabilities.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const env = vi.hoisted(() => ({ + executionEnvironment: 'storeClient', + modules: {} as Record, +})); +const requireOptionalNativeModule = vi.hoisted(() => vi.fn((name: string) => env.modules[name] ?? null)); + +vi.mock('expo', () => ({ requireOptionalNativeModule })); +vi.mock('expo-constants', () => ({ + default: { + get executionEnvironment() { + return env.executionEnvironment; + }, + }, + ExecutionEnvironment: { Bare: 'bare', Standalone: 'standalone', StoreClient: 'storeClient' }, +})); + +import { nativeCapabilities } from './native-capabilities'; + +describe('nativeCapabilities', () => { + beforeEach(() => { + requireOptionalNativeModule.mockClear(); + }); + + it('reports nothing in Expo Go and never looks a native module up', () => { + env.executionEnvironment = 'storeClient'; + // Even if a lookup would succeed, Expo Go must not be asked. + env.modules = { ExpoSpeechRecognition: {}, ExpoWidgets: { widgetsDirectory: 'file:///group/ExpoWidgets/' } }; + expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false }); + expect(requireOptionalNativeModule).not.toHaveBeenCalled(); + }); + + it('reports speech but no Live Activity in a development build without an App Group', () => { + env.executionEnvironment = 'bare'; + env.modules = { ExpoSpeechRecognition: {}, ExpoWidgets: { widgetsDirectory: null } }; + expect(nativeCapabilities()).toEqual({ speechRecognition: true, liveActivity: false }); + }); + + it('reports both when the build has the speech module and the App Group container', () => { + env.executionEnvironment = 'bare'; + env.modules = { ExpoSpeechRecognition: {}, ExpoWidgets: { widgetsDirectory: 'file:///group/ExpoWidgets/' } }; + expect(nativeCapabilities()).toEqual({ speechRecognition: true, liveActivity: true }); + }); + + it('reports nothing where the modules are missing, as in the web preview', () => { + env.executionEnvironment = 'bare'; + env.modules = {}; + expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false }); + }); +}); diff --git a/apps/mobile/src/lib/native-capabilities.ts b/apps/mobile/src/lib/native-capabilities.ts new file mode 100644 index 000000000..44bf0821e --- /dev/null +++ b/apps/mobile/src/lib/native-capabilities.ts @@ -0,0 +1,29 @@ +import { requireOptionalNativeModule } from 'expo'; +import Constants, { ExecutionEnvironment } from 'expo-constants'; + +// What the running binary can do beyond Expo Go. A development build links +// these modules only when app.config.ts ran with DEBATELAB_BUILD=dev, and +// Expo Go never has them, so it is not even asked: no import of a module +// that is missing, no crash. + +export type NativeCapabilities = { + /** expo-speech-recognition is linked. */ + speechRecognition: boolean; + /** expo-widgets is linked and its App Group container exists, so a Live Activity can start. */ + liveActivity: boolean; +}; + +type WidgetsModule = { widgetsDirectory?: string | null }; + +export function nativeCapabilities(): NativeCapabilities { + if (Constants.executionEnvironment === ExecutionEnvironment.StoreClient) { + return { speechRecognition: false, liveActivity: false }; + } + // expo-widgets links into every development build, but without the App + // Group (DEBATELAB_APP_GROUP) it has no extension and no container. + const widgets = requireOptionalNativeModule('ExpoWidgets'); + return { + speechRecognition: requireOptionalNativeModule('ExpoSpeechRecognition') != null, + liveActivity: !!widgets?.widgetsDirectory, + }; +} From 3baecfcc16ab8221dece93068c4c43079e7084d3 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:19:10 +0000 Subject: [PATCH 5/9] Document the development build and the build-day runbook docs/development.md gains a Development build section: what the two config switches add, how to inspect a mode without a Mac, the build budget, where the signing files live, and the exact commands for build day (decode the profile and match its certificate to the .p12, link the project, count builds, write credentials.json without printing the password, build with --no-wait, clean up) plus how Adam installs it. The Expo Go command now passes --go. The app's AGENTS.md says how a native module Expo Go lacks may enter the app. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- apps/mobile/AGENTS.md | 9 +-- docs/development.md | 129 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca1c7aa93..b11b10cd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ The local Supabase stack still uses shared default ports and container names. In - `src/` is the React 18 and TypeScript client. Routes and lazy page entry points live in `src/App.tsx`. - `src/lib/stores/` owns browser state and Supabase hydration. `src/lib/ai/client.ts` is the client AI boundary. - `packages/core/` (`@debatelab/core`, npm workspace) holds platform-neutral shared code: Zod schemas, validation, languages, Crossfire and Sparring logic. No DOM globals, React, or `import.meta` there; old `src/lib/` paths re-export it. Stores and i18n catalogs stay in `src/`. -- `apps/mobile/` (`@debatelab/mobile`, npm workspace) is the Expo iOS app (React 19, Expo Router). It imports `@debatelab/core`, the `src/locales` catalogs and `src/lib/supabase/projects.ts`; milestone 1 runs in Expo Go, so only Expo Go's native modules. Read [apps/mobile/AGENTS.md](apps/mobile/AGENTS.md) before changing it. +- `apps/mobile/` (`@debatelab/mobile`, npm workspace) is the Expo iOS app (React 19, Expo Router). It imports `@debatelab/core`, the `src/locales` catalogs and `src/lib/supabase/projects.ts`; it runs in Expo Go, and native modules Expo Go lacks go only into the development build (`DEBATELAB_BUILD=dev` in `app.config.ts`). Read [apps/mobile/AGENTS.md](apps/mobile/AGENTS.md) before changing it. - `supabase/functions/` contains Deno Edge Functions. Shared auth, CORS, research, prompts, cost, and validation code lives in `supabase/functions/_shared/`. - `supabase/migrations/` is the database history and schema source of truth. - `src/lib/stores/debate-store.ts` owns the Current Debate: every source and saved result belongs to one debate or to one-off work, and nothing crosses debates. diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 5c2f84e46..cddf81810 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -4,7 +4,8 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Rules -- Milestone 1 runs in the App Store's Expo Go, with no EAS builds. Every native module must be listed in `node_modules/expo/bundledNativeModules.json`. Check before adding a dependency, and install with `npx expo install ` from this folder so versions match the SDK. +- The app must keep running in the App Store's Expo Go. Install with `npx expo install ` from this folder so versions match the SDK. A native module that is not in `node_modules/expo/bundledNativeModules.json` belongs to the development build only: add its config plugin under `DEBATELAB_BUILD=dev` in `app.config.ts`, check `nativeCapabilities()` (`src/lib/native-capabilities.ts`) before using it, and never import it at the top level of a file Expo Go loads. +- A new native module, permission, entitlement or extension means a new cloud build, which has a strict budget (at most one a day and three a week without asking Adam). Batch native changes and read [docs/development.md](../../docs/development.md#development-build) before asking for one. Never run `eas build` or another EAS command that needs the account outside that runbook. - Expo changes between SDKs. Read the docs for SDK 57 (`https://docs.expo.dev/versions/v57.0.0/`), not `latest`. - React 18 (website) and React 19 (app) coexist in one workspace. Do not add a root `overrides` entry for react. - Shared logic comes from `@debatelab/core` (`packages/core`). Stores, the AI client and other `src/` wiring stay in the website. @@ -12,14 +13,14 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - The Supabase project URLs and anon keys live once, in `src/lib/supabase/projects.ts` at the repo root; `src/lib/supabase-config.ts` imports them. The app uses staging unless a build sets `EXPO_PUBLIC_SUPABASE_ENV=production`. - Google sign-in needs a development build, because Expo Go cannot receive OAuth redirects. Until then, sign-in is email code or demo code. - Native code has no DOM. eslint rejects `window`, `document`, `localStorage`, `sessionStorage` and `location` outside `*.web.ts(x)` files. A `.web.tsx` file exists only for the web preview. -- `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.json`. +- `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.config.ts`; without `DEBATELAB_BUILD` it must stay exactly the Expo Go config (compare `npx expo config --json` before and after). `eas.json` holds the one `development` profile; signing comes from a gitignored `credentials.json` written on build day. - A form sheet's root view is `SheetScrollView` (`src/components/sheet.tsx`), with nothing around it: no `KeyboardAvoidingView`, no wrapping `View`. react-native-screens positions only a scroll view that is the sheet's direct child; a nested one lands below the visible sheet and the sheet looks empty. ## Layout - `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), and `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), full screen above the tabs. All are declared in the root `_layout.tsx`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. -- `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. +- `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. - Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs`, `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. @@ -43,4 +44,4 @@ PORT= npx tsx apps/mobile/scripts/screenshots.ts Screenshots go to `.verify/mobile-shots/`. For signed-in screens, see the header of `scripts/screenshots.ts`. To load the app on a phone, see [docs/development.md](../../docs/development.md#mobile-app). -knip ignores `expo-updates`: its Expo plugin expects the package whenever `app.json` does not disable updates, and EAS Update is not set up yet. +knip ignores `expo-updates`: its Expo plugin expects the package whenever the app config does not disable updates, and EAS Update is not set up yet. It also ignores `expo-speech-recognition` and `expo-widgets`, which the app reaches by native module name, not by import. diff --git a/docs/development.md b/docs/development.md index f6ba1c2a1..bec17b358 100644 --- a/docs/development.md +++ b/docs/development.md @@ -97,7 +97,7 @@ Run the closest unit or component test while changing behavior. Run lint, type c ## Mobile app -The iOS app lives in [`apps/mobile/`](../apps/mobile/AGENTS.md). The root `npm ci` installs it with every other workspace, and the root `lint` and `typecheck` cover it. Install its dependencies with `npx expo install ` from `apps/mobile`, and only native modules that Expo Go bundles. +The iOS app lives in [`apps/mobile/`](../apps/mobile/AGENTS.md). The root `npm ci` installs it with every other workspace, and the root `lint` and `typecheck` cover it. Install its dependencies with `npx expo install ` from `apps/mobile`. The app must keep running in the App Store's Expo Go; a native module Expo Go lacks belongs only to the [development build](#development-build). ### Screenshots on the VPS @@ -118,10 +118,10 @@ The phone loads the app from a Metro dev server on the VPS over the tailnet. Sta PREVIEW=~/.claude/skills/tailnet-preview/scripts/preview HOST=$(tailscale status --self --json | jq -r '.Self.TailscaleIPs[0]') cd apps/mobile -$PREVIEW run 9230 "debatelab mobile expo-go" -- env REACT_NATIVE_PACKAGER_HOSTNAME=$HOST CI=1 npx expo start --port 9230 +$PREVIEW run 9230 "debatelab mobile expo-go" -- env REACT_NATIVE_PACKAGER_HOSTNAME=$HOST CI=1 npx expo start --go --port 9230 ``` -`REACT_NATIVE_PACKAGER_HOSTNAME` makes Metro put the VPS's tailnet IP in the manifest, so Expo Go fetches the bundle from `http://:9230`. iOS allows plain HTTP to IP addresses on local networks, and the firewall accepts all traffic on `tailscale0`, so the phone reaches that port directly. In Expo Go, enter `exp://:9230` as the URL, or open that link in Safari. `CI=1` stops Expo waiting for keyboard input in the background. To check the server without a phone: +`REACT_NATIVE_PACKAGER_HOSTNAME` makes Metro put the VPS's tailnet IP in the manifest, so Expo Go fetches the bundle from `http://:9230`. iOS allows plain HTTP to IP addresses on local networks, and the firewall accepts all traffic on `tailscale0`, so the phone reaches that port directly. In Expo Go, enter `exp://:9230` as the URL, or open that link in Safari. `CI=1` stops Expo waiting for keyboard input in the background. `--go` is needed because `expo-dev-client` is installed: without it Expo starts in development-client mode. To check the server without a phone: ```bash curl -s -H 'expo-platform: ios' http://127.0.0.1:9230/ | jq -r .launchAsset.url @@ -135,6 +135,129 @@ To sign the CLI in to an Expo account, run `npx expo login` from `apps/mobile`, Stop it with `$PREVIEW down "debatelab mobile expo-go"` when you are done. +### Development build + +A development build is the app's own binary with `expo-dev-client`, so it loads JavaScript from Metro like Expo Go but carries native modules Expo Go lacks: speech recognition for dictation now, and later Google sign-in (Expo Go cannot receive OAuth redirects) and the lock-screen timer. Its Info.plist also gets the 120 Hz key, which Expo Go ignores. JavaScript-only changes never need a new build. + +[`apps/mobile/app.config.ts`](../apps/mobile/app.config.ts) builds one config with two switches: + +- No `DEBATELAB_BUILD`: the Expo Go and web export config, unchanged. +- `DEBATELAB_BUILD=dev`: adds `expo-dev-client`, `expo-speech-recognition` with the microphone and speech prompts (English in Info.plist, Spanish and Hungarian as `InfoPlist.strings` from `mobile.permissions` in the catalogs) and `CFBundleAllowMixedLocalizations`. The `development` profile in [`apps/mobile/eas.json`](../apps/mobile/eas.json) sets it. +- `DEBATELAB_APP_GROUP=group.`, only with `dev`: adds the `expo-widgets` extension (`ExpoWidgetsTarget`, bundle `com.adam.debatelab.ExpoWidgetsTarget`) and the App Group on both targets, which a Live Activity needs. Set it only when the provisioning profile carries that group; a borrowed wildcard profile does not. EAS does not see your shell, so it goes in the profile's `env` in `eas.json`, committed with the build. + +`nativeCapabilities()` in `apps/mobile/src/lib/native-capabilities.ts` tells the app at runtime what the binary has. Feature code checks it and never imports a dev-build module at the top level, so Expo Go keeps working. + +To see what a mode produces without a Mac, from `apps/mobile`: + +```bash +DEBATELAB_BUILD=dev CI=1 npx expo config --json --type introspect # Info.plist and entitlements, in memory +``` + +`npx expo prebuild --platform ios --no-install --clean` writes the full `ios/` project. It is heavy. Run it under the RAM lock, in a copy of `apps/mobile`, and delete the output. `ios/` is never committed. + +**Budget.** Every iOS build is a cloud build (EAS cannot build iOS on Linux). The free plan has about 15 a month. Build only when native code, permissions, entitlements, extensions or the Expo SDK change; at most one a day and three a week without asking Adam; batch native changes; never retry a failed build before reading its log and fixing the cause. Record each build's ID and reason in its PR. + +**Credentials.** Adam's Signulous certificate (`.p12` and its password) and provisioning profile (`.mobileprovision`), and an EAS token, arrive through ProtoPi secret requests. Store them in `~/.config/debatelab/ios-signing/` (folder mode 700, files 600) as `cert.p12`, `p12-password`, `profile.mobileprovision` and `eas-token`, never in the repo, a log or chat. EAS signs with them as local credentials: `credentials.json` in `apps/mobile` points at those files. Git ignores it, and you delete it after the build. + +#### Build day + +Run these in order from the primary checkout (`/home/codex/Projects/DebateLab`) on the merged branch with a clean tree. EAS uploads the git working tree, and the build must match a commit. Stop at the first check that fails and tell Adam. + +**1. Decode the profile and check it before anything else.** + +```bash +S=~/.config/debatelab/ios-signing +openssl smime -inform der -verify -noverify -in $S/profile.mobileprovision -out $S/profile.plist 2>/dev/null +python3 - $S/profile.plist <<'EOF' +import hashlib, plistlib, sys +p = plistlib.load(open(sys.argv[1], 'rb')) +e = p['Entitlements'] +print('application-identifier:', e.get('application-identifier')) # TEAM.* is a wildcard +print('expires:', p['ExpirationDate']) +print('all devices (enterprise):', p.get('ProvisionsAllDevices', False)) +print('listed devices:', len(p.get('ProvisionedDevices', []))) +print('get-task-allow:', e.get('get-task-allow')) # true means a development profile +print('app groups:', e.get('com.apple.security.application-groups')) +print('push:', e.get('aps-environment')) +print('entitlement keys:', sorted(e)) +for cert in p.get('DeveloperCertificates', []): + print('profile cert SHA-1:', hashlib.sha1(cert).hexdigest().upper()) +EOF +openssl pkcs12 -legacy -in $S/cert.p12 -passin file:$S/p12-password -nokeys -clcerts \ + | openssl x509 -noout -fingerprint -sha1 -enddate | sed '/Fingerprint/s/://g' +``` + +Go on only if all of these hold: + +- the profile has not expired, and neither has the certificate (`notAfter`); +- `application-identifier` is `.*` or `.com.adam.debatelab`; +- the profile is enterprise (`all devices` true) or ad hoc with Adam's iPhone UDID among its devices: ask Adam for the UDID. A development profile (`get-task-allow` true) is not what EAS internal distribution expects; ask Adam first; +- the `.p12`'s SHA-1 equals one of the profile's certificate SHA-1s. + +Set `DEBATELAB_APP_GROUP` (in the profile's `env` in `eas.json`) only if `app groups` lists a group. Otherwise this build has no Live Activity. + +**2. Link the EAS project, once.** + +```bash +cd apps/mobile +export EXPO_TOKEN="$(cat $S/eas-token)" +npx eas-cli@24.13.0 init --non-interactive +``` + +`eas init` cannot edit `app.config.ts`, so it prints the project ID. Add `owner: ''` and `extra: { eas: { projectId: '' } }` to the config, commit, and build from that commit. Expo Go's config gains those two fields, which is expected. + +**3. Count the builds already used.** + +```bash +npx eas-cli@24.13.0 build:list --platform ios --json --non-interactive --limit 50 | python3 -c ' +import datetime, json, sys +now = datetime.datetime.now(datetime.timezone.utc) +made = [datetime.datetime.fromisoformat(b["createdAt"].replace("Z", "+00:00")) for b in json.load(sys.stdin)] +print("this month:", sum((t.year, t.month) == (now.year, now.month) for t in made), + "| last 7 days:", sum(now - t < datetime.timedelta(days=7) for t in made), + "| last 24 h:", sum(now - t < datetime.timedelta(days=1) for t in made))' +``` + +Stop if the last 24 hours already has one build or the last 7 days three. + +**4. Write the credentials file without printing the password.** + +```bash +python3 - $S <<'EOF' +import json, os, sys +s = sys.argv[1] +cert = {'path': f'{s}/cert.p12', 'password': open(f'{s}/p12-password').read().strip()} +target = {'provisioningProfilePath': f'{s}/profile.mobileprovision', 'distributionCertificate': cert} +# With DEBATELAB_APP_GROUP the widget extension is a second target with its own entry: +# {'ios': {'DebateLab': target, 'ExpoWidgetsTarget': target}}, with a profile valid for both. +fd = os.open('credentials.json', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) +os.write(fd, json.dumps({'ios': target}).encode()) +os.close(fd) +EOF +git status --short # credentials.json must not appear +``` + +**5. Start the build and do not wait for it.** + +```bash +npx eas-cli@24.13.0 build --platform ios --profile development --non-interactive --no-wait +``` + +Put the build ID, its URL and the reason for the build in the PR. Check it later with `npx eas-cli@24.13.0 build:view --json`. If it fails, read the log before anything else. + +**6. Clean up.** + +```bash +rm -f credentials.json $S/profile.plist +unset EXPO_TOKEN +``` + +#### Installing it + +The finished build's page on expo.dev has an Install button and a QR code. Adam opens it on the iPhone in Safari and installs. For an enterprise certificate, iOS then asks to trust the developer under Settings › General › VPN & Device Management. If iOS asks for Developer Mode, it is under Settings › Privacy & Security. + +The installed app opens the development client. Start Metro as for Expo Go, but with `--dev-client` instead of `--go`, and enter `http://:9230` in the client. + ## Collaboration service The local PostgreSQL-backed Hocuspocus service lives under `services/collaboration/`. It backs the collaborative Workspace, which is hidden and on hold; its routes register only when `VITE_COLLABORATION_URL` is set. Run its focused tests with: From fa67e129005cc2031213983e4993effa4e3e0550 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:49:49 +0000 Subject: [PATCH 6/9] Drop the push entitlement from every development build Prebuild applies expo-notifications' config plugin on its own once the package is installed, and it adds aps-environment like expo-widgets does. The signing profile has no push, so every development build drops it, not only the App Group one. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/app.config.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 247e3d281..171e0cb6d 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -41,8 +41,10 @@ function iosLocale(strings: PermissionStrings) { }; } -// expo-widgets adds the push entitlement even with its push option off, and a -// profile without push cannot sign it. +// expo-widgets adds the push entitlement even with its push option off, and +// prebuild applies expo-notifications' plugin on its own once the package is +// installed, which adds it too. The signing profile has no push, so every +// development build drops it until a profile with push exists. const withoutPushEntitlement: ConfigPlugin = (config) => withEntitlementsPlist(config, (mod) => { delete mod.modResults['aps-environment']; @@ -122,9 +124,8 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { widgets: [], }, ]); - // Registered before Expo applies the plugins list. Mods registered later act - // first, so this one runs after expo-widgets has written its entitlements. - return withoutPushEntitlement(config); } - return config; + // Registered before Expo applies the plugins list. Mods registered later act + // first, so this one runs after the plugins have written their entitlements. + return withoutPushEntitlement(config); }; From 1dcfd2066f61a357a2d7123f7eaa64418d4a2cb4 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 16:57:24 +0000 Subject: [PATCH 7/9] Say that every native build links the capability modules Autolinking puts expo-speech-recognition and expo-widgets into any native build; DEBATELAB_BUILD=dev only adds their config plugins. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/native-capabilities.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/lib/native-capabilities.ts b/apps/mobile/src/lib/native-capabilities.ts index 44bf0821e..b18c92622 100644 --- a/apps/mobile/src/lib/native-capabilities.ts +++ b/apps/mobile/src/lib/native-capabilities.ts @@ -1,10 +1,11 @@ import { requireOptionalNativeModule } from 'expo'; import Constants, { ExecutionEnvironment } from 'expo-constants'; -// What the running binary can do beyond Expo Go. A development build links -// these modules only when app.config.ts ran with DEBATELAB_BUILD=dev, and -// Expo Go never has them, so it is not even asked: no import of a module -// that is missing, no crash. +// What the running binary can do beyond Expo Go. Autolinking puts these +// modules into every native build; DEBATELAB_BUILD=dev in app.config.ts adds +// what they need to work (the speech prompts, and with an App Group the +// widget extension). Expo Go never has them, so it is not even asked: no +// import of a module that is missing, no crash. export type NativeCapabilities = { /** expo-speech-recognition is linked. */ From 4d019d500e190ae3f1e18d67106e61325c484aa2 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 17:04:06 +0000 Subject: [PATCH 8/9] Fix the build-day eas init and widget credentials steps A first non-interactive eas init needs --account and --force to link or create the project, and with an App Group the widget extension needs its own credentials entry, which the script now writes. Co-Authored-By: Claude Opus 5.5 --- docs/development.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/development.md b/docs/development.md index bec17b358..fc19fce26 100644 --- a/docs/development.md +++ b/docs/development.md @@ -194,17 +194,17 @@ Go on only if all of these hold: - the profile is enterprise (`all devices` true) or ad hoc with Adam's iPhone UDID among its devices: ask Adam for the UDID. A development profile (`get-task-allow` true) is not what EAS internal distribution expects; ask Adam first; - the `.p12`'s SHA-1 equals one of the profile's certificate SHA-1s. -Set `DEBATELAB_APP_GROUP` (in the profile's `env` in `eas.json`) only if `app groups` lists a group. Otherwise this build has no Live Activity. +Set `DEBATELAB_APP_GROUP` (in the profile's `env` in `eas.json`, and exported in this shell for step 4) only if `app groups` lists a group. Otherwise this build has no Live Activity. **2. Link the EAS project, once.** ```bash cd apps/mobile export EXPO_TOKEN="$(cat $S/eas-token)" -npx eas-cli@24.13.0 init --non-interactive +npx eas-cli@24.13.0 init --account --force --non-interactive ``` -`eas init` cannot edit `app.config.ts`, so it prints the project ID. Add `owner: ''` and `extra: { eas: { projectId: '' } }` to the config, commit, and build from that commit. Expo Go's config gains those two fields, which is expected. +Without a project ID in the config, the non-interactive init needs both `--account` (the Expo account that owns the project, `adamgaborjani` on Adam's VPS) and `--force`, which links `@/debatelab` if it exists and creates it otherwise. `eas init` cannot edit `app.config.ts`, so it prints the project ID. Add `owner: ''` and `extra: { eas: { projectId: '' } }` to the config, commit, and build from that commit. Expo Go's config gains those two fields, which is expected. **3. Count the builds already used.** @@ -228,10 +228,11 @@ import json, os, sys s = sys.argv[1] cert = {'path': f'{s}/cert.p12', 'password': open(f'{s}/p12-password').read().strip()} target = {'provisioningProfilePath': f'{s}/profile.mobileprovision', 'distributionCertificate': cert} -# With DEBATELAB_APP_GROUP the widget extension is a second target with its own entry: -# {'ios': {'DebateLab': target, 'ExpoWidgetsTarget': target}}, with a profile valid for both. +# With DEBATELAB_APP_GROUP the widget extension is a second target and needs its own entry, +# signed by a profile valid for both bundle ids (a wildcard profile is). +ios = {'DebateLab': target, 'ExpoWidgetsTarget': target} if os.environ.get('DEBATELAB_APP_GROUP') else target fd = os.open('credentials.json', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) -os.write(fd, json.dumps({'ios': target}).encode()) +os.write(fd, json.dumps({'ios': ios}).encode()) os.close(fd) EOF git status --short # credentials.json must not appear From 429e233b736422d315136e99fb712325b2bca9f2 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 17:08:12 +0000 Subject: [PATCH 9/9] Pass the widget target to the credentials step instead of exporting the App Group Exporting DEBATELAB_APP_GROUP in the build shell made eas init fail, because the config only accepts it alongside DEBATELAB_BUILD=dev. Co-Authored-By: Claude Opus 5.5 --- docs/development.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/development.md b/docs/development.md index fc19fce26..fe9e4f53b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -194,7 +194,7 @@ Go on only if all of these hold: - the profile is enterprise (`all devices` true) or ad hoc with Adam's iPhone UDID among its devices: ask Adam for the UDID. A development profile (`get-task-allow` true) is not what EAS internal distribution expects; ask Adam first; - the `.p12`'s SHA-1 equals one of the profile's certificate SHA-1s. -Set `DEBATELAB_APP_GROUP` (in the profile's `env` in `eas.json`, and exported in this shell for step 4) only if `app groups` lists a group. Otherwise this build has no Live Activity. +Set `DEBATELAB_APP_GROUP` in the profile's `env` in `eas.json` only if `app groups` lists a group, and then pass `widgets` to step 4. Don't export it in this shell: outside a build the config rejects it without `DEBATELAB_BUILD=dev`, which breaks `eas init`. Without an App Group this build has no Live Activity. **2. Link the EAS project, once.** @@ -223,14 +223,14 @@ Stop if the last 24 hours already has one build or the last 7 days three. **4. Write the credentials file without printing the password.** ```bash -python3 - $S <<'EOF' +python3 - $S <<'EOF' # add `widgets` after $S when eas.json sets DEBATELAB_APP_GROUP import json, os, sys s = sys.argv[1] cert = {'path': f'{s}/cert.p12', 'password': open(f'{s}/p12-password').read().strip()} target = {'provisioningProfilePath': f'{s}/profile.mobileprovision', 'distributionCertificate': cert} # With DEBATELAB_APP_GROUP the widget extension is a second target and needs its own entry, # signed by a profile valid for both bundle ids (a wildcard profile is). -ios = {'DebateLab': target, 'ExpoWidgetsTarget': target} if os.environ.get('DEBATELAB_APP_GROUP') else target +ios = {'DebateLab': target, 'ExpoWidgetsTarget': target} if sys.argv[2:] == ['widgets'] else target fd = os.open('credentials.json', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) os.write(fd, json.dumps({'ios': ios}).encode()) os.close(fd)