diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 5c8caa67..39f9057b 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -20,6 +20,18 @@ jobs: contents: read steps: + - name: ๐Ÿ—‘๏ธ Free Disk Space + uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 + with: + tool-cache: false + mandb: true + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: false + - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: @@ -91,6 +103,18 @@ jobs: contents: read steps: + - name: ๐Ÿ—‘๏ธ Free Disk Space + uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 + with: + tool-cache: false + mandb: true + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: false + - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: @@ -194,7 +218,7 @@ jobs: - name: ๐Ÿ”ง Setup Xcode uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 with: - xcode-version: "26.0.1" + xcode-version: "26.2" - name: ๐Ÿ—๏ธ Setup EAS uses: expo/expo-github-action@main @@ -203,9 +227,6 @@ jobs: token: ${{ secrets.EXPO_TOKEN }} eas-cache: true - - name: โš™๏ธ Ensure iOS SDKs installed - run: xcodebuild -downloadPlatform iOS - - name: ๐Ÿš€ Build iOS app env: EXPO_TV: 0 @@ -221,6 +242,63 @@ jobs: path: build-*.ipa retention-days: 7 + build-ios-phone-unsigned: + if: (!contains(github.event.head_commit.message, '[skip ci]')) + runs-on: macos-26 + name: ๐ŸŽ Build iOS IPA (Phone - Unsigned) + permissions: + contents: read + + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 + with: + bun-version: latest + + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-cache + + - name: ๐Ÿ“ฆ Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: ๐Ÿ› ๏ธ Generate project files + run: bun run prebuild + + - name: ๐Ÿ”ง Setup Xcode + uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 + with: + xcode-version: "26.2" + + - name: ๐Ÿš€ Build iOS app + env: + EXPO_TV: 0 + run: bun run ios:unsigned-build ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }} + + - name: ๐Ÿ“… Set date tag + run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV + + - name: ๐Ÿ“ค Upload IPA artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }} + path: build/*.ipa + retention-days: 7 + # Disabled for now - uncomment when ready to build iOS TV # build-ios-tv: # if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) @@ -271,9 +349,6 @@ jobs: # token: ${{ secrets.EXPO_TOKEN }} # eas-cache: true # - # - name: โš™๏ธ Ensure tvOS SDKs installed - # run: xcodebuild -downloadPlatform tvOS - # # - name: ๐Ÿš€ Build iOS app # env: # EXPO_TV: 1 diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml index b9fb8c59..58d806b5 100644 --- a/.github/workflows/ci-codeql.yml +++ b/.github/workflows/ci-codeql.yml @@ -27,13 +27,13 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: ๐Ÿ Initialize CodeQL - uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 with: languages: ${{ matrix.language }} queries: +security-extended,security-and-quality - name: ๐Ÿ› ๏ธ Autobuild - uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 - name: ๐Ÿงช Perform CodeQL Analysis - uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 diff --git a/.github/workflows/notification.yml b/.github/workflows/notification.yml index 92bb4a5d..cf0e4624 100644 --- a/.github/workflows/notification.yml +++ b/.github/workflows/notification.yml @@ -1,4 +1,5 @@ name: ๐Ÿ›Ž๏ธ Discord Notification +permissions: {} on: pull_request: diff --git a/.gitignore b/.gitignore index c022785c..2b3bd6ed 100644 --- a/.gitignore +++ b/.gitignore @@ -66,7 +66,10 @@ streamyfin-4fec1-firebase-adminsdk.json # Version and Backup Files /version-backup-* -modules/background-downloader/android/build/* /modules/sf-player/android/build /modules/music-controls/android/build +modules/background-downloader/android/build/* /modules/mpv-player/android/build + +# ios:unsigned-build Artifacts +build/ \ No newline at end of file diff --git a/README.md b/README.md index 7bb0c9bc..b5b418d2 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,12 @@ Streamyfin

+

+ + Streamyfin Discord + +

+ **Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.** --- diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index d51b96a4..3a07fef0 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -82,6 +82,7 @@ export default function page() { const [tracksReady, setTracksReady] = useState(false); const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0); + const [showTechnicalInfo, setShowTechnicalInfo] = useState(false); const progress = useSharedValue(0); const isSeeking = useSharedValue(false); @@ -726,6 +727,59 @@ export default function page() { videoRef.current?.seekTo?.(position / 1000); }, []); + // Technical info toggle handler + const handleToggleTechnicalInfo = useCallback(() => { + setShowTechnicalInfo((prev) => !prev); + }, []); + + // Get technical info from the player + const getTechnicalInfo = useCallback(async () => { + return (await videoRef.current?.getTechnicalInfo?.()) ?? {}; + }, []); + + // Determine play method based on stream URL and media source + const playMethod = useMemo< + "DirectPlay" | "DirectStream" | "Transcode" | undefined + >(() => { + if (!stream?.url) return undefined; + + // Check if transcoding (m3u8 playlist or TranscodingUrl present) + if (stream.url.includes("m3u8") || stream.mediaSource?.TranscodingUrl) { + return "Transcode"; + } + + // Check if direct play (no container remuxing needed) + // Direct play means the file is being served as-is + if (stream.url.includes("/Videos/") && stream.url.includes("/stream")) { + return "DirectStream"; + } + + // Default to direct play if we're not transcoding + return "DirectPlay"; + }, [stream?.url, stream?.mediaSource?.TranscodingUrl]); + + // Extract transcode reasons from the TranscodingUrl + const transcodeReasons = useMemo(() => { + const transcodingUrl = stream?.mediaSource?.TranscodingUrl; + if (!transcodingUrl) return []; + + try { + // Parse the TranscodeReasons parameter from the URL + const url = new URL(transcodingUrl, "http://localhost"); + const reasons = url.searchParams.get("TranscodeReasons"); + if (reasons) { + return reasons.split(",").filter(Boolean); + } + } catch { + // If URL parsing fails, try regex fallback + const match = transcodingUrl.match(/TranscodeReasons=([^&]+)/); + if (match) { + return match[1].split(",").filter(Boolean); + } + } + return []; + }, [stream?.mediaSource?.TranscodingUrl]); + const handleZoomToggle = useCallback(async () => { const newZoomState = !isZoomedToFill; await videoRef.current?.setZoomedToFill?.(newZoomState); @@ -924,6 +978,11 @@ export default function page() { downloadedFiles={downloadedFiles} playbackSpeed={currentPlaybackSpeed} setPlaybackSpeed={handleSetPlaybackSpeed} + showTechnicalInfo={showTechnicalInfo} + onToggleTechnicalInfo={handleToggleTechnicalInfo} + getTechnicalInfo={getTechnicalInfo} + playMethod={playMethod} + transcodeReasons={transcodeReasons} /> )} diff --git a/bun.lock b/bun.lock index b3772caa..3f1f717a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "streamyfin", diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index b33d2b2c..24fd135c 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -25,7 +25,14 @@ export type ToggleOption = { disabled?: boolean; }; -export type Option = RadioOption | ToggleOption; +export type ActionOption = { + type: "action"; + label: string; + onPress: () => void; + disabled?: boolean; +}; + +export type Option = RadioOption | ToggleOption | ActionOption; // Option group structure export type OptionGroup = { @@ -64,7 +71,10 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({ isLast, }) => { const isToggle = option.type === "toggle"; - const handlePress = isToggle ? option.onToggle : option.onPress; + const isAction = option.type === "action"; + const handlePress = isToggle + ? option.onToggle + : (option as RadioOption | ActionOption).onPress; return ( <> @@ -76,7 +86,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({ {option.label} {isToggle ? ( - ) : option.selected ? ( + ) : isAction ? null : (option as RadioOption).selected ? ( ) : ( @@ -150,6 +160,15 @@ const BottomSheetContent: React.FC<{ }, }; } + if (option.type === "action") { + return { + ...option, + onPress: () => { + option.onPress(); + onClose?.(); + }, + }; + } return option; }), })); @@ -225,6 +244,9 @@ const PlatformDropdownComponent = ({ const toggleOptions = group.options.filter( (opt) => opt.type === "toggle", ) as ToggleOption[]; + const actionOptions = group.options.filter( + (opt) => opt.type === "action", + ) as ActionOption[]; const items = []; @@ -291,6 +313,21 @@ const PlatformDropdownComponent = ({ ); }); + // Add Buttons for action options (no icon) + actionOptions.forEach((option, optionIndex) => { + items.push( + , + ); + }); + return items; })} diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 997b17ed..96dfad6b 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -21,6 +21,7 @@ import { useHaptic } from "@/hooks/useHaptic"; import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; +import type { TechnicalInfo } from "@/modules/mpv-player"; import { DownloadedItem } from "@/providers/Downloads/types"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -36,6 +37,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl"; import { useVideoNavigation } from "./hooks/useVideoNavigation"; import { useVideoSlider } from "./hooks/useVideoSlider"; import { useVideoTime } from "./hooks/useVideoTime"; +import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay"; import { useControlsTimeout } from "./useControlsTimeout"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { type AspectRatio } from "./VideoScalingModeSelector"; @@ -64,6 +66,12 @@ interface Props { // Playback speed props playbackSpeed?: number; setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; + // Technical info props + showTechnicalInfo?: boolean; + onToggleTechnicalInfo?: () => void; + getTechnicalInfo?: () => Promise; + playMethod?: "DirectPlay" | "DirectStream" | "Transcode"; + transcodeReasons?: string[]; } export const Controls: FC = ({ @@ -88,6 +96,11 @@ export const Controls: FC = ({ downloadedFiles = undefined, playbackSpeed = 1.0, setPlaybackSpeed, + showTechnicalInfo = false, + onToggleTechnicalInfo, + getTechnicalInfo, + playMethod, + transcodeReasons, }) => { const offline = useOfflineMode(); const { settings, updateSettings } = useSettings(); @@ -460,6 +473,16 @@ export const Controls: FC = ({ onSkipForward={handleSkipForward} onSkipBackward={handleSkipBackward} /> + {/* Technical Info Overlay - rendered outside animated views to stay visible */} + {getTechnicalInfo && ( + + )} = ({ onZoomToggle={onZoomToggle} playbackSpeed={playbackSpeed} setPlaybackSpeed={setPlaybackSpeed} + showTechnicalInfo={showTechnicalInfo} + onToggleTechnicalInfo={onToggleTechnicalInfo} /> void; + // Technical info props + showTechnicalInfo?: boolean; + onToggleTechnicalInfo?: () => void; } export const HeaderControls: FC = ({ @@ -52,6 +55,8 @@ export const HeaderControls: FC = ({ onZoomToggle, playbackSpeed = 1.0, setPlaybackSpeed, + showTechnicalInfo = false, + onToggleTechnicalInfo, }) => { const { settings } = useSettings(); const router = useRouter(); @@ -110,6 +115,8 @@ export const HeaderControls: FC = ({ )} diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx new file mode 100644 index 00000000..1498990d --- /dev/null +++ b/components/video-player/controls/TechnicalInfoOverlay.tsx @@ -0,0 +1,283 @@ +import { type FC, memo, useCallback, useEffect, useState } from "react"; +import { Platform, StyleSheet, Text, View } from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { TechnicalInfo } from "@/modules/mpv-player"; +import { useSettings } from "@/utils/atoms/settings"; +import { HEADER_LAYOUT } from "./constants"; + +type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode"; + +interface TechnicalInfoOverlayProps { + showControls: boolean; + visible: boolean; + getTechnicalInfo: () => Promise; + playMethod?: PlayMethod; + transcodeReasons?: string[]; +} + +const formatBitrate = (bitsPerSecond: number): string => { + const mbps = bitsPerSecond / 1_000_000; + if (mbps >= 1) { + return `${mbps.toFixed(1)} Mbps`; + } + const kbps = bitsPerSecond / 1_000; + return `${kbps.toFixed(0)} Kbps`; +}; + +const formatCodec = (codec: string): string => { + // Normalize common codec names + const codecMap: Record = { + h264: "H.264", + hevc: "HEVC", + h265: "HEVC", + vp9: "VP9", + vp8: "VP8", + av1: "AV1", + aac: "AAC", + ac3: "AC3", + eac3: "E-AC3", + dts: "DTS", + truehd: "TrueHD", + flac: "FLAC", + opus: "Opus", + mp3: "MP3", + }; + return codecMap[codec.toLowerCase()] || codec.toUpperCase(); +}; + +const formatFps = (fps: number): string => { + // Common frame rates + if (Math.abs(fps - 23.976) < 0.01) return "23.976"; + if (Math.abs(fps - 29.97) < 0.01) return "29.97"; + if (Math.abs(fps - 59.94) < 0.01) return "59.94"; + if (Number.isInteger(fps)) return fps.toString(); + return fps.toFixed(2); +}; + +const getPlayMethodLabel = (method: PlayMethod): string => { + switch (method) { + case "DirectPlay": + return "Direct Play"; + case "DirectStream": + return "Direct Stream"; + case "Transcode": + return "Transcoding"; + default: + return method; + } +}; + +const getPlayMethodColor = (method: PlayMethod): string => { + switch (method) { + case "DirectPlay": + return "#4ade80"; // green + case "DirectStream": + return "#60a5fa"; // blue + case "Transcode": + return "#fbbf24"; // yellow/amber + default: + return "white"; + } +}; + +const formatTranscodeReason = (reason: string): string => { + // Convert camelCase/PascalCase to readable format + const reasonMap: Record = { + ContainerNotSupported: "Container not supported", + VideoCodecNotSupported: "Video codec not supported", + AudioCodecNotSupported: "Audio codec not supported", + SubtitleCodecNotSupported: "Subtitle codec not supported", + AudioIsExternal: "Audio is external", + SecondaryAudioNotSupported: "Secondary audio not supported", + VideoProfileNotSupported: "Video profile not supported", + VideoLevelNotSupported: "Video level not supported", + VideoResolutionNotSupported: "Resolution not supported", + VideoBitDepthNotSupported: "Bit depth not supported", + VideoFramerateNotSupported: "Framerate not supported", + RefFramesNotSupported: "Ref frames not supported", + AnamorphicVideoNotSupported: "Anamorphic video not supported", + InterlacedVideoNotSupported: "Interlaced video not supported", + AudioChannelsNotSupported: "Audio channels not supported", + AudioProfileNotSupported: "Audio profile not supported", + AudioSampleRateNotSupported: "Sample rate not supported", + AudioBitDepthNotSupported: "Audio bit depth not supported", + ContainerBitrateExceedsLimit: "Bitrate exceeds limit", + VideoBitrateNotSupported: "Video bitrate not supported", + AudioBitrateNotSupported: "Audio bitrate not supported", + UnknownVideoStreamInfo: "Unknown video stream", + UnknownAudioStreamInfo: "Unknown audio stream", + DirectPlayError: "Direct play error", + VideoRangeTypeNotSupported: "HDR not supported", + VideoCodecTagNotSupported: "Video codec tag not supported", + }; + return reasonMap[reason] || reason; +}; + +export const TechnicalInfoOverlay: FC = memo( + ({ + showControls, + visible, + getTechnicalInfo, + playMethod, + transcodeReasons, + }) => { + const { settings } = useSettings(); + const insets = useSafeAreaInsets(); + const [info, setInfo] = useState(null); + + const opacity = useSharedValue(0); + + // Animate visibility based on visible prop only (stays visible regardless of controls) + useEffect(() => { + opacity.value = withTiming(visible ? 1 : 0, { + duration: 300, + easing: Easing.out(Easing.quad), + }); + }, [visible, opacity]); + + // Fetch technical info periodically when visible + const fetchInfo = useCallback(async () => { + try { + const data = await getTechnicalInfo(); + setInfo(data); + } catch (_error) { + // Silently fail - the info is optional + } + }, [getTechnicalInfo]); + + useEffect(() => { + if (!visible) { + return; + } + + // Fetch immediately + fetchInfo(); + + // Then fetch every 2 seconds + const interval = setInterval(fetchInfo, 2000); + return () => clearInterval(interval); + }, [visible, fetchInfo]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + // Hide on TV platforms + if (Platform.isTV) return null; + + // Don't render if not visible + if (!visible) return null; + + return ( + + + {playMethod && ( + + {getPlayMethodLabel(playMethod)} + + )} + {transcodeReasons && transcodeReasons.length > 0 && ( + + {transcodeReasons.map(formatTranscodeReason).join(", ")} + + )} + {info?.videoWidth && info?.videoHeight && ( + + {info.videoWidth}x{info.videoHeight} + + )} + {info?.videoCodec && ( + + Video: {formatCodec(info.videoCodec)} + {info.fps ? ` @ ${formatFps(info.fps)} fps` : ""} + + )} + {info?.audioCodec && ( + + Audio: {formatCodec(info.audioCodec)} + + )} + {(info?.videoBitrate || info?.audioBitrate) && ( + + Bitrate:{" "} + {info.videoBitrate + ? formatBitrate(info.videoBitrate) + : info.audioBitrate + ? formatBitrate(info.audioBitrate) + : "N/A"} + + )} + {info?.cacheSeconds !== undefined && ( + + Buffer: {info.cacheSeconds.toFixed(1)}s + + )} + {info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( + + Dropped: {info.droppedFrames} frames + + )} + {!info && !playMethod && ( + Loading... + )} + + + ); + }, +); + +TechnicalInfoOverlay.displayName = "TechnicalInfoOverlay"; + +const styles = StyleSheet.create({ + container: { + position: "absolute", + zIndex: 15, + }, + infoBox: { + backgroundColor: "rgba(0, 0, 0, 0.5)", + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + minWidth: 150, + }, + infoText: { + color: "white", + fontSize: 12, + fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", + lineHeight: 18, + }, + warningText: { + color: "#ff9800", + }, + reasonText: { + color: "#fbbf24", + fontSize: 10, + }, +}); diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index 83487d29..5b631ec4 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -30,11 +30,15 @@ const SUBTITLE_SIZE_PRESETS = [ interface DropdownViewProps { playbackSpeed?: number; setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; + showTechnicalInfo?: boolean; + onToggleTechnicalInfo?: () => void; } const DropdownView = ({ playbackSpeed = 1.0, setPlaybackSpeed, + showTechnicalInfo = false, + onToggleTechnicalInfo, }: DropdownViewProps) => { const { subtitleTracks, audioTracks } = useVideoContext(); const { item, mediaSource } = usePlayerContext(); @@ -161,6 +165,21 @@ const DropdownView = ({ }); } + // Technical Info (at bottom) + if (onToggleTechnicalInfo) { + groups.push({ + options: [ + { + type: "action" as const, + label: showTechnicalInfo + ? "Hide Technical Info" + : "Show Technical Info", + onPress: onToggleTechnicalInfo, + }, + ], + }); + } + return groups; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -175,6 +194,8 @@ const DropdownView = ({ updateSettings, playbackSpeed, setPlaybackSpeed, + showTechnicalInfo, + onToggleTechnicalInfo, // Note: subtitleTracks and audioTracks are intentionally excluded // because we use subtitleTracksKey and audioTracksKey for stability ]); diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 1d94fe01..81bbbc99 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -430,6 +430,57 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { MPVLib.setPropertyDouble("panscan", panscanValue) } + // MARK: - Technical Info + + fun getTechnicalInfo(): Map { + val info = mutableMapOf() + + // Video dimensions + MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let { + info["videoWidth"] = it + } + MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let { + info["videoHeight"] = it + } + + // Video codec + MPVLib.getPropertyString("video-format")?.let { + info["videoCodec"] = it + } + + // Audio codec + MPVLib.getPropertyString("audio-codec-name")?.let { + info["audioCodec"] = it + } + + // FPS (container fps) + MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let { + info["fps"] = it + } + + // Video bitrate (bits per second) + MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let { + info["videoBitrate"] = it + } + + // Audio bitrate (bits per second) + MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let { + info["audioBitrate"] = it + } + + // Demuxer cache duration (seconds of video buffered) + MPVLib.getPropertyDouble("demuxer-cache-duration")?.let { + info["cacheSeconds"] = it + } + + // Dropped frames + MPVLib.getPropertyInt("frame-drop-count")?.let { + info["droppedFrames"] = it + } + + return info + } + // MARK: - MPVLib.EventObserver override fun eventProperty(property: String) { diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 053082e1..245c4ef5 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -173,6 +173,11 @@ class MpvPlayerModule : Module() { view.isZoomedToFill() } + // Technical info function + AsyncFunction("getTechnicalInfo") { view: MpvPlayerView -> + view.getTechnicalInfo() + } + // Defines events that the view can send to JavaScript Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index ecc7ab52..b746b74c 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -330,6 +330,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context return _isZoomedToFill } + // MARK: - Technical Info + + fun getTechnicalInfo(): Map { + return renderer?.getTechnicalInfo() ?: emptyMap() + } + // MARK: - MPVLayerRenderer.Delegate override fun onPositionChanged(position: Double, duration: Double) { diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index 1cbbd2f8..73c4bf1d 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -763,4 +763,64 @@ final class MPVLayerRenderer { getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid) return Int(aid) } + + // MARK: - Technical Info + + func getTechnicalInfo() -> [String: Any] { + guard let handle = mpv else { return [:] } + + var info: [String: Any] = [:] + + // Video dimensions + var videoWidth: Int64 = 0 + var videoHeight: Int64 = 0 + if getProperty(handle: handle, name: "video-params/w", format: MPV_FORMAT_INT64, value: &videoWidth) >= 0 { + info["videoWidth"] = Int(videoWidth) + } + if getProperty(handle: handle, name: "video-params/h", format: MPV_FORMAT_INT64, value: &videoHeight) >= 0 { + info["videoHeight"] = Int(videoHeight) + } + + // Video codec + if let videoCodec = getStringProperty(handle: handle, name: "video-format") { + info["videoCodec"] = videoCodec + } + + // Audio codec + if let audioCodec = getStringProperty(handle: handle, name: "audio-codec-name") { + info["audioCodec"] = audioCodec + } + + // FPS (container fps) + var fps: Double = 0 + if getProperty(handle: handle, name: "container-fps", format: MPV_FORMAT_DOUBLE, value: &fps) >= 0 && fps > 0 { + info["fps"] = fps + } + + // Video bitrate (bits per second) + var videoBitrate: Int64 = 0 + if getProperty(handle: handle, name: "video-bitrate", format: MPV_FORMAT_INT64, value: &videoBitrate) >= 0 && videoBitrate > 0 { + info["videoBitrate"] = Int(videoBitrate) + } + + // Audio bitrate (bits per second) + var audioBitrate: Int64 = 0 + if getProperty(handle: handle, name: "audio-bitrate", format: MPV_FORMAT_INT64, value: &audioBitrate) >= 0 && audioBitrate > 0 { + info["audioBitrate"] = Int(audioBitrate) + } + + // Demuxer cache duration (seconds of video buffered) + var cacheSeconds: Double = 0 + if getProperty(handle: handle, name: "demuxer-cache-duration", format: MPV_FORMAT_DOUBLE, value: &cacheSeconds) >= 0 { + info["cacheSeconds"] = cacheSeconds + } + + // Dropped frames + var droppedFrames: Int64 = 0 + if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 { + info["droppedFrames"] = Int(droppedFrames) + } + + return info + } } diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index c8355791..b60a3d40 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -173,6 +173,11 @@ public class MpvPlayerModule: Module { return view.isZoomedToFill() } + // Technical info function + AsyncFunction("getTechnicalInfo") { (view: MpvPlayerView) -> [String: Any] in + return view.getTechnicalInfo() + } + // Defines events that the view can send to JavaScript Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") } diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index b4cc40bf..608448b8 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -282,6 +282,12 @@ class MpvPlayerView: ExpoView { return _isZoomedToFill } + // MARK: - Technical Info + + func getTechnicalInfo() -> [String: Any] { + return renderer?.getTechnicalInfo() ?? [:] + } + deinit { pipController?.stopPictureInPicture() renderer?.stop() diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 8ed61d51..dc25007b 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -89,6 +89,8 @@ export interface MpvPlayerViewRef { // Video scaling setZoomedToFill: (zoomed: boolean) => Promise; isZoomedToFill: () => Promise; + // Technical info + getTechnicalInfo: () => Promise; } export type SubtitleTrack = { @@ -106,3 +108,15 @@ export type AudioTrack = { channels?: number; selected?: boolean; }; + +export type TechnicalInfo = { + videoWidth?: number; + videoHeight?: number; + videoCodec?: string; + audioCodec?: string; + fps?: number; + videoBitrate?: number; + audioBitrate?: number; + cacheSeconds?: number; + droppedFrames?: number; +}; diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx index e5e0ccda..ad3fcdfa 100644 --- a/modules/mpv-player/src/MpvPlayerView.tsx +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -101,6 +101,10 @@ export default React.forwardRef( isZoomedToFill: async () => { return await nativeRef.current?.isZoomedToFill(); }, + // Technical info + getTechnicalInfo: async () => { + return await nativeRef.current?.getTechnicalInfo(); + }, })); return ; diff --git a/package.json b/package.json index c21d813d..0a76b812 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "android": "cross-env EXPO_TV=0 expo run:android", "android:tv": "cross-env EXPO_TV=1 expo run:android", "build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease", + "ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production", "prepare": "husky", "typecheck": "node scripts/typecheck.js", "check": "biome check . --max-diagnostics 1000", diff --git a/scripts/ios/build-ios.ts b/scripts/ios/build-ios.ts new file mode 100644 index 00000000..1d7bf77c --- /dev/null +++ b/scripts/ios/build-ios.ts @@ -0,0 +1,1675 @@ +#!/usr/bin/env -S bun --transpile-only +/** + * Standalone iOS Build Script + * + * Author: Victor Cristea (retrozenith) <80767544+retrozenith@users.noreply.github.com> + * + * This script builds iOS apps similar to `cross-env EXPO_TV=0 expo run:ios` + * but as a completely separate standalone script. + * + * It also supports production builds similar to `eas build -p ios --local --non-interactive` + * without requiring EAS login. + * + * Usage: + * EXPO_TV=0 bun scripts/ios/build-ios.ts [options] + * + * Options: + * --configuration [Debug|Release] Xcode build configuration (default: Debug) + * --device [name|udid] Target device or simulator + * --scheme [name] Xcode scheme to build + * --no-bundler Skip starting Metro bundler + * --no-install Skip installing dependencies (pods) + * --clean Clean build before building + * --project-root [path] Project root directory (default: cwd) + * --port [number] Metro bundler port (default: 8081) + * --production Build production IPA (like eas build --local) + * --output [path] Output path for production build artifact + * --simulator Build for simulator (production mode) + * --skip-credentials Skip credentials setup (unsigned build) + * --verbose Show verbose output + * --help Show this help message + */ + +/* eslint-disable @typescript-eslint/no-var-requires */ +const fs = require("node:fs"); +const path = require("node:path"); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { spawn, execSync, spawnSync } = require("node:child_process"); + +import type { ChildProcess } from "node:child_process"; + +// Track Metro bundler process for cleanup +let metroProcess: ChildProcess | null = null; + +// ============================================================================= +// Configuration Constants +// ============================================================================= + +/** Default Metro bundler port */ +const DEFAULT_METRO_PORT = 8081; + +/** Minimum allowed port number (avoiding privileged ports) */ +const MIN_PORT = 1024; + +/** Maximum allowed port number */ +const MAX_PORT = 65535; + +/** Progress line width for padding */ +const PROGRESS_LINE_WIDTH = 60; + +/** Maximum buffer size for xcodebuild output (100MB) */ +const MAX_BUILD_BUFFER = 100 * 1024 * 1024; + +/** Default build timeout in milliseconds (30 minutes) */ +const DEFAULT_BUILD_TIMEOUT_MS = 30 * 60 * 1000; + +/** Regex to find .app path in DerivedData build output */ +const DERIVED_DATA_APP_PATH_REGEX = + /\/[\S]+\/DerivedData\/[\S]+\/Build\/Products\/[\S]+-[\S]+\/[\S]+\.app/; + +/** Name of the iOS directory */ +const IOS_DIR_NAME = "ios"; + +/** Maximum build timeout in milliseconds (2 hours) */ +const MAX_BUILD_TIMEOUT_MS = 2 * 60 * 60 * 1000; + +/** Simulator boot wait time in milliseconds (30 seconds max) */ +const SIMULATOR_BOOT_WAIT_MS = 30 * 1000; + +/** Number of output lines to show when no errors found */ +const ERROR_OUTPUT_TAIL_LINES = 50; + +// ============================================================================= +// Security Helpers +// ============================================================================= + +/** + * Validates and sanitizes a path to prevent command injection. + * Throws an error if the path contains dangerous characters. + * @param inputPath - The path to sanitize + * @param projectRoot - Optional project root to verify path doesn't escape + * @param mustExist - If true, validates that the path exists (default: false) + * @returns The validated absolute path + * @throws Error if validation fails + */ +function sanitizePath( + inputPath: string, + projectRoot?: string, + mustExist = false, +): string { + // Validate input is a string + if (typeof inputPath !== "string" || inputPath.trim() === "") { + throw new Error("Path must be a non-empty string"); + } + + // Check for null bytes (common injection technique) + if (inputPath.includes("\0")) { + throw new Error("Path contains null byte"); + } + + // Resolve to absolute path to prevent traversal + const resolved = path.resolve(inputPath); + + // Check for dangerous shell metacharacters (allow tilde for Unix home paths) + const dangerousChars = /[`$&|;<>(){}[\]!#]/; + if (dangerousChars.test(resolved)) { + throw new Error( + `Path contains potentially dangerous characters: ${resolved}`, + ); + } + + // If projectRoot provided, ensure path doesn't escape it + if (projectRoot) { + const absProjectRoot = path.resolve(projectRoot); + // Allow system temp directories for build artifacts + const systemTempDir = require("node:os").tmpdir(); + if ( + !resolved.startsWith(absProjectRoot) && + !resolved.startsWith(systemTempDir) + ) { + throw new Error( + `Path escapes project boundary: ${resolved} (expected within ${absProjectRoot})`, + ); + } + } + + // Optionally validate path exists + if (mustExist && !fs.existsSync(resolved)) { + throw new Error(`Path does not exist: ${resolved}`); + } + + // Check for symlink traversal (optional additional security) + if (mustExist && fs.existsSync(resolved)) { + try { + const realPath = fs.realpathSync(resolved); + if (projectRoot) { + const absProjectRoot = path.resolve(projectRoot); + const systemTempDir = require("node:os").tmpdir(); + if ( + !realPath.startsWith(absProjectRoot) && + !realPath.startsWith(systemTempDir) + ) { + throw new Error( + `Symlink resolves outside project boundary: ${realPath}`, + ); + } + } + } catch (error: unknown) { + if ( + error instanceof Error && + error.message?.includes("project boundary") + ) { + throw error; + } + // Ignore other errors (e.g., permission issues) + } + } + + return resolved; +} + +/** + * Validates a bundle ID to prevent command injection. + * Bundle IDs should only contain alphanumeric, dots, and hyphens. + */ +function validateBundleId(bundleId: string): string { + if (!/^[a-zA-Z0-9.-]+$/.test(bundleId)) { + throw new Error(`Invalid bundle ID format: ${bundleId}`); + } + return bundleId; +} + +/** + * Validates a scheme name to prevent command injection. + * Scheme names should only contain alphanumeric, spaces, dashes, and underscores. + */ +function validateSchemeName(scheme: string): string { + if (!/^[a-zA-Z0-9 _-]+$/.test(scheme)) { + throw new Error(`Invalid scheme name format: ${scheme}`); + } + return scheme; +} + +/** + * Validates a port number to ensure it's within valid range. + * Ports must be between 1024 and 65535 (avoiding privileged ports). + * @param port - The port number to validate + * @returns The validated port number + * @throws Error if port is invalid + */ +function validatePort(port: number): number { + if (!Number.isInteger(port)) { + throw new Error(`Port must be an integer, got: ${port}`); + } + if (port < MIN_PORT || port > MAX_PORT) { + throw new Error( + `Port must be between ${MIN_PORT} and ${MAX_PORT} (got ${port}). Privileged ports (below ${MIN_PORT}) are not allowed.`, + ); + } + return port; +} + +/** + * Validates a timeout value in milliseconds. + * @param timeoutMs - The timeout in milliseconds + * @returns The validated timeout value + * @throws Error if timeout is invalid + */ +function validateTimeout(timeoutMs: number): number { + if (!Number.isInteger(timeoutMs)) { + throw new Error(`Timeout must be an integer, got: ${timeoutMs}`); + } + if (timeoutMs < 0) { + throw new Error( + `Timeout cannot be negative, got ${timeoutMs}ms. Use --no-timeout to disable timeout.`, + ); + } + // Warn but allow large timeouts + if (timeoutMs > MAX_BUILD_TIMEOUT_MS) { + console.warn( + `\x1b[33mโš  Warning: Custom timeout ${timeoutMs / 1000}s exceeds default limit of ${MAX_BUILD_TIMEOUT_MS / 1000}s. Proceeding anyway.\x1b[0m`, + ); + } + return timeoutMs; +} + +// ============================================================================= +// Types +// ============================================================================= + +interface BuildOptions { + configuration: "Debug" | "Release"; + device?: string; + scheme?: string; + bundler: boolean; + install: boolean; + clean: boolean; + projectRoot: string; + port: number; + production: boolean; + output?: string; + simulator: boolean; + skipCredentials: boolean; + verbose: boolean; + buildTimeout: number; + noTimeout: boolean; +} + +interface XcodeProject { + name: string; + isWorkspace: boolean; + path: string; +} + +interface Device { + udid: string; + name: string; + state: string; + isSimulator: boolean; +} + +// ============================================================================= +// Argument Parsing +// ============================================================================= + +function parseArgs(argv: string[]): BuildOptions { + const args = argv.slice(2); + const options: BuildOptions = { + configuration: "Debug", + bundler: true, + install: true, + clean: false, + projectRoot: process.cwd(), + port: DEFAULT_METRO_PORT, + production: false, + simulator: false, + skipCredentials: false, + verbose: false, + buildTimeout: DEFAULT_BUILD_TIMEOUT_MS, + noTimeout: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case "--help": + case "-h": + printHelp(); + process.exit(0); + break; + case "--configuration": { + const configArg = args[++i]; + if (!configArg) { + throw new Error("--configuration requires an argument"); + } + options.configuration = (configArg as "Debug" | "Release") || "Debug"; + break; + } + case "--device": + case "-d": { + const deviceArg = args[++i]; + if (!deviceArg) { + throw new Error("--device requires an argument"); + } + options.device = deviceArg; + break; + } + case "--scheme": { + const schemeArg = args[++i]; + if (!schemeArg) { + throw new Error("--scheme requires an argument"); + } + options.scheme = schemeArg; + break; + } + case "--no-bundler": + options.bundler = false; + break; + case "--no-install": + options.install = false; + break; + case "--clean": + options.clean = true; + break; + case "--project-root": { + const rootPath = args[++i]; + if (!rootPath) { + throw new Error("--project-root requires a path argument"); + } + const resolved = path.resolve(rootPath); + if (!fs.existsSync(resolved)) { + throw new Error(`Project root does not exist: ${resolved}`); + } + if (!fs.statSync(resolved).isDirectory()) { + throw new Error(`Project root is not a directory: ${resolved}`); + } + options.projectRoot = resolved; + break; + } + case "--port": + case "-p": { + const portArg = args[++i]; + if (!portArg) { + throw new Error("--port requires an argument"); + } + const effectivePort = parseInt(portArg, 10); + // Handle 0 or NaN validly based on PR suggestion logic + // Using isNaN check allows 0 to be a valid input if that was intended, + // though typically 0 means "random free port". + // The original PR suggestion was: const effectivePort = Number.isNaN(parsedPort) ? DEFAULT_METRO_PORT : parsedPort; + const validPort = Number.isNaN(effectivePort) + ? DEFAULT_METRO_PORT + : effectivePort; + options.port = validatePort(validPort); + break; + } + case "--production": + options.production = true; + options.configuration = "Release"; + options.skipCredentials = true; // Default to unsigned builds + break; + case "--output": + case "-o": { + const outputArg = args[++i]; + if (!outputArg) { + throw new Error("--output requires a path argument"); + } + const resolvedOutput = path.resolve(outputArg); + const outputDir = path.dirname(resolvedOutput); + if (!fs.existsSync(outputDir)) { + throw new Error(`Output directory does not exist: ${outputDir}`); + } + if (!fs.statSync(outputDir).isDirectory()) { + throw new Error(`Output path is not a directory: ${outputDir}`); + } + options.output = resolvedOutput; + break; + } + case "--simulator": + options.simulator = true; + break; + case "--skip-credentials": + options.skipCredentials = true; + break; + case "--sign": + options.skipCredentials = false; + break; + case "--verbose": + options.verbose = true; + break; + case "--no-timeout": + options.noTimeout = true; + break; + case "--timeout": { + const timeoutArg = args[++i]; + if (!timeoutArg) { + throw new Error("--timeout requires an argument"); + } + const timeoutSeconds = parseInt(timeoutArg, 10); + if (Number.isNaN(timeoutSeconds)) { + throw new Error( + "Invalid timeout value. Must be a number in seconds.", + ); + } + options.buildTimeout = validateTimeout(timeoutSeconds * 1000); + break; + } + } + } + + return options; +} + +function printHelp(): void { + console.log(` +Standalone iOS Build Script + +Usage: + EXPO_TV=0 bun scripts/ios/build-ios.ts [options] + +Development Build Options: + --configuration [Debug|Release] Xcode build configuration (default: Debug) + --device, -d [name|udid] Target device or simulator + --scheme [name] Xcode scheme to build + --no-bundler Skip starting Metro bundler + --no-install Skip installing dependencies (pods) + --clean Clean build before building + --project-root [path] Project root directory (default: cwd) + --port, -p [number] Metro bundler port (default: ${DEFAULT_METRO_PORT}) + +Production Build Options: + --production Build unsigned production archive (default: no signing) + --output, -o [path] Output path for build artifact + --simulator Build .app for simulator instead of device + --sign Enable code signing (creates signed IPA) + --timeout [seconds] Build timeout in seconds (default: ${DEFAULT_BUILD_TIMEOUT_MS / 1000}s = 30min) + --no-timeout Disable build timeout entirely + +Output Options: + --verbose Stream full xcodebuild output to console. + When disabled, only errors are shown on failure. + Note: CI uses --verbose for PRs to aid debugging. + --help, -h Show this help message + +Environment Variables: + EXPO_TV=0|1 Set to 0 for phone, 1 for TV builds + NODE_ENV Set to 'production' for Release builds + +Examples: + # Development build + EXPO_TV=0 bun scripts/ios/build-ios.ts + EXPO_TV=0 bun scripts/ios/build-ios.ts --device "iPhone 15" + + # Production unsigned build (default) + EXPO_TV=0 bun scripts/ios/build-ios.ts --production + + # Production signed IPA + EXPO_TV=0 bun scripts/ios/build-ios.ts --production --sign + + # Production simulator build + EXPO_TV=0 bun scripts/ios/build-ios.ts --production --simulator + + # Long build without timeout + EXPO_TV=0 bun scripts/ios/build-ios.ts --production --no-timeout +`); +} + +// ============================================================================= +// Logging +// ============================================================================= + +const log = { + info: (msg: string) => console.log(`\x1b[36mโ€บ\x1b[0m ${msg}`), + success: (msg: string) => console.log(`\x1b[32mโœ“\x1b[0m ${msg}`), + warn: (msg: string) => console.log(`\x1b[33mโš \x1b[0m ${msg}`), + error: (msg: string) => console.error(`\x1b[31mโœ–\x1b[0m ${msg}`), + step: (msg: string) => console.log(`\x1b[1mโ€บ ${msg}\x1b[0m`), +}; + +/** + * Displays build error output in a structured format. + * Shows stderr, extracts error lines from stdout, or falls back to showing last N lines. + * @param stderr - Standard error output from the build process + * @param stdout - Standard output from the build process + * @param errorPatterns - Patterns to search for in stdout to identify errors + */ +function displayBuildError( + stderr: string, + stdout: string, + errorPatterns: string[] = [ + "error:", + "Error:", + "fatal error", + "** BUILD FAILED **", + "** ARCHIVE FAILED **", + "** EXPORT FAILED **", + ], +): void { + // Show stderr if present (may contain warnings or errors) + if (stderr.trim()) { + console.error("\n--- Build Error Output (stderr) ---"); + console.error(stderr); + console.error("--- End stderr ---\n"); + } + + // Extract and show actual error lines from stdout + const errorLines = stdout + .split("\n") + .filter((line: string) => + errorPatterns.some((pattern) => line.includes(pattern)), + ); + + if (errorLines.length > 0) { + console.error("\n--- Build Errors (from stdout) ---"); + for (const line of errorLines) { + console.error(line); + } + console.error("--- End Build Errors ---\n"); + } else if (stdout.trim()) { + // No specific error patterns found, show last N lines of stdout + const lines = stdout.split("\n"); + const lastLines = lines.slice(-ERROR_OUTPUT_TAIL_LINES).join("\n"); + console.error( + `\n--- Last ${ERROR_OUTPUT_TAIL_LINES} lines of build output ---`, + ); + console.error(lastLines); + console.error("--- End build output ---\n"); + } +} + +/** + * Sleeps for a specified duration. + * @param ms - Duration in milliseconds + */ +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +// ============================================================================= +// Platform Check +// ============================================================================= + +function assertPlatform(): void { + if (process.platform !== "darwin") { + log.error("iOS apps can only be built on macOS devices."); + log.info("Use `eas build -p ios` to build in the cloud."); + process.exit(1); + } +} + +// ============================================================================= +// Xcode Project Resolution +// ============================================================================= + +/** + * Finds the Xcode project or workspace in the iOS directory. + * Prefers .xcworkspace over .xcodeproj when both exist. + * @param projectRoot - The root directory of the project + * @returns XcodeProject object containing project information + * @throws Error if iOS directory is not found or not readable + */ +function findXcodeProject(projectRoot: string): XcodeProject { + const iosPath = path.join(projectRoot, IOS_DIR_NAME); + + try { + if (!fs.existsSync(iosPath)) { + log.error(`iOS directory not found at: ${iosPath}`); + log.info("Run `bunx expo prebuild` to generate the iOS project."); + process.exit(1); + } + + let files: string[]; + try { + files = fs.readdirSync(iosPath); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + log.error(`Failed to read iOS directory: ${errorMessage}`); + log.info(`Check permissions for directory: ${iosPath}`); + process.exit(1); + } + + // Prefer workspace over project + const workspace = files.find((f: string) => f.endsWith(".xcworkspace")); + if (workspace) { + return { + name: workspace, + isWorkspace: true, + path: path.join(iosPath, workspace), + }; + } + + const project = files.find((f: string) => f.endsWith(".xcodeproj")); + if (project) { + return { + name: project, + isWorkspace: false, + path: path.join(iosPath, project), + }; + } + + log.error("No Xcode project or workspace found in ios/ directory"); + log.info("Run `bunx expo prebuild` to generate the iOS project."); + process.exit(1); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error(`Unexpected error finding Xcode project: ${errorMessage}`); + throw error; + } +} + +// ============================================================================= +// Scheme Resolution +// ============================================================================= + +/** + * Retrieves available schemes from an Xcode project. + * Falls back to project name if xcodebuild command fails. + * @param xcodeProject - The Xcode project to query + * @returns Array of scheme names + */ +function getSchemes(xcodeProject: XcodeProject): string[] { + try { + const flag = xcodeProject.isWorkspace ? "-workspace" : "-project"; + const safePath = sanitizePath(xcodeProject.path); + const result = spawnSync("xcodebuild", ["-list", flag, safePath], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + const output = result.stdout || ""; + + const schemesMatch = output.match(/Schemes:\s*\n([\s\S]*?)(?:\n\n|\n$|$)/); + if (schemesMatch) { + return schemesMatch[1] + .split("\n") + .map((s: string) => s.trim()) + .filter(Boolean); + } + } catch (error) { + // xcodebuild -list failed; log a warning and fall back to inferring from project name + console.warn( + `Warning: Failed to list Xcode schemes for project "${xcodeProject.path}". Falling back to project-name-based scheme inference.`, + error, + ); + } + + // Default scheme from project name + const name = xcodeProject.name.replace(/\.(xcworkspace|xcodeproj)$/, ""); + return [name]; +} + +/** + * Resolves and validates the scheme name for building. + * Uses provided scheme if valid, otherwise selects best match. + * @param xcodeProject - The Xcode project + * @param schemeName - Optional scheme name to use + * @returns The validated scheme name + * @throws Error if no schemes are available + */ +function resolveScheme( + xcodeProject: XcodeProject, + schemeName?: string, +): string { + const schemes = getSchemes(xcodeProject); + + if (schemeName && schemes.includes(schemeName)) { + return validateSchemeName(schemeName); + } + + if (schemes.length === 0) { + log.error("No schemes found in Xcode project"); + process.exit(1); + } + + // Prefer scheme that matches project name + const projectName = xcodeProject.name.replace( + /\.(xcworkspace|xcodeproj)$/, + "", + ); + const matchingScheme = schemes.find((s) => s === projectName); + + const finalScheme = matchingScheme || schemes[0]; + return validateSchemeName(finalScheme); +} + +// ============================================================================= +// Device Resolution +// ============================================================================= + +/** + * Retrieves list of available iOS simulators. + * @returns Array of Device objects representing available simulators + */ +function getAvailableSimulators(): Device[] { + try { + const output = execSync("xcrun simctl list devices available --json", { + encoding: "utf-8", + }); + const devicesData = JSON.parse(output); + const devices: Device[] = []; + + for (const [runtime, deviceList] of Object.entries( + devicesData.devices || {}, + )) { + if (!runtime.includes("iOS")) continue; + + for (const device of deviceList as any[]) { + devices.push({ + udid: device.udid, + name: device.name, + state: device.state, + isSimulator: true, + }); + } + } + + return devices; + } catch { + return []; + } +} + +/** + * Resolves target device for installation. + * Prefers booted simulator or matches by name/UDID. + * @param deviceName - Optional device name or UDID to match + * @returns Device object for the resolved target + * @throws Error if no simulators are available + */ +function resolveDevice(deviceName?: string): Device { + const simulators = getAvailableSimulators(); + + if (simulators.length === 0) { + log.error("No iOS simulators available."); + log.info("Create a simulator using Xcode or `xcrun simctl create`"); + process.exit(1); + } + + if (deviceName) { + // Match by name or UDID + const match = simulators.find( + (d) => + d.udid.toLowerCase() === deviceName.toLowerCase() || + d.name.toLowerCase().includes(deviceName.toLowerCase()), + ); + if (match) { + return match; + } + log.warn(`Device "${deviceName}" not found, using default simulator.`); + } + + // Prefer booted simulator, otherwise pick the first iPhone + const bootedDevice = simulators.find((d) => d.state === "Booted"); + if (bootedDevice) { + return bootedDevice; + } + + const iPhoneDevice = simulators.find((d) => d.name.includes("iPhone")); + return iPhoneDevice || simulators[0]; +} + +// ============================================================================= +// CocoaPods +// ============================================================================= + +/** + * Installs CocoaPods dependencies for the iOS project. + * Tries `pod install` first, falls back to `pod install --repo-update`. + * @param projectRoot - Project root directory + */ +function installPods(projectRoot: string): void { + const iosPath = path.join(projectRoot, IOS_DIR_NAME); + const podfilePath = path.join(iosPath, "Podfile"); + + if (!fs.existsSync(podfilePath)) { + log.info("No Podfile found, skipping pod install"); + return; + } + + log.step("Installing CocoaPods dependencies..."); + + try { + execSync("pod install", { + cwd: iosPath, + stdio: "inherit", + env: { ...process.env }, + }); + log.success("Pods installed successfully"); + } catch (_error) { + log.warn("Pod install failed, trying with repo update..."); + try { + execSync("pod install --repo-update", { + cwd: iosPath, + stdio: "inherit", + env: { ...process.env }, + }); + log.success("Pods installed successfully"); + } catch { + log.error("Failed to install CocoaPods dependencies"); + process.exit(1); + } + } +} + +// ============================================================================= +// Build Process +// ============================================================================= + +function getXcodeBuildArgs( + xcodeProject: XcodeProject, + scheme: string, + device: Device, + options: BuildOptions, +): string[] { + const args = [ + xcodeProject.isWorkspace ? "-workspace" : "-project", + xcodeProject.path, + "-configuration", + options.configuration, + "-scheme", + scheme, + "-destination", + `id=${device.udid}`, + ]; + + if (options.clean) { + args.push("clean", "build"); + } + + return args; +} + +function getProcessEnv(options: BuildOptions): NodeJS.ProcessEnv { + return { + ...process.env, + RCT_METRO_PORT: options.port.toString(), + RCT_NO_LAUNCH_PACKAGER: options.bundler ? undefined : "true", + // Preserve EXPO_TV and other environment variables + EXPO_TV: process.env.EXPO_TV, + }; +} + +async function runXcodeBuild( + args: string[], + env: NodeJS.ProcessEnv, + verbose = false, +): Promise { + return new Promise((resolve, reject) => { + log.step("Building iOS app..."); + log.info(`xcodebuild ${args.join(" ")}`); + + const buildProcess = spawn("xcodebuild", args, { + env, + stdio: ["inherit", "pipe", "pipe"], + }); + + let output = ""; + let errorOutput = ""; + + // Always capture stdout for binary path extraction + buildProcess.stdout?.on("data", (data: Buffer) => { + const str = data.toString(); + output += str; + + if (verbose) { + process.stdout.write(str); + } else { + // Simple progress indicator + if (str.includes("Build succeeded")) { + log.success("Build succeeded"); + } else if (str.includes("Compiling")) { + // Show compilation progress + const match = str.match(/Compiling\s+(\S+)/); + if (match) { + process.stdout.write( + `\r Compiling ${match[1]}...`.padEnd(PROGRESS_LINE_WIDTH), + ); + } + } + } + }); + + buildProcess.stderr?.on("data", (data: Buffer) => { + const str = data.toString(); + errorOutput += str; + if (verbose) { + process.stderr.write(str); + } + }); + + buildProcess.on("close", (code: number | null) => { + if (!verbose) { + process.stdout.write("\n"); + } + if (code === 0) { + resolve(output); + } else if (code === null) { + log.error("xcodebuild process terminated abnormally (no exit code)"); + if (errorOutput && !verbose) { + console.error(errorOutput); + } + reject(new Error("Build process exited without code")); + } else { + log.error(`xcodebuild exited with code ${code}`); + if (errorOutput && !verbose) { + console.error(errorOutput); + } + reject(new Error(`Build failed with code ${code}`)); + } + }); + }); +} + +function extractBinaryPath(buildOutput: string): string | null { + // Extract CONFIGURATION_BUILD_DIR and UNLOCALIZED_RESOURCES_FOLDER_PATH + const buildDirMatch = buildOutput.match( + /export CONFIGURATION_BUILD_DIR\\?=(.+)$/m, + ); + const appNameMatch = buildOutput.match( + /export UNLOCALIZED_RESOURCES_FOLDER_PATH\\?=(.+)$/m, + ); + + if (buildDirMatch && appNameMatch) { + return path.join(buildDirMatch[1], appNameMatch[1]); + } + + // Fallback: find .app path in DerivedData + const appPathMatch = buildOutput.match(DERIVED_DATA_APP_PATH_REGEX); + return appPathMatch ? appPathMatch[0] : null; +} + +// ============================================================================= +// App Launch +// ============================================================================= + +/** + * Waits for simulator to boot by polling its state. + * @param udid - Simulator UDID + * @param maxWaitMs - Maximum time to wait in milliseconds + * @throws Error if simulator doesn't boot within timeout + */ +async function waitForSimulatorBoot( + udid: string, + maxWaitMs: number, +): Promise { + const startTime = Date.now(); + const pollIntervalMs = 1000; // Check every second + + while (Date.now() - startTime < maxWaitMs) { + try { + const result = execSync("xcrun simctl list devices --json", { + encoding: "utf-8", + }); + const data = JSON.parse(result); + + // Find the device in the JSON output + let isBooted = false; + const devices = data.devices || {}; + + for (const runtime in devices) { + if (Object.hasOwn(devices, runtime)) { + const deviceList = devices[runtime]; + if (Array.isArray(deviceList)) { + const device = deviceList.find((d: any) => d.udid === udid); + if (device && device.state === "Booted") { + isBooted = true; + break; + } + } + } + } + + if (isBooted) { + log.info("Simulator is ready"); + return; + } + } catch { + // Simulator not found or not booted yet, continue polling + if (pollIntervalMs > 1000) { + // Only log if we've been waiting a while to avoid spam + // console.warn("Simulator polling failed, retrying..."); + } + } + + // Wait before next poll + await sleep(pollIntervalMs); + } + + throw new Error( + `Simulator failed to boot within ${maxWaitMs / 1000} seconds`, + ); +} + +/** + * Installs and launches the app on the specified simulator. + * Handles simulator booting, app installation, and launching. + * @param binaryPath - Path to the compiled .app directory + * @param device - Target simulator device + * @throws Error if installation or launch fails + */ +async function launchApp(binaryPath: string, device: Device): Promise { + log.step("Installing and launching app..."); + + const sanitizedBinaryPath = sanitizePath(binaryPath); + + // Boot simulator if not running + if (device.state !== "Booted") { + log.info(`Booting simulator: ${device.name}`); + try { + // Use spawnSync with array to prevent command injection via device.udid + spawnSync("xcrun", ["simctl", "boot", device.udid], { stdio: "ignore" }); + } catch { + // May already be booting + } + } + + // Open Simulator app + spawnSync("open", ["-a", "Simulator"], { stdio: "ignore" }); + + // Wait for simulator to be ready with polling + await waitForSimulatorBoot(device.udid, SIMULATOR_BOOT_WAIT_MS); + + // Install the app + log.info("Installing app on simulator..."); + try { + // Use spawnSync with array to prevent command injection + const installResult = spawnSync( + "xcrun", + ["simctl", "install", device.udid, sanitizedBinaryPath], + { stdio: "inherit" }, + ); + if (installResult.status !== 0) { + throw new Error("simctl install failed"); + } + } catch (error) { + log.error("Failed to install app on simulator"); + throw error; + } + + // Get bundle ID from Info.plist + const infoPlistPath = path.join(sanitizedBinaryPath, "Info.plist"); + let bundleId: string | null = null; + + try { + const bundleIdResult = spawnSync( + "/usr/libexec/PlistBuddy", + ["-c", "Print:CFBundleIdentifier", infoPlistPath], + { encoding: "utf-8" }, + ); + if (bundleIdResult.status === 0 && bundleIdResult.stdout) { + bundleId = validateBundleId(bundleIdResult.stdout.toString().trim()); + } + } catch { + log.warn("Could not read bundle ID from Info.plist"); + } + + if (bundleId) { + log.info(`Launching app: ${bundleId}`); + try { + // Use spawnSync with array to prevent command injection + const launchResult = spawnSync( + "xcrun", + ["simctl", "launch", device.udid, bundleId], + { stdio: "inherit" }, + ); + if (launchResult.status !== 0) { + throw new Error("simctl launch failed"); + } + log.success(`App launched on ${device.name}`); + } catch (error) { + log.error("Failed to launch app"); + throw error; + } + } +} + +// ============================================================================= +// Metro Bundler +// ============================================================================= + +/** + * Starts the Metro bundler in a detached process. + * Tracks the process for cleanup on script exit. + * @param projectRoot - Project root directory + * @param port - Port to run Metro on + */ +function startMetroBundler(projectRoot: string, port: number): void { + log.step("Starting Metro bundler..."); + + metroProcess = spawn("bunx", ["expo", "start", "--port", port.toString()], { + cwd: sanitizePath(projectRoot), + stdio: "inherit", + detached: true, + env: { ...process.env }, + }); + + if (metroProcess) { + metroProcess.unref(); + } + log.info(`Metro bundler started on port ${port}`); +} + +/** + * Cleanup handler for Metro bundler process. + * Ensures Metro is killed when script exits. + */ +function cleanupMetroBundler(): void { + if (metroProcess && !metroProcess.killed) { + try { + metroProcess.kill(); + log.info("Metro bundler stopped"); + } catch (_error) { + // Process might already be killed, ignore error + } + } +} + +// Register cleanup handler for process exit +process.on("exit", cleanupMetroBundler); +// Handle process termination for graceful cleanup +const handleExit = (signal: NodeJS.Signals, exitCode: number) => () => { + log.info(`Received ${signal}, shutting down gracefully...`); + cleanupMetroBundler(); + process.exit(exitCode); +}; + +process.on("SIGINT", handleExit("SIGINT", 130)); +process.on("SIGTERM", handleExit("SIGTERM", 143)); + +// ============================================================================= +// Production Build (IPA/App Archive) +// ============================================================================= + +interface AppConfig { + expo?: { + ios?: { + bundleIdentifier?: string; + }; + }; + ios?: { + bundleIdentifier?: string; + }; +} + +/** + * Reads and parses the app configuration. + * @param projectRoot - Project root directory + * @returns Parsed app configuration object + */ +function getAppConfig(projectRoot: string): AppConfig { + // Try to read app.json or app.config.js + const appJsonPath = path.join(projectRoot, "app.json"); + const appConfigPath = path.join(projectRoot, "app.config.js"); + const appConfigTsPath = path.join(projectRoot, "app.config.ts"); + + if (fs.existsSync(appJsonPath)) { + try { + const content = fs.readFileSync(appJsonPath, "utf-8"); + const parsed = JSON.parse(content); + return parsed.expo || parsed; + } catch (error) { + log.warn( + `Failed to parse app.json: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + log.info("Continuing with default configuration"); + return {}; + } + } + + // For JS/TS configs, we'd need to evaluate them - just return defaults + if (fs.existsSync(appConfigPath) || fs.existsSync(appConfigTsPath)) { + log.warn("Dynamic app config detected. Using defaults for bundle ID."); + return {}; + } + + return {}; +} + +function getBundleIdentifier( + projectRoot: string, + xcodeProject: XcodeProject, +): string { + const appConfig = getAppConfig(projectRoot); + + // Try from app config + if (appConfig.ios?.bundleIdentifier) { + return appConfig.ios.bundleIdentifier; + } + + // Try from Xcode project + const projectName = xcodeProject.name.replace( + /\.(xcworkspace|xcodeproj)$/, + "", + ); + const pbxprojPath = path.join( + projectRoot, + IOS_DIR_NAME, + `${projectName}.xcodeproj`, + "project.pbxproj", + ); + + if (fs.existsSync(pbxprojPath)) { + try { + const content = fs.readFileSync(pbxprojPath, "utf-8"); + const match = content.match( + /PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";]+)"?/, + ); + if (match) { + return match[1]; + } + } catch { + // Fall through + } + } + + // Default fallback + return `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, "")}`; +} + +/** + * Creates the ExportOptions.plist file for IPA export. + * @param options - Build options + * @param outputDir - Directory to write the plist to + * @returns Path to the created plist file + * @throws Error if file creation fails + */ +function createExportOptionsPlist( + options: BuildOptions, + outputDir: string, +): string { + const plistPath = path.join(outputDir, "ExportOptions.plist"); + + const exportMethod = options.simulator + ? "development" + : options.skipCredentials + ? "development" + : "ad-hoc"; + + const plistContent = ` + + + + method + ${exportMethod} + signingStyle + automatic + stripSwiftSymbols + + compileBitcode + + +`; + + try { + fs.writeFileSync(plistPath, plistContent); + + if (!fs.existsSync(plistPath)) { + throw new Error("Failed to write ExportOptions.plist"); + } + + return plistPath; + } catch (error) { + log.error( + `Failed to create export options: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + throw error; + } +} + +/** + * Executes xcodebuild command with standardized error handling and options. + * @param args - Arguments for xcodebuild + * @param options - Build options + * @param errorContext - Description of action for error messages + */ +function runXcodeBuildCommand( + args: string[], + options: BuildOptions, + errorContext: string, +): void { + log.info(`xcodebuild ${args.join(" ")}`); + + const result = spawnSync("xcodebuild", args, { + cwd: sanitizePath(path.join(options.projectRoot, IOS_DIR_NAME)), + stdio: options.verbose ? "inherit" : "pipe", + maxBuffer: MAX_BUILD_BUFFER, + timeout: options.noTimeout ? undefined : options.buildTimeout, + env: { + ...process.env, + EXPO_TV: process.env.EXPO_TV, + }, + }); + + if (result.status !== 0) { + log.error(`${errorContext} failed`); + if (!options.verbose && result.stderr) { + const stderr = result.stderr.toString(); + const stdout = result.stdout?.toString() || ""; + displayBuildError(stderr, stdout); + log.info("Run with --verbose to see full build output"); + } else if (!options.verbose) { + log.info( + "No detailed error output available. Run with --verbose to see more.", + ); + } + process.exit(1); + } +} + +/** + * Orchestrates the production build process (Archive -> Export IPA). + * Handles both device (IPA) and simulator (.app) builds. + * @param options - Build options + * @throws Error if build, archive, or export fails + */ +async function runProductionBuild(options: BuildOptions): Promise { + log.step("Production Build Mode"); + console.log( + ` Building ${options.simulator ? "Simulator" : "Device"} artifact...`, + ); + console.log(""); + + const xcodeProject = findXcodeProject(options.projectRoot); + log.info(`Found Xcode project: ${xcodeProject.name}`); + + const scheme = resolveScheme(xcodeProject, options.scheme); + log.info(`Using scheme: ${scheme}`); + + const bundleId = getBundleIdentifier(options.projectRoot, xcodeProject); + log.info(`Bundle ID: ${bundleId}`); + + // Install pods if needed + if (options.install) { + installPods(options.projectRoot); + } + + // Create output directory + const outputDir = options.output + ? path.dirname(options.output) + : path.join(options.projectRoot, "build"); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const archivePath = path.join(outputDir, `${scheme}.xcarchive`); + const projectOrWorkspaceFlag = xcodeProject.isWorkspace + ? "-workspace" + : "-project"; + + if (options.simulator) { + // Simulator build - just build the .app + log.step("Building for Simulator..."); + + const buildArgs = [ + projectOrWorkspaceFlag, + sanitizePath(xcodeProject.path), + "-scheme", + scheme, + "-configuration", + options.configuration, + "-sdk", + "iphonesimulator", + "-derivedDataPath", + sanitizePath(path.join(outputDir, "DerivedData")), + "ONLY_ACTIVE_ARCH=NO", + "CODE_SIGNING_ALLOWED=NO", + "build", + ]; + + if (options.clean) { + buildArgs.unshift("clean"); + } + + runXcodeBuildCommand(buildArgs, options, "Simulator build"); + + // Find the built .app + const derivedDataPath = path.join(outputDir, "DerivedData"); + const productsPath = path.join(derivedDataPath, "Build", "Products"); + + let appPath: string | null = null; + + if (fs.existsSync(productsPath)) { + const configDir = fs + .readdirSync(productsPath) + .find((d: string) => d.includes("iphonesimulator")); + if (configDir) { + const configPath = path.join(productsPath, configDir); + const appName = fs + .readdirSync(configPath) + .find((f: string) => f.endsWith(".app")); + if (appName) { + appPath = path.join(configPath, appName); + } + } + } + + if (appPath && fs.existsSync(appPath)) { + // Copy to output location + const finalPath = sanitizePath( + options.output || path.join(outputDir, `${scheme}.app`), + options.projectRoot, + ); + if (fs.existsSync(finalPath)) { + fs.rmSync(finalPath, { recursive: true }); + } + fs.cpSync(sanitizePath(appPath), finalPath, { + recursive: true, + }); + + console.log(""); + log.success("Simulator build complete!"); + log.info(`Output: ${finalPath}`); + } else { + log.warn("Build completed but .app not found"); + } + } else { + // Device build - create archive and export IPA + log.step("Creating Archive..."); + + const archiveArgs = [ + projectOrWorkspaceFlag, + sanitizePath(xcodeProject.path), + "-scheme", + scheme, + "-configuration", + options.configuration, + "-archivePath", + sanitizePath(archivePath), + "archive", + ]; + + if (!options.skipCredentials) { + archiveArgs.push( + "CODE_SIGN_STYLE=Automatic", + "-allowProvisioningUpdates", + ); + } else { + archiveArgs.push("CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO"); + } + + if (options.clean) { + archiveArgs.unshift("clean"); + } + + runXcodeBuildCommand(archiveArgs, options, "Archive creation"); + + if (!fs.existsSync(archivePath)) { + log.error("Archive was not created"); + process.exit(1); + } + + log.success(`Archive created: ${archivePath}`); + + if (!options.skipCredentials) { + // Export IPA + log.step("Exporting IPA..."); + + const exportDir = path.join(outputDir, "export"); + if (!fs.existsSync(exportDir)) { + fs.mkdirSync(exportDir, { recursive: true }); + } + + const exportPlistPath = createExportOptionsPlist(options, outputDir); + + const exportArgs = [ + "-exportArchive", + "-archivePath", + sanitizePath(archivePath), + "-exportPath", + sanitizePath(exportDir), + "-exportOptionsPlist", + sanitizePath(exportPlistPath), + "-allowProvisioningUpdates", + ]; + + runXcodeBuildCommand(exportArgs, options, "IPA export"); + + // Find the IPA + const ipaFile = fs + .readdirSync(exportDir) + .find((f: string) => f.endsWith(".ipa")); + if (ipaFile) { + const ipaPath = path.join(exportDir, ipaFile); + const finalPath = sanitizePath( + options.output || path.join(outputDir, `${scheme}.ipa`), + options.projectRoot, + ); + + if (finalPath !== ipaPath) { + fs.copyFileSync(ipaPath, finalPath); + } + + console.log(""); + log.success("Production build complete!"); + log.info(`IPA: ${finalPath}`); + log.info(`Archive: ${archivePath}`); + } else { + log.warn("IPA not found in export directory"); + log.info(`Archive available at: ${archivePath}`); + } + } else { + // Create unsigned IPA manually from the archive + log.step("Creating unsigned IPA from archive..."); + + const productsPath = path.join(archivePath, "Products", "Applications"); + if (!fs.existsSync(productsPath)) { + log.error("Could not find Products/Applications in archive"); + log.info(`Archive available at: ${archivePath}`); + process.exit(1); + } + + const appName = fs + .readdirSync(productsPath) + .find((f: string) => f.endsWith(".app")); + if (!appName) { + log.error("Could not find .app in archive"); + log.info(`Archive available at: ${archivePath}`); + process.exit(1); + } + + const appPath = path.join(productsPath, appName); + const payloadDir = path.join(outputDir, "Payload"); + const ipaPath = sanitizePath( + options.output || path.join(outputDir, `${scheme}.ipa`), + options.projectRoot, + ); + + // Clean up previous Payload directory if exists + if (fs.existsSync(payloadDir)) { + fs.rmSync(payloadDir, { recursive: true }); + } + fs.mkdirSync(payloadDir, { recursive: true }); + + // Copy .app to Payload + log.info("Copying app to Payload folder..."); + fs.cpSync( + sanitizePath(appPath), + path.join(payloadDir, path.basename(appPath)), + { + recursive: true, + }, + ); + + // Create IPA (zip the Payload folder) + log.info("Creating IPA..."); + const safeOutputDir = sanitizePath(outputDir); + const safeIpaPath = ipaPath; // Already sanitized above + + // Zip Payload directory to create IPA + // Using /usr/bin/zip to ensure compatibility + const zipArgs = ["-r", "-y", safeIpaPath, "Payload"]; + + const zipResult = spawnSync("zip", zipArgs, { + cwd: safeOutputDir, // Run zip from output dir so Payload is at root of archive + stdio: options.verbose ? "inherit" : "pipe", + env: { ...process.env }, + }); + + if (zipResult.status !== 0) { + log.error("Failed to create IPA"); + if (!options.verbose) { + console.error(zipResult.stderr?.toString()); + } + process.exit(1); + } + + // Clean up Payload directory + fs.rmSync(payloadDir, { recursive: true }); + + console.log(""); + log.success("Unsigned IPA created!"); + log.info(`IPA: ${ipaPath}`); + log.info(`Archive: ${archivePath}`); + log.warn( + "Note: This IPA is unsigned and cannot be installed on devices without signing.", + ); + } + } +} + +// ============================================================================= +// Main +// ============================================================================= + +/** + * Main entry point for the iOS build script. + * Handles argument parsing, environment setup, and dispatching to appropriate build flow. + * @throws Error if build fails to complete + */ +async function main(): Promise { + assertPlatform(); + + const options = parseArgs(process.argv); + + console.log("\n"); + log.step("Standalone iOS Build Script"); + console.log(` EXPO_TV=${process.env.EXPO_TV || "not set"}`); + console.log(` Mode: ${options.production ? "Production" : "Development"}`); + console.log(` Configuration: ${options.configuration}`); + console.log(` Project Root: ${options.projectRoot}`); + console.log("\n"); + + // Production build mode + if (options.production) { + await runProductionBuild(options); + return; + } + + // Development build mode (original behavior) + // Find Xcode project + const xcodeProject = findXcodeProject(options.projectRoot); + log.info(`Found Xcode project: ${xcodeProject.name}`); + + // Resolve scheme + const scheme = resolveScheme(xcodeProject, options.scheme); + log.info(`Using scheme: ${scheme}`); + + // Resolve device + const device = resolveDevice(options.device); + log.info(`Target device: ${device.name} (${device.udid})`); + + // Install pods if needed + if (options.install) { + installPods(options.projectRoot); + } + + // Build the app + const buildArgs = getXcodeBuildArgs(xcodeProject, scheme, device, options); + const env = getProcessEnv(options); + try { + const buildOutput = await runXcodeBuild(buildArgs, env, options.verbose); + + // Find the built binary + const binaryPath = extractBinaryPath(buildOutput); + + if (binaryPath && fs.existsSync(binaryPath)) { + log.success(`Built app at: ${binaryPath}`); + + // Start Metro bundler if needed + if (options.bundler && options.configuration === "Debug") { + startMetroBundler(options.projectRoot, options.port); + } + + // Launch the app + await launchApp(binaryPath, device); + + console.log("\n"); + log.success("Build complete!"); + + if (options.bundler) { + log.info("Metro bundler is running. Press Ctrl+C to stop."); + } + } else { + log.warn("Built successfully but could not locate app binary"); + log.info("Check the Xcode build output for the .app location"); + } + } catch (_error) { + log.error("Development build failed"); + process.exit(1); + } +} + +main().catch((error) => { + log.error(error.message); + process.exit(1); +});