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/.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/AGENTS.md b/apps/mobile/AGENTS.md index 4ee041d69..a3e17292d 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/`, `factoids/` and `sparring/` (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`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. - `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. @@ -44,4 +45,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/apps/mobile/app.config.ts b/apps/mobile/app.config.ts new file mode 100644 index 000000000..171e0cb6d --- /dev/null +++ b/apps/mobile/app.config.ts @@ -0,0 +1,131 @@ +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 +// 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']; + 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 the plugins have written their entitlements. + return withoutPushEntitlement(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/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" + } + } + } +} 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/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"] } 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..b18c92622 --- /dev/null +++ b/apps/mobile/src/lib/native-capabilities.ts @@ -0,0 +1,30 @@ +import { requireOptionalNativeModule } from 'expo'; +import Constants, { ExecutionEnvironment } from 'expo-constants'; + +// 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. */ + 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, + }; +} diff --git a/docs/development.md b/docs/development.md index f6ba1c2a1..fe9e4f53b 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,130 @@ 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, 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.** + +```bash +cd apps/mobile +export EXPO_TOKEN="$(cat $S/eas-token)" +npx eas-cli@24.13.0 init --account --force --non-interactive +``` + +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.** + +```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' # 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 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) +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: 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 0446df86f..3432a5f0c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2629,6 +2629,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 122d92072..7a1c1450a 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2629,6 +2629,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 012f7814f..a8af70660 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2629,6 +2629,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." } } }