1515from utils .logger import logger
1616from utils .auth_utils import get_optional_user_id
1717from services .supabase import DBConnection
18+ from utils .qr_extractor import extract_expo_url
1819
1920# Preview proxy URL - removes Daytona warning page
2021PREVIEW_PROXY_URL = os .environ .get ('PREVIEW_PROXY_URL' , 'https://preview.trycheatcode.com' )
@@ -1095,6 +1096,146 @@ async def get_sandbox_preview_url(
10951096# Complex proxy endpoint removed - replaced with simple preview URL endpoint above
10961097
10971098
1099+ @router .get ("/sandboxes/{sandbox_id}/expo-url" )
1100+ async def get_expo_url (
1101+ sandbox_id : str ,
1102+ user_id : str = Depends (get_optional_user_id )
1103+ ):
1104+ """
1105+ Get the Expo URL for a mobile sandbox using Daytona's deterministic preview links.
1106+
1107+ This endpoint uses Daytona's get_preview_link() API to generate a deterministic
1108+ exp:// URL that Expo Go can use to connect to the Metro bundler. This approach
1109+ is more reliable than parsing terminal logs for tunnel URLs.
1110+
1111+ The URL format is: exp://{port}-{sandbox_id}.{daytona_domain}
1112+ """
1113+ if not db :
1114+ raise HTTPException (status_code = 500 , detail = "Database not initialized" )
1115+
1116+ if not user_id :
1117+ raise HTTPException (status_code = 401 , detail = "Authentication required" )
1118+
1119+ client = await db .client
1120+
1121+ # Verify the user has access to this sandbox
1122+ await verify_sandbox_access (client , sandbox_id , user_id )
1123+
1124+ try :
1125+ # Verify this is a mobile project
1126+ project_result = await client .table ('projects' ).select ('app_type' ).filter ('sandbox->>id' , 'eq' , sandbox_id ).execute ()
1127+ if not project_result .data :
1128+ raise HTTPException (status_code = 404 , detail = "Project not found for sandbox" )
1129+
1130+ app_type = project_result .data [0 ].get ('app_type' , 'web' )
1131+
1132+ if app_type != 'mobile' :
1133+ return {
1134+ "expo_url" : None ,
1135+ "status" : "not_mobile_project" ,
1136+ "message" : "This endpoint is only available for mobile projects"
1137+ }
1138+
1139+ # Get sandbox (don't start if stopped - just check current state)
1140+ sandbox = await get_sandbox_by_id_safely (client , sandbox_id , start_if_stopped = False )
1141+
1142+ if not sandbox :
1143+ return {
1144+ "expo_url" : None ,
1145+ "status" : "sandbox_not_running"
1146+ }
1147+
1148+ # DETERMINISTIC APPROACH: Use Daytona's get_preview_link API
1149+ # This returns a URL based on sandbox ID - no log parsing needed!
1150+ # Metro bundler runs on port 8081 for Expo apps
1151+ METRO_PORT = 8081
1152+
1153+ try :
1154+ # Get the deterministic preview link from Daytona
1155+ preview_info = await sandbox .get_preview_link (METRO_PORT )
1156+ daytona_url = preview_info .url if hasattr (preview_info , 'url' ) else str (preview_info )
1157+
1158+ logger .info (f"[EXPO URL] Got Daytona preview URL for port { METRO_PORT } : { daytona_url } " )
1159+
1160+ if daytona_url :
1161+ # Convert HTTPS URL to exp:// URL for Expo Go
1162+ # Daytona URL format: https://8081-{sandbox_id}.{domain}
1163+ # Expo Go URL format: exp://8081-{sandbox_id}.{domain}
1164+ if daytona_url .startswith ('https://' ):
1165+ expo_url = daytona_url .replace ('https://' , 'exp://' , 1 )
1166+ elif daytona_url .startswith ('http://' ):
1167+ expo_url = daytona_url .replace ('http://' , 'exp://' , 1 )
1168+ else :
1169+ expo_url = f"exp://{ daytona_url } "
1170+
1171+ # Remove any trailing slashes
1172+ expo_url = expo_url .rstrip ('/' )
1173+
1174+ logger .info (f"[EXPO URL] Deterministic Expo URL for sandbox { sandbox_id } : { expo_url } " )
1175+
1176+ return {
1177+ "expo_url" : expo_url ,
1178+ "daytona_url" : daytona_url , # Include original URL for debugging
1179+ "status" : "available" ,
1180+ "method" : "daytona_preview_link" # Indicate which method was used
1181+ }
1182+
1183+ except Exception as preview_error :
1184+ logger .warning (f"[EXPO URL] Failed to get Daytona preview link: { preview_error } " )
1185+ # Fall through to legacy log parsing method
1186+
1187+ # FALLBACK: Try legacy log parsing if Daytona preview link fails
1188+ # This handles cases where the Expo tunnel might have a different URL
1189+ logger .info (f"[EXPO URL] Falling back to log parsing method for sandbox { sandbox_id } " )
1190+
1191+ try :
1192+ # Try to extract exp.direct URL from tmux session (Expo's tunnel format)
1193+ extract_cmd = """
1194+ # Check for exp.direct URL in Metro output (Expo's tunnel domain)
1195+ if command -v tmux &> /dev/null && tmux has-session -t dev_server_mobile 2>/dev/null; then
1196+ TMUX_OUTPUT=$(tmux capture-pane -t dev_server_mobile -p -S -200 2>/dev/null)
1197+ # Look for exp.direct URL pattern
1198+ EXP_URL=$(echo "$TMUX_OUTPUT" | grep -oE 'exp://[a-zA-Z0-9-]+\.exp\.direct' | tail -1)
1199+ if [ -n "$EXP_URL" ]; then
1200+ echo "$EXP_URL"
1201+ exit 0
1202+ fi
1203+ # Also check for exp.direct with port suffix
1204+ EXP_URL=$(echo "$TMUX_OUTPUT" | grep -oE 'exp://[a-zA-Z0-9-]+-[0-9]+\.exp\.direct' | tail -1)
1205+ if [ -n "$EXP_URL" ]; then
1206+ echo "$EXP_URL"
1207+ exit 0
1208+ fi
1209+ fi
1210+ echo ""
1211+ """
1212+
1213+ result = await sandbox .process .exec (extract_cmd , timeout = 10 )
1214+
1215+ if result and hasattr (result , 'result' ) and result .result :
1216+ output = result .result .strip ()
1217+ if output and output .startswith ('exp://' ):
1218+ logger .info (f"[EXPO URL] Found Expo tunnel URL from logs: { output } " )
1219+ return {
1220+ "expo_url" : output ,
1221+ "status" : "available" ,
1222+ "method" : "log_parsing"
1223+ }
1224+
1225+ except Exception as log_error :
1226+ logger .warning (f"[EXPO URL] Log parsing fallback failed: { log_error } " )
1227+
1228+ return {
1229+ "expo_url" : None ,
1230+ "status" : "not_ready" ,
1231+ "message" : "Expo URL not yet available. The dev server may still be starting."
1232+ }
1233+
1234+ except Exception as e :
1235+ logger .error (f"Error getting Expo URL for sandbox { sandbox_id } : { str (e )} " )
1236+ raise HTTPException (status_code = 500 , detail = f"Error getting Expo URL: { str (e )} " )
1237+
1238+
10981239@router .get ("/sandboxes/{sandbox_id}/dev-server/stream" )
10991240async def stream_dev_server_status (
11001241 sandbox_id : str ,
@@ -1158,10 +1299,11 @@ async def event_generator():
11581299 # Stream logs using Daytona's async log streaming
11591300 server_ready = False
11601301 log_buffer = []
1302+ expo_url_found = None # Track if we've found an Expo URL
11611303
11621304 async def on_log_chunk (chunk : str ):
11631305 """Callback for real-time log chunks."""
1164- nonlocal server_ready , log_buffer
1306+ nonlocal server_ready , log_buffer , expo_url_found
11651307 log_buffer .append (chunk )
11661308
11671309 # Check if any ready pattern is in the logs
@@ -1171,6 +1313,13 @@ async def on_log_chunk(chunk: str):
11711313 server_ready = True
11721314 break
11731315
1316+ # For mobile apps, also try to extract the Expo URL from logs
1317+ if app_type == 'mobile' and expo_url_found is None :
1318+ extracted_url = extract_expo_url (combined_logs )
1319+ if extracted_url :
1320+ expo_url_found = extracted_url
1321+ logger .info (f"Extracted Expo URL from logs: { extracted_url } " )
1322+
11741323 # Start streaming logs asynchronously
11751324 try :
11761325 # Get the command ID - Command object has 'id' field, not 'cmd_id'
@@ -1205,6 +1354,11 @@ async def on_log_chunk(chunk: str):
12051354 except Exception :
12061355 pass
12071356
1357+ # For mobile apps, emit the Expo URL for QR code generation
1358+ if app_type == 'mobile' and expo_url_found :
1359+ yield f"data: { json .dumps ({'type' : 'expo_url' , 'url' : expo_url_found })} \n \n "
1360+ logger .info (f"Emitted Expo URL via SSE: { expo_url_found } " )
1361+
12081362 break
12091363
12101364 # Check if client disconnected
0 commit comments