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
11const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT);
12const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1";
13const runtimeVersionPolicy =
14 process.env.MOBILE_VERSION_POLICY ??
15 (APP_VARIANT === "development" ? "appVersion" : "fingerprint");
16
17const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim();
18const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
19
20const fromRepoRoot = (relativePath: string) => `../../${relativePath}`;
21// Android layers are rendered by scripts/export-android-icons.ts from the Icon Composer sources.
22// The knife sits inside the adaptive icon safe zone.
23const androidAdaptiveForeground = "../../assets/knife/android-foreground.png";
24
25if (
26 isIosPersonalTeamBuild &&
27 (!personalTeamBundleIdentifier ||
28 !IOS_BUNDLE_IDENTIFIER_PATTERN.test(personalTeamBundleIdentifier))
29) {
30 throw new Error(
31 "T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID must be a reverse-DNS identifier such as com.example.t3code when T3CODE_IOS_PERSONAL_TEAM=1.",
32 );
33}
34
35const DEVELOPMENT_ASSETS = {
36 appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
37 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject),
38 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
39 androidAdaptiveForeground,
40 androidAdaptiveBackgroundColor: "#171422",
41 androidAdaptiveBackgroundImage: undefined,
42 androidSplashIcon: "../../assets/knife/android-splash.png",
43 androidMonochromeIcon: "../../assets/knife/android-foreground.png",
44 androidNotificationIcon: "../../assets/knife/android-foreground.png",
45 androidNotificationColor: "#00639B",
46} as const;
47
48const PREVIEW_ASSETS = {
49 appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
50 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject),
51 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
52 androidAdaptiveForeground,
53 androidAdaptiveBackgroundColor: "#171422",
54 androidAdaptiveBackgroundImage: undefined,
55 androidSplashIcon: "../../assets/knife/android-splash.png",
56 androidMonochromeIcon: "../../assets/knife/android-foreground.png",
57 androidNotificationIcon: "../../assets/knife/android-foreground.png",
58 androidNotificationColor: "#7565C7",
59} as const;
60
61const RELEASE_ASSETS = {
62 appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
63 iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject),
64 splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
65 androidAdaptiveForeground,
66 androidAdaptiveBackgroundColor: "#171422",
67 androidAdaptiveBackgroundImage: undefined,
68 androidSplashIcon: "../../assets/knife/android-splash.png",
69 androidMonochromeIcon: "../../assets/knife/android-foreground.png",
70 androidNotificationIcon: "../../assets/knife/android-foreground.png",
71 androidNotificationColor: "#FFFFFF",
72} as const;
73
74const VARIANT_CONFIG = {
75 development: {
76 appName: "The Knife Dev",
77 scheme: "t3code-dev",
78 iosBundleIdentifier: "com.t3tools.t3code.dev",
79 androidPackage: "com.t3tools.t3code.dev",
80 relyingParty: "clerk.t3.codes",
81 assets: DEVELOPMENT_ASSETS,
82 },
83 preview: {
84 appName: "The Knife Preview",
85 scheme: "t3code-preview",
86 iosBundleIdentifier: "com.t3tools.t3code.preview",
87 androidPackage: "com.t3tools.t3code.preview",
88 relyingParty: "clerk.t3.codes",
89 assets: PREVIEW_ASSETS,
90 },
91 production: {
92 appName: "The Knife",
93 scheme: "t3code",
94 iosBundleIdentifier: "com.t3tools.t3code",
95 androidPackage: "com.t3tools.t3code",
96 relyingParty: "clerk.t3.codes",
97 assets: RELEASE_ASSETS,
98 },
99} as const;
100
101function resolveAppVariant(value: string | undefined): AppVariant {
102 switch (value) {
103 case "development":
104 case "preview":
105 case "production":
106 return value;
107 default:
108 return "production";
109 }
110}
111
112const variant = VARIANT_CONFIG[APP_VARIANT];
113const iosBundleIdentifier = isIosPersonalTeamBuild
114 ? personalTeamBundleIdentifier!
115 : variant.iosBundleIdentifier;
116
117const dmSansFonts = {
118 regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
119 medium: "@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf",
120 bold: "@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",
121} as const;
122
123const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
124 "expo-widgets",
125 {
126 bundleIdentifier: `${iosBundleIdentifier}.widgets`,
127 groupIdentifier: `group.${iosBundleIdentifier}`,
128 enablePushNotifications: true,
129 // Agent activity can update many times an hour; without the
130 // frequent-updates entitlement iOS throttles the update budget sooner.
131 frequentUpdates: true,
132 enableAndroid: true,
133 widgets: [
134 {
135 name: "SubscriptionUsage",
136 displayName: "Subscription usage",
137 description: "Subscription quotas from your connected environments.",
138 ios: {
139 configuration: {
140 title: "Subscription usage",
141 description:
142 "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.",
143 parameters: {
144 codexPeriod: {
145 title: "Codex limits",
146 type: "enum",
147 default: "auto",
148 values: [
149 { name: "Both", value: "auto" },
150 { name: "Session", value: "session" },
151 { name: "Weekly", value: "weekly" },
152 ],
153 },
154 claudePeriod: {
155 title: "Claude limits",
156 type: "enum",
157 default: "auto",
158 values: [
159 { name: "Both", value: "auto" },
160 { name: "Session", value: "session" },
161 { name: "Weekly", value: "weekly" },
162 ],
163 },
164 },
165 },
166 supportedFamilies: [
167 "systemSmall",
168 "systemMedium",
169 "systemLarge",
170 "systemExtraLarge",
171 "accessoryRectangular",
172 ],
173 },
174 android: {
175 minWidth: 250,
176 minHeight: 180,
177 targetCellWidth: 4,
178 targetCellHeight: 3,
179 resizeMode: "both",
180 // Embeds the layout in the APK so the widget renders before the app
181 // has run once; the app replaces it with stored props on publish.
182 initialLayout: "./src/widgets/SubscriptionUsage.android.tsx",
183 },
184 },
185 {
186 name: "AgentActivity",
187 displayName: "Agent Activity",
188 description: "Shows the current state of your active agents.",
189 // Live Activity companion; there is no Android presentation for it.
190 android: null,
191 ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] },
192 },
193 ],
194 },
195];
196
197const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
198 "expo-sharing",
199 {
200 ios: {
201 // Personal Teams cannot sign App Groups or extension targets. Keep the
202 // reduced-capability local build usable while release builds expose the
203 // real system share target.
204 enabled: !isIosPersonalTeamBuild,
205 extensionBundleIdentifier: `${iosBundleIdentifier}.sharing`,
206 appGroupId: `group.${iosBundleIdentifier}`,
207 activationRule: {
208 supportsText: true,
209 supportsWebUrlWithMaxCount: 1,
210 supportsImageWithMaxCount: 8,
211 supportsMovieWithMaxCount: 8,
212 supportsFileWithMaxCount: 8,
213 },
214 },
215 android: {
216 enabled: true,
217 singleShareMimeTypes: ["*/*"],
218 multipleShareMimeTypes: ["*/*"],
219 },
220 },
221];
222
223// These aliases match the fonts' PostScript names on iOS. Register the same
224// names on Android so React Native and the native composer use one set of
225// family names without waiting for runtime font loading.
226
227const config: ExpoConfig = {
228 name: variant.appName,
229 slug: "t3-code",
230 platforms: ["ios", "android"],
231 scheme: variant.scheme,
232 version: "2.0.0",
233 runtimeVersion: {
234 // Development manifests resolve on every launch, so avoid fingerprint's
235 // expensive native-project calculation there. Preview and production stay
236 // fingerprinted so OTAs only reach binaries with matching native projects.
237 policy: runtimeVersionPolicy,
238 },
239 orientation: "portrait",
240 icon: variant.assets.appIcon,
241 userInterfaceStyle: "automatic",
242 updates: {
243 enabled: repoEnv.T3CODE_MOBILE_UPDATES_ENABLED !== "0",
244 url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454",
245 checkAutomatically: "ON_LOAD",
246 fallbackToCacheTimeout: 0,
247 },
248 ios: {
249 icon: variant.assets.iosIcon,
250 supportsTablet: true,
251 // Multitasking-capable iPad apps cannot rotate programmatically, so the
252 // showcase capture build requires full screen (see infoPlist below).
253 requireFullScreen: process.env.T3_SHOWCASE_CAPTURE_BUILD === "1",
254 bundleIdentifier: iosBundleIdentifier,
255 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
256 // does not fall back to a personal team (which cannot sign app groups,
257 // Sign in with Apple, or push notification entitlements).
258 appleTeamId: "ARK85ZXQ4Z",
259 associatedDomains: [
260 `applinks:${variant.relyingParty}`,
261 `webcredentials:${variant.relyingParty}`,
262 ],
263 entitlements: {
264 "keychain-access-groups": [`$(AppIdentifierPrefix)${iosBundleIdentifier}`],
265 },
266 infoPlist: {
267 UISupportedInterfaceOrientations: [
268 "UIInterfaceOrientationPortrait",
269 "UIInterfaceOrientationLandscapeLeft",
270 "UIInterfaceOrientationLandscapeRight",
271 ],
272 NSAppTransportSecurity: {
273 NSAllowsArbitraryLoads: true,
274 },
275 NSLocalNetworkUsageDescription:
276 "Allow The Knife to connect to The Knife servers on your local network or tailnet.",
277 NSPhotoLibraryAddUsageDescription: "Allow The Knife to save images to your photo library.",
278 // "Audio, AirPlay, and Picture in Picture": the browser screen's system
279 // picture in picture needs it to start and to stay up outside the app.
280 UIBackgroundModes: ["audio"],
281 ITSAppUsesNonExemptEncryption: false,
282 // The App Store screenshot harness rotates the iPad interface from
283 // inside the app (CI denies osascript the Accessibility access that
284 // Simulator menu scripting needs), and iPadOS ignores programmatic
285 // orientation requests for multitasking-capable apps — so the capture
286 // build opts out of multitasking and declares landscape support.
287 ...(process.env.T3_SHOWCASE_CAPTURE_BUILD === "1"
288 ? {
289 "UISupportedInterfaceOrientations~ipad": [
290 "UIInterfaceOrientationPortrait",
291 "UIInterfaceOrientationPortraitUpsideDown",
292 "UIInterfaceOrientationLandscapeLeft",
293 "UIInterfaceOrientationLandscapeRight",
294 ],
295 }
296 : {}),
297 },
298 },
299 android: {
300 icon: variant.assets.appIcon,
301 package: variant.androidPackage,
302 ...(repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE
303 ? { googleServicesFile: repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE }
304 : {}),
305 adaptiveIcon: {
306 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
307 ...(variant.assets.androidAdaptiveBackgroundImage
308 ? { backgroundImage: variant.assets.androidAdaptiveBackgroundImage }
309 : {}),
310 foregroundImage: variant.assets.androidAdaptiveForeground,
311 monochromeImage: variant.assets.androidMonochromeIcon,
312 },
313 // Opts into OnBackInvokedCallback-based back dispatch (Android 13+).
314 // JS back handling survives it via react-native's Android 16 shim plus
315 // withAndroidPredictiveBackCompat on Android 13-15.
316 predictiveBackGestureEnabled: true,
317 // expo-sensors declares this for its pedometer, which the app does not use.
318 blockedPermissions: ["android.permission.ACTIVITY_RECOGNITION"],
319 },
320 web: {
321 favicon: variant.assets.appIcon,
322 },
323 plugins: [
324 ...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []),
325 "expo-asset",
326 [
327 "expo-font",
328 {
329 ios: {
330 fonts: [dmSansFonts.regular, dmSansFonts.medium, dmSansFonts.bold],
331 },
332 android: {
333 fonts: [
334 {
335 fontFamily: "DMSans-Regular",
336 fontDefinitions: [{ path: dmSansFonts.regular, weight: 400 }],
337 },
338 {
339 fontFamily: "DMSans-Medium",
340 fontDefinitions: [{ path: dmSansFonts.medium, weight: 500 }],
341 },
342 {
343 fontFamily: "DMSans-Bold",
344 fontDefinitions: [{ path: dmSansFonts.bold, weight: 700 }],
345 },
346 ],
347 },
348 },
349 ],
350 "expo-secure-store",
351 "expo-sqlite",
352 ...(isIosPersonalTeamBuild
353 ? [sharingPlugin]
354 : ["./plugins/withShareExtensionDisplayName.cjs", sharingPlugin]),
355 [
356 "expo-notifications",
357 {
358 icon: variant.assets.androidNotificationIcon,
359 color: variant.assets.androidNotificationColor,
360 mode: APP_VARIANT === "development" ? "development" : "production",
361 },
362 ],
363 ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }],
364 "expo-web-browser",
365 [
366 "expo-quick-actions",
367 {
368 // Adaptive launcher-shortcut icon; referenced by resource name from
369 // the shortcut items set in src/features/shortcuts.
370 androidIcons: {
371 shortcut_icon: {
372 foregroundImage: variant.assets.androidAdaptiveForeground,
373 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
374 ...(variant.assets.androidAdaptiveBackgroundImage
375 ? { backgroundImage: variant.assets.androidAdaptiveBackgroundImage }
376 : {}),
377 },
378 },
379 },
380 ],
381 [
382 "expo-audio",
383 {
384 microphonePermission: "Allow The Knife to use your microphone for voice input.",
385 recordAudioAndroid: false,
386 enableBackgroundPlayback: false,
387 enableBackgroundRecording: false,
388 },
389 ],
390 [
391 "expo-camera",
392 {
393 cameraPermission: "Allow The Knife to access your camera so you can scan pairing QR codes.",
394 microphonePermission: false,
395 barcodeScannerEnabled: true,
396 recordAudioAndroid: false,
397 },
398 ],
399 ["expo-image-picker", { photosPermission: false, microphonePermission: false }],
400 [
401 "expo-splash-screen",
402 {
403 image: variant.assets.splashIcon,
404 resizeMode: "contain",
405 backgroundColor: "#ffffff",
406 imageWidth: 220,
407 dark: {
408 image: variant.assets.splashIcon,
409 backgroundColor: "#0a0a0a",
410 },
411 android: {
412 // Android 12+ masks the splash icon to a circle over the central two thirds of
413 // its 288dp canvas, so the iOS export's corners get cut. A full-canvas image of
414 // the composed adaptive layers puts the wordmark in the same frame the launcher
415 // icon uses.
416 image: variant.assets.androidSplashIcon,
417 imageWidth: 288,
418 dark: { image: variant.assets.androidSplashIcon },
419 },
420 },
421 ],
422 [
423 "expo-build-properties",
424 {
425 android: {
426 // Keep the supported floor explicit and covered by native notification tests.
427 minSdkVersion: 24,
428 // kotlinx-io uses Kotlin 2.3's return-value checker annotation, while
429 // SDK 58 builds with Kotlin 2.2. It has no runtime behavior.
430 //
431 // WorkManager 2.9 keeps InputMerger classes but not their constructors,
432 // and R8 full mode no longer keeps a default constructor implicitly.
433 // Without it no work request can start, so the Glance session behind
434 // the widget never renders and it stays on "Loading widget". WorkManager
435 // 2.10 ships this rule itself; drop it once the resolved version gets there.
436 extraProguardRules: [
437 "-dontwarn kotlin.MustUseReturnValues",
438 "-keep class * extends androidx.work.InputMerger { <init>(); }",
439 ].join("\n"),
440 },
441 ios: {
442 deploymentTarget: "18.0",
443 // AppCheckCore 11.3+ includes Swift and needs module maps for these Objective-C dependencies.
444 extraPods: [
445 { name: "GoogleUtilities", modular_headers: true },
446 { name: "RecaptchaInterop", modular_headers: true },
447 ],
448 },
449 },
450 ],
451 "./plugins/withIosCocoaPodsUuidCache.cjs",
452 // Only the accelerometer is used (device viewer shake). Compile out the
453 // pedometer so iOS needs no motion purpose string.
454 ["expo-sensors", { motionPermission: false }],
455 // Must be listed BEFORE expo-widgets: same-type mods run last-registered-
456 // first, so registering earlier makes this plugin's mods run AFTER
457 // expo-widgets' — its dangerous mod wipes ios/ExpoWidgetsTarget/ (which
458 // would delete the asset catalog) and its xcodeproj mod creates the widget
459 // target (which must exist before the compile phase can be attached).
460 ...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []),
461 "./plugins/withAndroidCleartextTraffic.cjs",
462 "./plugins/withAndroidGradleHeap.cjs",
463 "./plugins/withAndroidInputBackground.cjs",
464 "./plugins/withAndroidModernPopupMenu.cjs",
465 "./plugins/withAndroidModernAlertDialog.cjs",
466 "./plugins/withAndroidPredictiveBackCompat.cjs",
467 "./plugins/withAndroidTabletOrientation.cjs",
468 ],
469 extra: {
470 appVariant: APP_VARIANT,
471 iosPersonalTeamBuild: isIosPersonalTeamBuild,
472 relay: {
473 url: repoEnv.T3CODE_RELAY_URL ?? null,
474 },
475 clerk: {
476 publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,
477 jwtTemplate: repoEnv.EXPO_PUBLIC_CLERK_JWT_TEMPLATE ?? null,
478 },
479 // Native Google sign-in credentials. @clerk/expo reads these from `extra`
480 // under their exact env-var names (not nested), and its config plugin reads
481 // the iOS URL scheme at prebuild to register it in Info.plist.
482 // Unset values must be omitted (not null): the public manifest serializes
483 // null to {}, which is truthy and would defeat Clerk's fallback checks.
484 EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID,
485 EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID,
486 EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID,
487 EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME,
488 eas: {
489 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
490 },
491 },
492 owner: "pingdotgg",
493};
494
495export default config;