1- import React , { useEffect , useState , useRef } from "react" ;
1+ import React , { useEffect , useState , useRef , useCallback } from "react" ;
22import { ExclamationTriangleIcon } from "@heroicons/react/24/solid" ;
33import { ArrowPathIcon , ArrowRightIcon } from "@heroicons/react/16/solid" ;
44import { motion , AnimatePresence } from "framer-motion" ;
@@ -13,7 +13,8 @@ import { useRTCStore, PostRebootAction } from "@/hooks/stores";
1313import LogoBlue from "@/assets/logo-blue.svg" ;
1414import LogoWhite from "@/assets/logo-white.svg" ;
1515import { isOnDevice } from "@/main" ;
16- import { sleep } from "@/utils" ;
16+ import { sleep , buildCloudUrl } from "@/utils" ;
17+ import { getLocalVersion } from "@/utils/jsonrpc" ;
1718
1819
1920interface OverlayContentProps {
@@ -398,124 +399,133 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
398399interface RebootingOverlayProps {
399400 readonly show : boolean ;
400401 readonly postRebootAction : PostRebootAction ;
402+ readonly deviceId ?: string ; // Required for cloud mode to build versioned URLs
401403}
402404
403- export function RebootingOverlay ( { show, postRebootAction } : RebootingOverlayProps ) {
405+ export function RebootingOverlay ( { show, postRebootAction, deviceId } : RebootingOverlayProps ) {
404406 const { peerConnectionState } = useRTCStore ( ) ;
405-
406- // Check if we've already seen the connection drop (confirms reboot actually started)
407407 const [ hasSeenDisconnect , setHasSeenDisconnect ] = useState (
408- [ ' disconnected' , ' closed' , ' failed' ] . includes ( peerConnectionState ?? '' )
408+ [ " disconnected" , " closed" , " failed" ] . includes ( peerConnectionState ?? "" ) ,
409409 ) ;
410-
411- // Track if we've timed out
412410 const [ hasTimedOut , setHasTimedOut ] = useState ( false ) ;
411+ const isCheckingRef = useRef ( false ) ;
412+ const abortControllerRef = useRef < AbortController | null > ( null ) ;
413413
414- // Monitor for disconnect after reboot is initiated
414+ // Detect connection drop (confirms reboot started)
415415 useEffect ( ( ) => {
416- if ( ! show ) return ;
417- if ( hasSeenDisconnect ) return ;
418-
419- if ( [ 'disconnected' , 'closed' , 'failed' ] . includes ( peerConnectionState ?? '' ) ) {
420- console . log ( 'hasSeenDisconnect' , hasSeenDisconnect ) ;
416+ if ( ! show || hasSeenDisconnect ) return ;
417+ if ( [ "disconnected" , "closed" , "failed" ] . includes ( peerConnectionState ?? "" ) ) {
421418 setHasSeenDisconnect ( true ) ;
422419 }
423420 } , [ show , peerConnectionState , hasSeenDisconnect ] ) ;
424421
425- // Set timeout after 30 seconds
422+ // Timeout after 30 seconds
426423 useEffect ( ( ) => {
427424 if ( ! show ) {
428425 setHasTimedOut ( false ) ;
429426 return ;
430427 }
431-
432- const timeoutId = setTimeout ( ( ) => {
433- setHasTimedOut ( true ) ;
434- } , 30 * 1000 ) ;
435-
436- return ( ) => {
437- clearTimeout ( timeoutId ) ;
438- } ;
428+ const id = setTimeout ( ( ) => setHasTimedOut ( true ) , 30_000 ) ;
429+ return ( ) => clearTimeout ( id ) ;
439430 } , [ show ] ) ;
440431
432+ // Redirect helper - navigates and forces reload
433+ const redirectTo = useCallback ( async ( url : string ) => {
434+ console . log ( "Redirecting to" , url ) ;
435+ window . location . href = url ;
436+ await sleep ( 1000 ) ;
437+ window . location . reload ( ) ;
438+ } , [ ] ) ;
441439
442- // Poll suggested IP in device mode to detect when it's available
443- const abortControllerRef = useRef < AbortController | null > ( null ) ;
444- const isFetchingRef = useRef ( false ) ;
445-
440+ // Local mode: poll HTTP health endpoint
446441 useEffect ( ( ) => {
447- // Only run in device mode with a postRebootAction
448- if ( ! isOnDevice || ! postRebootAction || ! show || ! hasSeenDisconnect ) {
449- return ;
450- }
442+ if ( ! isOnDevice || ! postRebootAction || ! show || ! hasSeenDisconnect ) return ;
451443
452- const checkPostRebootHealth = async ( ) => {
453- // Don't start a new fetch if one is already in progress
454- if ( isFetchingRef . current ) {
455- return ;
456- }
457-
458- // Cancel any pending fetch
459- if ( abortControllerRef . current ) {
460- abortControllerRef . current . abort ( ) ;
461- }
444+ const checkHealth = async ( ) => {
445+ if ( isCheckingRef . current ) return ;
462446
463- // Create new abort controller for this fetch
464- const abortController = new AbortController ( ) ;
465- abortControllerRef . current = abortController ;
466- isFetchingRef . current = true ;
447+ abortControllerRef . current ?. abort ( ) ;
448+ const controller = new AbortController ( ) ;
449+ abortControllerRef . current = controller ;
450+ isCheckingRef . current = true ;
467451
468- console . log ( 'Checking post-reboot health endpoint:' , postRebootAction . healthCheck ) ;
469- const timeoutId = window . setTimeout ( ( ) => abortController . abort ( ) , 2000 ) ;
452+ const timeout = setTimeout ( ( ) => controller . abort ( ) , 2000 ) ;
470453 try {
471- const response = await fetch (
472- postRebootAction . healthCheck ,
473- { signal : abortController . signal , }
474- ) ;
475-
476- if ( response . ok ) {
477- // Device is available, redirect to the specified URL
478- console . log ( 'Device is available, redirecting to:' , postRebootAction . redirectTo ) ;
479-
480- // URL constructor handles all cases elegantly:
481- // - Absolute paths: resolved against current origin
482- // - Protocol-relative URLs: resolved with current protocol
483- // - Fully qualified URLs: used as-is
484- const targetUrl = new URL ( postRebootAction . redirectTo , window . location . origin ) ;
485- clearInterval ( intervalId ) ; // Stop polling before redirect
486-
487- window . location . href = targetUrl . href ;
488- // Add 1s delay between setting location.href and calling reload() to prevent reload from interrupting the navigation.
489- await sleep ( 1000 ) ;
490- window . location . reload ( ) ;
454+ // URL constructor handles relative paths, protocol-relative URLs, and absolute URLs
455+ // Relative path → resolves against origin
456+ // new URL("/device/status", "http://192.168.1.77").href
457+ // // → "http://192.168.1.77/device/status"
458+
459+ // // Protocol-relative URL → uses protocol from base, host from URL
460+ // new URL("//192.168.1.100/device/status", "http://192.168.1.77").href
461+ // // → "http://192.168.1.100/device/status"
462+
463+ // // Fully qualified URL → base is ignored entirely
464+ // new URL("http://192.168.1.100/device/status", "http://192.168.1.77").href
465+ // // → "http://192.168.1.100/device/status"
466+ const healthUrl = new URL ( postRebootAction . healthCheck , window . location . origin ) . href ;
467+ const res = await fetch ( healthUrl , { signal : controller . signal } ) ;
468+ if ( res . ok ) {
469+ clearInterval ( intervalId ) ;
470+ const targetUrl = new URL ( postRebootAction . redirectTo , window . location . origin ) . href ;
471+ await redirectTo ( targetUrl ) ;
491472 }
492473 } catch ( err ) {
493- // Ignore errors - they're expected while device is rebooting
494- // Only log if it's not an abort error
495- if ( err instanceof Error && err . name !== 'AbortError' ) {
496- console . debug ( 'Error checking post-reboot health:' , err ) ;
474+ if ( err instanceof Error && err . name !== "AbortError" ) {
475+ console . debug ( "Health check failed:" , err . message ) ;
497476 }
498477 } finally {
499- clearTimeout ( timeoutId ) ;
500- isFetchingRef . current = false ;
478+ clearTimeout ( timeout ) ;
479+ isCheckingRef . current = false ;
501480 }
502481 } ;
503482
504- // Start interval (check every 2 seconds)
505- const intervalId = setInterval ( checkPostRebootHealth , 2000 ) ;
506-
507- // Also check immediately
508- checkPostRebootHealth ( ) ;
483+ const intervalId = setInterval ( checkHealth , 2000 ) ;
484+ checkHealth ( ) ;
509485
510- // Cleanup on unmount or when dependencies change
511486 return ( ) => {
512487 clearInterval ( intervalId ) ;
513- if ( abortControllerRef . current ) {
514- abortControllerRef . current . abort ( ) ;
488+ abortControllerRef . current ?. abort ( ) ;
489+ isCheckingRef . current = false ;
490+ } ;
491+ } , [ show , postRebootAction , hasSeenDisconnect , redirectTo ] ) ;
492+
493+ // Cloud mode: wait for WebRTC reconnection via RPC, then redirect with versioned URL
494+ useEffect ( ( ) => {
495+ if ( isOnDevice ) return ;
496+ if ( ! postRebootAction || ! deviceId || ! show || ! hasSeenDisconnect ) return ;
497+
498+ let cancelled = false ;
499+
500+ const waitForReconnectAndRedirect = async ( ) => {
501+ if ( isCheckingRef . current ) return ;
502+ isCheckingRef . current = true ;
503+
504+ try {
505+ const { appVersion } = await getLocalVersion ( {
506+ attemptTimeoutMs : 2000 ,
507+ } ) ;
508+
509+ if ( cancelled ) return ;
510+
511+ clearInterval ( intervalId ) ;
512+ const targetUrl = buildCloudUrl ( deviceId , appVersion , postRebootAction . redirectTo ) ;
513+ await redirectTo ( targetUrl ) ;
514+ } catch ( err ) {
515+ console . debug ( "Cloud reconnect check failed:" , err ) ;
516+ isCheckingRef . current = false ;
515517 }
516- isFetchingRef . current = false ;
517518 } ;
518- } , [ show , postRebootAction , hasTimedOut , hasSeenDisconnect ] ) ;
519+
520+ const intervalId = setInterval ( waitForReconnectAndRedirect , 3000 ) ;
521+ waitForReconnectAndRedirect ( ) ;
522+
523+ return ( ) => {
524+ cancelled = true ;
525+ clearInterval ( intervalId ) ;
526+ isCheckingRef . current = false ;
527+ } ;
528+ } , [ show , postRebootAction , deviceId , hasSeenDisconnect , redirectTo ] ) ;
519529
520530 return (
521531 < AnimatePresence >
0 commit comments