import { ExpoConfig, ConfigContext } from "expo/config"; import fs from "fs"; import path from "path"; import pkg from "./package.json"; // Derives a monotonic integer from semver so `pnpm bump:*` propagates to the // native build identifiers Google Play / App Store require. 1.2.3 → 10203. const [major, minor, patch] = pkg.version.split(".").map((n) => parseInt(n, 10)); const nativeBuildNumber = major * 10000 + minor * 100 + patch; // APP_VARIANT=development produces a side-by-side install: different name, // bundle id, package, and URL scheme so both prod and dev builds can coexist // on the same device. Slug and EAS project stay shared so OTA channels still // route correctly (dev binaries pull the "development" channel via eas.json). const IS_DEV = process.env.APP_VARIANT === "development"; const APP_NAME = IS_DEV ? "Dashboarr Dev" : "Dashboarr"; const BUNDLE_ID = IS_DEV ? "com.dashboarr.app.dev" : "com.dashboarr.app"; const APP_SCHEME = IS_DEV ? "dashboarr-dev" : "dashboarr"; // google-services.json holds real FCM credentials, so it's gitignored and a // fresh clone won't have it. Local dev builds must still work: when the file // is missing we omit android.googleServicesFile entirely, prebuild skips the // Google Services gradle plugin, and push tokens are simply unavailable at // runtime. Without this, a missing file aborts prebuild partway and leaves a // broken half-generated android/ folder. // Builds that ship must instead fail loudly — silently dropping FCM would // break push for every user. That means any EAS Android build (the file // arrives there via the GOOGLE_SERVICES_JSON file secret) and local release // builds (scripts/build-android-prod.js sets DASHBOARR_RELEASE=1). iOS never // uses this file — push goes through APNs. const GOOGLE_SERVICES_FILE = process.env.GOOGLE_SERVICES_JSON ?? "./google-services.json"; const HAS_GOOGLE_SERVICES = fs.existsSync( path.isAbsolute(GOOGLE_SERVICES_FILE) ? GOOGLE_SERVICES_FILE : path.join(__dirname, GOOGLE_SERVICES_FILE) ); const IS_ANDROID_RELEASE = process.env.DASHBOARR_RELEASE === "1" || process.env.EAS_BUILD_PLATFORM === "android"; if (!HAS_GOOGLE_SERVICES) { if (IS_ANDROID_RELEASE) { throw new Error( `google-services.json not found (looked for ${GOOGLE_SERVICES_FILE}). ` + "Release builds require real FCM credentials: place google-services.json at the " + "project root, or point GOOGLE_SERVICES_JSON at it (EAS file secret on cloud builds)." ); } console.warn( "[app.config] google-services.json not found — building without FCM. " + "Push notifications will be unavailable in this build." ); } export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, name: APP_NAME, "owner": "dashboarr", slug: "dashboarr", version: pkg.version, orientation: "portrait", icon: "./assets/icon.png", userInterfaceStyle: "dark", newArchEnabled: true, splash: { image: "./assets/splash.png", resizeMode: "cover", backgroundColor: "#09090b", }, // EAS Update — OTA JS/asset updates shipped via `eas update --branch `. // Binary and update must share the same runtimeVersion; bumping the native // `version` (or adding any native code) cuts a new runtime and requires a // fresh TestFlight / Play Store build. updates: { url: "https://u.expo.dev/2e40d2d5-f7c5-4c28-922c-40e2a5ab2a8c", requestHeaders: { "expo-channel-name": process.env.EAS_UPDATE_CHANNEL ?? "production", }, }, runtimeVersion: { policy: "fingerprint", }, ios: { // iOS 18+ Home Screen appearances (Edit > Customize): iOS swaps in the // dark or tinted variant when the user picks that look, and the tinted // one takes whatever tint color they choose (#415). All three come from // scripts/generate-icon.js. Native assets: needs a new binary. icon: { light: "./assets/icon.png", dark: "./assets/icon-dark.png", tinted: "./assets/icon-tinted.png", }, supportsTablet: true, bundleIdentifier: BUNDLE_ID, buildNumber: String(nativeBuildNumber), infoPlist: { ITSAppUsesNonExemptEncryption: false, NSLocalNetworkUsageDescription: "Dashboarr sends Wake-on-LAN magic packets to your home server on the local network.", // Self-hosted services on a LAN almost always speak plain http://, and // iOS blocks cleartext by default (App Transport Security). Mirrors the // Android cleartext-traffic plugin. The remote-URL form already warns // when users pick http:// for an internet-facing endpoint. NSAppTransportSecurity: { NSAllowsArbitraryLoads: true, }, // Registers the app as an "Open in" target for .torrent files (Files app, // Safari downloads, the share sheet's app row). iOS has no built-in UTI // for torrents, so the type is imported here under the identifier the // desktop clients (Transmission, qBittorrent) export. Opened files are // copied into Documents/Inbox and arrive as a file:// URL, which // app/+native-intent.ts routes to the Downloads add card. Native // config: needs a new binary, not an OTA update. // Declaring document types makes App Store Connect require an explicit // open-in-place choice (ITMS-90737). NO keeps the Inbox-copy behavior // above; YES would hand us the original file, which needs // security-scoped access the add flow doesn't do. LSSupportsOpeningDocumentsInPlace: false, CFBundleDocumentTypes: [ { CFBundleTypeName: "BitTorrent Document", LSHandlerRank: "Alternate", LSItemContentTypes: ["org.bittorrent.torrent"], }, ], UTImportedTypeDeclarations: [ { UTTypeIdentifier: "org.bittorrent.torrent", UTTypeDescription: "BitTorrent Document", UTTypeConformsTo: ["public.data"], UTTypeTagSpecification: { "public.filename-extension": ["torrent"], "public.mime-type": ["application/x-bittorrent"], }, }, ], }, // Required so NetInfo can read the current Wi-Fi SSID/BSSID for the // local-vs-remote auto-switch feature. iOS also needs Location When-In-Use // permission at runtime (handled in lib/wifi.ts via expo-location). entitlements: { "com.apple.developer.networking.wifi-info": true, }, }, android: { // Android 12+ ignores full-screen splash images — the system splash is a // small centered icon (~288dp). Feeding it the tall splash.png shrinks the // badge to a speck (reported on Honor Magic 6 Pro), so Android gets the // badge-only square art instead; iOS keeps the full-screen splash above. // scripts/generate-icon.js also writes the density drawables directly. splash: { image: "./assets/adaptive-icon.png", resizeMode: "contain", backgroundColor: "#09090b", }, adaptiveIcon: { foregroundImage: "./assets/adaptive-icon.png", // Android 13+ themed icons: with the system's themed-icons toggle on, // the launcher draws this silhouette in the wallpaper palette instead // of the colored foreground (#415). monochromeImage: "./assets/adaptive-icon-monochrome.png", backgroundColor: "#09090b", }, // Always the base id at prebuild time. The dev variant suffix (".dev") is // applied at Gradle build time by plugins/withDevVariant — that lets one // prebuilt android/ project produce both variants by toggling APP_VARIANT // when invoking gradlew, without a second prebuild. package: "com.dashboarr.app", versionCode: nativeBuildNumber, ...(HAS_GOOGLE_SERVICES ? { googleServicesFile: GOOGLE_SERVICES_FILE } : {}), // VIEW intent filters for .torrent files (file managers, browser // downloads, "Open with"). The first matches by MIME type; the second // catches providers that label the download application/octet-stream // but keep the .torrent name (a host is required for a path pattern to // match). The delivered content:// / file:// URI reaches // app/+native-intent.ts through Linking. Native config: needs a new // binary, not an OTA update. intentFilters: [ { action: "VIEW", category: ["DEFAULT", "BROWSABLE"], data: [ { scheme: "content", mimeType: "application/x-bittorrent" }, { scheme: "file", mimeType: "application/x-bittorrent" }, ], }, { action: "VIEW", category: ["DEFAULT", "BROWSABLE"], data: [ { scheme: "content", host: "*", mimeType: "*/*", pathPattern: ".*\\.torrent" }, { scheme: "file", host: "*", mimeType: "*/*", pathPattern: ".*\\.torrent" }, ], }, ], }, // APP_SCHEME must stay first — expo-linking's createURL uses the first entry. // "magnet" registers the app as a magnet-link handler (Android intent filter // + iOS CFBundleURLSchemes); incoming URLs are rewritten by app/+native-intent.ts. scheme: [APP_SCHEME, "magnet"], // Shared EAS project used by every install. Required for real push tokens — // Notifications.getExpoPushTokenAsync({ projectId }) reads from extra.eas.projectId. // Keep this stable across releases; replace with the real EAS project UUID // from `eas init`. Must NOT have Enhanced Security for Push Notifications // enabled on the Expo side (see backend README). extra: { eas: { "projectId": "2e40d2d5-f7c5-4c28-922c-40e2a5ab2a8c" }, }, plugins: [ "expo-router", "expo-secure-store", "expo-web-browser", "expo-localization", "./plugins/withAndroidSigning", "./plugins/withCleartextTraffic", "./plugins/withDevVariant", "./plugins/withFmtConstevalFix", "./plugins/withGradleJvmArgs", "./plugins/withInsecureTls", "./plugins/withPodsDeploymentTarget", "./plugins/withSceneLifecycle", [ "expo-location", { locationWhenInUsePermission: "Allow Dashboarr to detect your WiFi network name for automatic local/remote URL switching.", }, ], [ "expo-notifications", { color: "#3b82f6", }, ], [ "expo-camera", { cameraPermission: "Allow Dashboarr to scan your backend pairing QR code.", }, ], // Android home-screen "Releasing Soon" calendar widget (issue #224). The // widget UI + data live in widgets/*; this plugin injects the AppWidget // + provider metadata into the manifest. `name` MUST match // WIDGET_NAME in widgets/widget-config.ts. updatePeriodMillis is Android's // 30-min floor (smaller is clamped); the reliable refresh is the in-app // foreground push (hooks/use-widget-refresh). The dev variant's ".dev" // applicationIdSuffix doesn't rename the manifest namespace, so prod and dev // install as two distinct providers with no plugin change. [ "react-native-android-widget", { widgets: [ { name: "Calendar", label: "Releasing Soon", description: "Upcoming TV episodes and movie releases", minWidth: "250dp", minHeight: "110dp", targetCellWidth: 4, targetCellHeight: 2, updatePeriodMillis: 1800000, resizeMode: "horizontal|vertical", }, ], }, ], ], });