1import type { ExpoConfig } from "expo/config";
2
3import { BRAND_ASSET_PATHS } from "../../scripts/lib/brand-assets.ts";
4import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
5
6type AppVariant = "development" | "preview" | "production";
7
8const repoEnv = loadRepoEnv();
9Object.assign(process.env, repoEnv);
10// Expo's TypeScript setup otherwise installs `typescript` on every start; we typecheck with tsrs.
11process.env.EXPO_NO_TYPESCRIPT_SETUP = "1";
12
13const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT);
14const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1";
15const runtimeVersionPolicy =
16 process.env.MOBILE_VERSION_POLICY ??
17 (APP_VARIANT === "development" ? "appVersion" : "fingerprint");
18
19const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim();
20const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
21
22// Compiled into the native project. Without a relay, Observe's native side could dispatch to
23// Expo's own ingest before observe.ts turns it off; `.invalid` never resolves (RFC 6761).
24const observeEndpointUrl = repoEnv.T3CODE_RELAY_URL?.trim()
25 ? new URL("/v1/mobile/observe", repoEnv.T3CODE_RELAY_URL.trim()).toString()
26 : "https://observe.invalid";
27
28const fromRepoRoot = (relativePath: string) => `../../${relativePath}`;
29const androidAdaptiveForeground = "./assets/android-icon-foreground.png";
30
31if (
32 isIosPersonalTeamBuild &&
33 (!personalTeamBundleIdentifier ||
34 !IOS_BUNDLE_IDENTIFIER_PATTERN.test(personalTeamBundleIdentifier))
35) {
36 throw new Error(
37 "T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID must be a reverse-DNS identifier such as com.example.t3code when T3CODE_IOS_PERSONAL_TEAM=1.",
38 );
39}
40
41const DEVELOPMENT_ASSETS = {
42 appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
43 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject),
44 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
45 androidAdaptiveForeground,
46 androidAdaptiveBackgroundColor: "#347FF8",
47 androidAdaptiveBackgroundImage: "./assets/android-icon-background-dev.png",
48 androidSplashIcon: "./assets/android-splash-icon-dev.png",
49 androidMonochromeIcon: "./assets/android-icon-mark.png",
50 androidNotificationIcon: "./assets/android-notification-icon.png",
51 androidNotificationColor: "#00639B",
52} as const;
53
54const PREVIEW_ASSETS = {
55 appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
56 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject),
57 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
58 androidAdaptiveForeground,
59 androidAdaptiveBackgroundColor: "#111533",
60 androidAdaptiveBackgroundImage: "./assets/android-icon-background-nightly.png",
61 androidSplashIcon: "./assets/android-splash-icon-nightly.png",
62 androidMonochromeIcon: "./assets/android-icon-mark.png",
63 androidNotificationIcon: "./assets/android-notification-icon.png",
64 androidNotificationColor: "#7565C7",
65} as const;
66
67const RELEASE_ASSETS = {
68 appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
69 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject),
70 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
71 androidAdaptiveForeground,
72 androidAdaptiveBackgroundColor: "#000000",
73 androidAdaptiveBackgroundImage: undefined,
74 androidSplashIcon: "./assets/android-splash-icon-prod.png",
75 androidMonochromeIcon: "./assets/android-icon-mark.png",
76 androidNotificationIcon: "./assets/android-notification-icon.png",
77 androidNotificationColor: "#FFFFFF",
78} as const;
79
80const VARIANT_CONFIG = {
81 development: {
82 appName: "T3 Code Dev",
83 scheme: "t3code-dev",
84 iosBundleIdentifier: "com.t3tools.t3code.dev",
85 androidPackage: "com.t3tools.t3code.dev",
86 relyingParty: "clerk.t3.codes",
87 assets: DEVELOPMENT_ASSETS,
88 },
89 preview: {
90 appName: "T3 Code Preview",
91 scheme: "t3code-preview",
92 iosBundleIdentifier: "com.t3tools.t3code.preview",
93 androidPackage: "com.t3tools.t3code.preview",
94 relyingParty: "clerk.t3.codes",
95 assets: PREVIEW_ASSETS,
96 },
97 production: {
98 appName: "T3 Code",
99 scheme: "t3code",
100 iosBundleIdentifier: "com.t3tools.t3code",
101 androidPackage: "com.t3tools.t3code",
102 relyingParty: "clerk.t3.codes",
103 assets: RELEASE_ASSETS,
104 },
105} as const;
106
107function resolveAppVariant(value: string | undefined): AppVariant {
108 switch (value) {
109 case "development":
110 case "preview":
111 case "production":
112 return value;
113 case undefined:
114 return "production";
115 default:
116 throw new Error("APP_VARIANT must be development, preview, or production.");
117 }
118}
119
120const variant = VARIANT_CONFIG[APP_VARIANT];
121// Copied thread links open the app. app.t3.codes/.well-known lists every variant's identifiers.
122const HOSTED_APP_HOST = "app.t3.codes";
123const iosBundleIdentifier = isIosPersonalTeamBuild
124 ? personalTeamBundleIdentifier!
125 : variant.iosBundleIdentifier;
126
127const dmSansFonts = {
128 regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
129 medium: "@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf",
130 bold: "@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",
131} as const;
132
133const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
134 "expo-widgets",
135 {
136 bundleIdentifier: `${iosBundleIdentifier}.widgets`,
137 groupIdentifier: `group.${iosBundleIdentifier}`,
138 enablePushNotifications: true,
139 // Frequent updates need the iOS entitlement to avoid early throttling.
140 frequentUpdates: true,
141 enableAndroid: true,
142 widgets: [
143 {
144 name: "SubscriptionUsage",
145 displayName: "Subscription usage",
146 description: "Subscription quotas from your connected T3 Code environments.",
147 ios: {
148 configuration: {
149 title: "Subscription usage",
150 description:
151 "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.",
152 parameters: {
153 codexPeriod: {
154 title: "Codex limits",
155 type: "enum",
156 default: "auto",
157 values: [
158 { name: "Both", value: "auto" },
159 { name: "Session", value: "session" },
160 { name: "Weekly", value: "weekly" },
161 ],
162 },
163 claudePeriod: {
164 title: "Claude limits",
165 type: "enum",
166 default: "auto",
167 values: [
168 { name: "Both", value: "auto" },
169 { name: "Session", value: "session" },
170 { name: "Weekly", value: "weekly" },
171 ],
172 },
173 },
174 },
175 supportedFamilies: [
176 "systemSmall",
177 "systemMedium",
178 "systemLarge",
179 "systemExtraLarge",
180 "accessoryRectangular",
181 ],
182 },
183 android: {
184 minWidth: 250,
185 minHeight: 180,
186 targetCellWidth: 4,
187 targetCellHeight: 3,
188 resizeMode: "both",
189 // The embedded layout renders before first launch; publishing replaces it with stored props.
190 initialLayout: "./src/widgets/SubscriptionUsage.android.tsx",
191 },
192 },
193 {
194 name: "AgentActivity",
195 displayName: "Agent Activity",
196 description: "Shows the current state of active T3 Code agents.",
197 android: null,
198 ios: {
199 supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"],
200 // Embeds the layout so the gallery preview and a new widget render
201 // before the app has run once; the app replaces it on publish.
202 initialLayout: "./src/widgets/AgentActivityWidget.tsx",
203 },
204 },
205 ],
206 },
207];
208
209const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
210 "expo-sharing",
211 {
212 ios: {
213 // Personal Teams cannot sign App Groups or extension targets; keep local builds usable.
214 enabled: !isIosPersonalTeamBuild,
215 extensionBundleIdentifier: `${iosBundleIdentifier}.sharing`,
216 appGroupId: `group.${iosBundleIdentifier}`,
217 activationRule: {
218 supportsText: true,
219 supportsWebUrlWithMaxCount: 1,
220 supportsImageWithMaxCount: 8,
221 supportsMovieWithMaxCount: 8,
222 supportsFileWithMaxCount: 8,
223 },
224 },
225 android: {
226 enabled: true,
227 singleShareMimeTypes: ["*/*"],
228 multipleShareMimeTypes: ["*/*"],
229 },
230 },
231];
232
233// Match iOS PostScript names on Android so RN and the native composer share fonts before runtime loading.
234
235const config: ExpoConfig = {
236 name: variant.appName,
237 slug: "t3-code",
238 platforms: ["ios", "android"],
239 scheme: variant.scheme,
240 version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.3.0",
241 runtimeVersion: {
242 // Skip costly fingerprinting on dev launches; preview and production OTAs require matching native projects.
243 policy: runtimeVersionPolicy,
244 },
245 orientation: "portrait",
246 icon: variant.assets.appIcon,
247 userInterfaceStyle: "automatic",
248 updates: {
249 enabled: repoEnv.T3CODE_MOBILE_UPDATES_ENABLED !== "0",
250 url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454",
251 checkAutomatically: "ON_LOAD",
252 fallbackToCacheTimeout: 0,
253 },
254 ios: {
255 icon: variant.assets.iosIcon,
256 supportsTablet: true,
257 requireFullScreen: process.env.T3_SHOWCASE_CAPTURE_BUILD === "1",
258 bundleIdentifier: iosBundleIdentifier,
259 // Avoid Personal Team fallback: it cannot sign App Groups, Apple sign-in or push entitlements.
260 appleTeamId: "ARK85ZXQ4Z",
261 associatedDomains: [
262 `applinks:${variant.relyingParty}`,
263 `webcredentials:${variant.relyingParty}`,
264 `applinks:${HOSTED_APP_HOST}`,
265 ],
266 entitlements: {
267 "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`],
268 },
269 infoPlist: {
270 UISupportedInterfaceOrientations: [
271 "UIInterfaceOrientationPortrait",
272 "UIInterfaceOrientationLandscapeLeft",
273 "UIInterfaceOrientationLandscapeRight",
274 ],
275 NSAppTransportSecurity: {
276 NSAllowsArbitraryLoads: true,
277 },
278 NSLocalNetworkUsageDescription:
279 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",
280 NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.",
281 // System picture in picture needs background audio to start and survive outside the app.
282 UIBackgroundModes: ["audio"],
283 ITSAppUsesNonExemptEncryption: false,
284 // Capture builds disable multitasking because iPadOS otherwise ignores orientation requests.
285 ...(process.env.T3_SHOWCASE_CAPTURE_BUILD === "1"
286 ? {
287 "UISupportedInterfaceOrientations~ipad": [
288 "UIInterfaceOrientationPortrait",
289 "UIInterfaceOrientationPortraitUpsideDown",
290 "UIInterfaceOrientationLandscapeLeft",
291 "UIInterfaceOrientationLandscapeRight",
292 ],
293 }
294 : {}),
295 },
296 },
297 android: {
298 icon: variant.assets.appIcon,
299 package: variant.androidPackage,
300 ...(repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE
301 ? { googleServicesFile: repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE }
302 : {}),
303 adaptiveIcon: {
304 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
305 ...(variant.assets.androidAdaptiveBackgroundImage
306 ? { backgroundImage: variant.assets.androidAdaptiveBackgroundImage }
307 : {}),
308 foregroundImage: variant.assets.androidAdaptiveForeground,
309 monochromeImage: variant.assets.androidMonochromeIcon,
310 },
311 // JS back dispatch needs RN's Android 16 shim and withAndroidPredictiveBackCompat on Android 13-15.
312 predictiveBackGestureEnabled: true,
313 // expo-sensors declares this for its pedometer, which the app does not use.
314 blockedPermissions: ["android.permission.ACTIVITY_RECOGNITION"],
315 intentFilters: [
316 {
317 action: "VIEW",
318 autoVerify: true,
319 data: [{ scheme: "https", host: HOSTED_APP_HOST, pathPrefix: "/threads/" }],
320 category: ["BROWSABLE", "DEFAULT"],
321 },
322 ],
323 },
324 web: {
325 favicon: variant.assets.appIcon,
326 },
327 plugins: [
328 "expo-asset",
329 [
330 "expo-font",
331 {
332 ios: {
333 fonts: [dmSansFonts.regular, dmSansFonts.medium, dmSansFonts.bold],
334 },
335 android: {
336 fonts: [
337 {
338 fontFamily: "DMSans-Regular",
339 fontDefinitions: [{ path: dmSansFonts.regular, weight: 400 }],
340 },
341 {
342 fontFamily: "DMSans-Medium",
343 fontDefinitions: [{ path: dmSansFonts.medium, weight: 500 }],
344 },
345 {
346 fontFamily: "DMSans-Bold",
347 fontDefinitions: [{ path: dmSansFonts.bold, weight: 700 }],
348 },
349 ],
350 },
351 },
352 ],
353 "expo-secure-store",
354 "expo-sqlite",
355 ...(isIosPersonalTeamBuild
356 ? [sharingPlugin]
357 : ["./plugins/withShareExtensionDisplayName.cjs", sharingPlugin]),
358 [
359 "expo-notifications",
360 {
361 icon: variant.assets.androidNotificationIcon,
362 color: variant.assets.androidNotificationColor,
363 mode: APP_VARIANT === "development" ? "development" : "production",
364 },
365 ],
366 // Gate Clerk here: withoutIosPersonalTeamCapabilities runs too early to strip its entitlement.
367 ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }],
368 "expo-web-browser",
369 [
370 "expo-quick-actions",
371 {
372 androidIcons: {
373 shortcut_icon: {
374 foregroundImage: variant.assets.androidAdaptiveForeground,
375 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
376 ...(variant.assets.androidAdaptiveBackgroundImage
377 ? { backgroundImage: variant.assets.androidAdaptiveBackgroundImage }
378 : {}),
379 },
380 },
381 },
382 ],
383 [
384 "expo-audio",
385 {
386 microphonePermission: "Allow T3 Code to use your microphone for voice input.",
387 recordAudioAndroid: true,
388 enableBackgroundPlayback: false,
389 enableBackgroundRecording: false,
390 },
391 ],
392 [
393 "expo-camera",
394 {
395 cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.",
396 microphonePermission: false,
397 barcodeScannerEnabled: true,
398 recordAudioAndroid: false,
399 },
400 ],
401 [
402 "expo-image-picker",
403 {
404 photosPermission: false,
405 // `false` makes this plugin add RECORD_AUDIO to blockedPermissions, breaking Android dictation.
406 microphonePermission: "Allow T3 Code to use your microphone for voice input.",
407 },
408 ],
409 [
410 "expo-splash-screen",
411 {
412 image: variant.assets.splashIcon,
413 resizeMode: "contain",
414 backgroundColor: "#ffffff",
415 imageWidth: 220,
416 dark: {
417 image: variant.assets.splashIcon,
418 backgroundColor: "#0a0a0a",
419 },
420 android: {
421 // Android 12+ masks the central two thirds of the 288dp canvas; adaptive layers avoid clipped corners.
422 image: variant.assets.androidSplashIcon,
423 imageWidth: 288,
424 dark: { image: variant.assets.androidSplashIcon },
425 },
426 },
427 ],
428 [
429 "expo-build-properties",
430 {
431 android: {
432 minSdkVersion: 24,
433 // SDK 58 lacks Kotlin 2.3's runtime-inert annotation. R8 needs explicit InputMerger constructors for widgets.
434 extraProguardRules: [
435 "-dontwarn kotlin.MustUseReturnValues",
436 "-keep class * extends androidx.work.InputMerger { <init>(); }",
437 ].join("\n"),
438 },
439 ios: {
440 deploymentTarget: "18.0",
441 // AppCheckCore 11.3+ includes Swift and needs module maps for these Objective-C dependencies.
442 extraPods: [
443 { name: "GoogleUtilities", modular_headers: true },
444 { name: "RecaptchaInterop", modular_headers: true },
445 ],
446 },
447 },
448 ],
449 "./plugins/withIosCocoaPodsUuidCache.cjs",
450 // Compile out the unused pedometer to avoid an iOS motion purpose string.
451 ["expo-sensors", { motionPermission: false }],
452 // Mods run in reverse order; expo-widgets must create and wipe the target before assets are added.
453 ...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []),
454 "./plugins/withAndroidNetworkSecurityConfig.cjs",
455 "./plugins/withAndroidGradleHeap.cjs",
456 "./plugins/withAndroidInputBackground.cjs",
457 "./plugins/withAndroidModernPopupMenu.cjs",
458 "./plugins/withAndroidModernAlertDialog.cjs",
459 "./plugins/withAndroidPredictiveBackCompat.cjs",
460 // MainActivity mods run in reverse order; extend the tablet-orientation override.
461 "./plugins/withAndroidWindowDisplayMetrics.cjs",
462 "./plugins/withAndroidTabletOrientation.cjs",
463 ...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []),
464 ],
465 extra: {
466 appVariant: APP_VARIANT,
467 secretsFeatureEnabled: ["1", "true"].includes(
468 process.env.T3CODE_FEATURE_SECRETS?.trim().toLowerCase() ?? "",
469 ),
470 iosPersonalTeamBuild: isIosPersonalTeamBuild,
471 relay: {
472 url: repoEnv.T3CODE_RELAY_URL ?? null,
473 },
474 clerk: {
475 publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,
476 jwtTemplate: repoEnv.EXPO_PUBLIC_CLERK_JWT_TEMPLATE ?? null,
477 },
478 // Clerk reads these exact names at prebuild. Omit unset values: manifest null becomes truthy {}.
479 EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID,
480 EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID,
481 EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID,
482 EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME,
483 observability: {
484 tracesUrl: repoEnv.EXPO_PUBLIC_OTLP_TRACES_URL ?? "https://api.axiom.co/v1/traces",
485 tracesDataset: repoEnv.EXPO_PUBLIC_OTLP_TRACES_DATASET ?? null,
486 tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null,
487 },
488 eas: {
489 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
490 observe: { endpointUrl: observeEndpointUrl },
491 },
492 },
493 owner: "pingdotgg",
494 experiments: {
495 // Metro only runs babel-plugin-react-compiler when this is set; see src/reactCompilerCoverage.test.ts.
496 reactCompiler: true,
497 },
498};
499
500export default config;