@@ -36,6 +36,8 @@ public void Initialize(HubWindow hub)
3636 if ( _connectionManager != null )
3737 _connectionManager . StateChanged += OnManagerStateChanged ;
3838
39+ Unloaded += OnPageUnloaded ;
40+
3941 // Populate manual connection fields
4042 GatewayUrlTextBox . Text = settings . GatewayUrl ?? "" ;
4143 SshToggle . IsOn = settings . UseSshTunnel ;
@@ -58,6 +60,12 @@ public void Initialize(HubWindow hub)
5860
5961 private GatewayConnectionSnapshot ? _lastSnapshot ;
6062
63+ private void OnPageUnloaded ( object sender , RoutedEventArgs e )
64+ {
65+ if ( _connectionManager != null )
66+ _connectionManager . StateChanged -= OnManagerStateChanged ;
67+ }
68+
6169 private void OnManagerStateChanged ( object ? sender , GatewayConnectionSnapshot snapshot )
6270 {
6371 DispatcherQueue ? . TryEnqueue ( ( ) =>
@@ -217,7 +225,10 @@ private void UpdatePairingGuidance(GatewayConnectionSnapshot snapshot)
217225 }
218226 }
219227 }
220- catch { }
228+ catch ( Exception ex )
229+ {
230+ System . Diagnostics . Debug . WriteLine ( $ "[ConnectionPage] Failed to read device ID from identity file: { ex . Message } ") ;
231+ }
221232 }
222233
223234 if ( snapshot . OperatorState == RoleConnectionState . PairingRequired )
@@ -520,29 +531,54 @@ private async void OnDirectConnect(object sender, RoutedEventArgs e)
520531 }
521532
522533 url = GatewayUrlHelper . NormalizeForWebSocket ( url ) ;
534+
535+ // Validate SSH config upfront before mutating any state
536+ var useSsh = SshToggle . IsOn ;
537+ SshTunnelConfig ? sshConfig = null ;
538+ if ( useSsh )
539+ {
540+ var sshUser = SshUserBox . Text . Trim ( ) ;
541+ var sshHost = SshHostBox . Text . Trim ( ) ;
542+ if ( string . IsNullOrWhiteSpace ( sshUser ) || string . IsNullOrWhiteSpace ( sshHost ) )
543+ {
544+ DirectConnectResultText . Text = "SSH user and host are required" ;
545+ return ;
546+ }
547+ if ( ! int . TryParse ( SshRemotePortBox . Text , out var remotePort ) || remotePort is < 1 or > 65535 )
548+ {
549+ DirectConnectResultText . Text = "SSH remote port must be 1–65535" ;
550+ return ;
551+ }
552+ if ( ! int . TryParse ( SshLocalPortBox . Text , out var localPort ) || localPort is < 1 or > 65535 )
553+ {
554+ DirectConnectResultText . Text = "SSH local port must be 1–65535" ;
555+ return ;
556+ }
557+ sshConfig = new SshTunnelConfig ( sshUser , sshHost , remotePort , localPort ) ;
558+ }
559+
523560 DirectConnectResultText . Text = "Connecting…" ;
524561
562+ // Snapshot previous state for rollback
563+ var previousActiveId = _gatewayRegistry . ActiveGatewayId ;
564+ var previousSettings = _hub ? . Settings ;
565+ var prevGatewayUrl = previousSettings ? . GatewayUrl ;
566+ var prevUseSsh = previousSettings ? . UseSshTunnel ?? false ;
567+ var prevSshUser = previousSettings ? . SshTunnelUser ;
568+ var prevSshHost = previousSettings ? . SshTunnelHost ;
569+ var prevSshRemotePort = previousSettings ? . SshTunnelRemotePort ?? 0 ;
570+ var prevSshLocalPort = previousSettings ? . SshTunnelLocalPort ?? 0 ;
571+
572+ var existing = _gatewayRegistry . FindByUrl ( url ) ;
573+ var isNewRecord = existing == null ;
574+ var existingRecordSnapshot = existing ;
575+ var recordId = existing ? . Id ?? Guid . NewGuid ( ) . ToString ( ) ;
576+
525577 try
526578 {
527579 await _connectionManager . DisconnectAsync ( ) ;
528580
529- // Parse SSH config
530- var useSsh = SshToggle . IsOn ;
531- SshTunnelConfig ? sshConfig = null ;
532- if ( useSsh )
533- {
534- var sshUser = SshUserBox . Text . Trim ( ) ;
535- var sshHost = SshHostBox . Text . Trim ( ) ;
536- int . TryParse ( SshRemotePortBox . Text , out var remotePort ) ;
537- int . TryParse ( SshLocalPortBox . Text , out var localPort ) ;
538- if ( remotePort <= 0 ) remotePort = 18789 ;
539- if ( localPort <= 0 ) localPort = 18789 ;
540- sshConfig = new SshTunnelConfig ( sshUser , sshHost , remotePort , localPort ) ;
541- }
542-
543581 // Create/update gateway record with shared token + SSH config
544- var existing = _gatewayRegistry . FindByUrl ( url ) ;
545- var recordId = existing ? . Id ?? Guid . NewGuid ( ) . ToString ( ) ;
546582 var record = new GatewayRecord
547583 {
548584 Id = recordId ,
@@ -576,23 +612,25 @@ private async void OnDirectConnect(object sender, RoutedEventArgs e)
576612 writer . Flush ( ) ;
577613 System . IO . File . WriteAllBytes ( keyPath , ms . ToArray ( ) ) ;
578614 }
579- catch { }
615+ catch ( Exception ex )
616+ {
617+ System . Diagnostics . Debug . WriteLine ( $ "[ConnectionPage] Failed to clear device tokens: { ex . Message } ") ;
618+ }
580619 }
581620
582621 // Save settings (SSH config + gateway URL for legacy compat)
583- var settings = _hub ? . Settings ;
584- if ( settings != null )
622+ if ( previousSettings != null )
585623 {
586- settings . GatewayUrl = url ;
587- settings . UseSshTunnel = useSsh ;
624+ previousSettings . GatewayUrl = url ;
625+ previousSettings . UseSshTunnel = useSsh ;
588626 if ( useSsh && sshConfig != null )
589627 {
590- settings . SshTunnelUser = sshConfig . User ;
591- settings . SshTunnelHost = sshConfig . Host ;
592- settings . SshTunnelRemotePort = sshConfig . RemotePort ;
593- settings . SshTunnelLocalPort = sshConfig . LocalPort ;
628+ previousSettings . SshTunnelUser = sshConfig . User ;
629+ previousSettings . SshTunnelHost = sshConfig . Host ;
630+ previousSettings . SshTunnelRemotePort = sshConfig . RemotePort ;
631+ previousSettings . SshTunnelLocalPort = sshConfig . LocalPort ;
594632 }
595- settings . Save ( ) ;
633+ previousSettings . Save ( ) ;
596634 }
597635
598636 // Start SSH tunnel if configured
@@ -604,11 +642,82 @@ private async void OnDirectConnect(object sender, RoutedEventArgs e)
604642 }
605643
606644 await _connectionManager . ConnectAsync ( recordId ) ;
607- DirectConnectResultText . Text = $ "✓ Connected to { GatewayUrlHelper . SanitizeForDisplay ( url ) } ";
645+
646+ // Poll connection manager state — ConnectAsync fires connect asynchronously,
647+ // so we need to wait for a definitive result before reporting success/failure.
648+ bool connected = false ;
649+ bool failed = false ;
650+ for ( int attempt = 0 ; attempt < 15 ; attempt ++ )
651+ {
652+ await Task . Delay ( 1000 ) ;
653+ var snapshot = _connectionManager . CurrentSnapshot ;
654+ if ( snapshot . OverallState is Services . Connection . OverallConnectionState . Connected
655+ or Services . Connection . OverallConnectionState . Ready )
656+ {
657+ connected = true ;
658+ break ;
659+ }
660+ if ( snapshot . OverallState is Services . Connection . OverallConnectionState . Error )
661+ {
662+ failed = true ;
663+ break ;
664+ }
665+ if ( snapshot . OverallState is Services . Connection . OverallConnectionState . PairingRequired )
666+ {
667+ DirectConnectResultText . Text = $ "⏳ Pairing required — approve on gateway";
668+ return ; // don't rollback, pairing is in progress
669+ }
670+ }
671+
672+ if ( connected )
673+ {
674+ DirectConnectResultText . Text = $ "✓ Connected to { GatewayUrlHelper . SanitizeForDisplay ( url ) } ";
675+ return ;
676+ }
677+
678+ // Connection failed or timed out — rollback
679+ var reason = failed ? "Connection failed" : "Connection timed out" ;
680+ DirectConnectResultText . Text = $ "✗ { reason } ";
681+ RollbackDirectConnect ( previousActiveId , isNewRecord , recordId , existingRecordSnapshot ,
682+ previousSettings , prevGatewayUrl , prevUseSsh , prevSshUser , prevSshHost , prevSshRemotePort , prevSshLocalPort ) ;
608683 }
609684 catch ( Exception ex )
610685 {
611686 DirectConnectResultText . Text = $ "✗ { ex . Message } ";
687+ RollbackDirectConnect ( previousActiveId , isNewRecord , recordId , existingRecordSnapshot ,
688+ previousSettings , prevGatewayUrl , prevUseSsh , prevSshUser , prevSshHost , prevSshRemotePort , prevSshLocalPort ) ;
689+ }
690+ }
691+
692+ private void RollbackDirectConnect (
693+ string ? previousActiveId , bool isNewRecord , string recordId ,
694+ GatewayRecord ? existingRecordSnapshot , SettingsManager ? settings ,
695+ string ? prevGatewayUrl , bool prevUseSsh , string ? prevSshUser ,
696+ string ? prevSshHost , int prevSshRemotePort , int prevSshLocalPort )
697+ {
698+ if ( _gatewayRegistry == null ) return ;
699+
700+ // Restore or remove the gateway record
701+ if ( isNewRecord )
702+ _gatewayRegistry . Remove ( recordId ) ;
703+ else if ( existingRecordSnapshot != null )
704+ _gatewayRegistry . AddOrUpdate ( existingRecordSnapshot ) ;
705+
706+ // Restore active gateway
707+ if ( previousActiveId != null )
708+ _gatewayRegistry . SetActive ( previousActiveId ) ;
709+ _gatewayRegistry . Save ( ) ;
710+
711+ // Restore legacy settings
712+ if ( settings != null )
713+ {
714+ settings . GatewayUrl = prevGatewayUrl ;
715+ settings . UseSshTunnel = prevUseSsh ;
716+ settings . SshTunnelUser = prevSshUser ;
717+ settings . SshTunnelHost = prevSshHost ;
718+ settings . SshTunnelRemotePort = prevSshRemotePort ;
719+ settings . SshTunnelLocalPort = prevSshLocalPort ;
720+ settings . Save ( ) ;
612721 }
613722 }
614723
0 commit comments