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 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);
+});