@@ -17,7 +17,7 @@ import { show_modal_dialog } from "cockpit-components-dialog.jsx";
1717
1818const _ = cockpit . gettext ;
1919
20- function show_error_dialog ( title , message ) {
20+ export function show_error_dialog ( title , message ) {
2121 const props = {
2222 id : "error-popup" ,
2323 title,
@@ -332,7 +332,8 @@ export function NetworkManagerModel() {
332332
333333 if ( signal == "PropertiesChanged" ) {
334334 push_refresh ( ) ;
335- set_object_properties ( obj , remove_signatures ( args [ 0 ] ) ) ;
335+ const props = remove_signatures ( args [ 0 ] ) ;
336+ set_object_properties ( obj , props ) ;
336337 pop_refresh ( ) ;
337338 } else if ( type . signals && type . signals [ signal ] )
338339 type . signals [ signal ] ( obj , args ) ;
@@ -399,6 +400,21 @@ export function NetworkManagerModel() {
399400 Object . keys ( interfaces ) . forEach ( iface => {
400401 const props = interfaces [ iface ] ;
401402
403+ /* Capture connection failure reasons for devices
404+ NM transitions through PREPARE → CONFIG → NEED_AUTH → FAILED → DISCONNECTED very
405+ quickly, and React batches these updates, so the UI often misses the FAILED state.
406+ Store the failure reason here at the D-Bus event level so we can retrieve it later. */
407+ if ( path . includes ( "/Devices/" ) && props ?. StateReason ) {
408+ const obj = peek_object ( path ) ;
409+ if ( obj ) {
410+ const [ state , reason ] = props . StateReason ;
411+ if ( state === 120 && reason !== 0 ) {
412+ utils . debug ( "Captured" , obj . Interface , "failure, reason:" , reason ) ;
413+ priv ( obj ) . lastFailureReason = reason ;
414+ }
415+ }
416+ }
417+
402418 if ( props )
403419 interface_properties ( path , iface , props ) ;
404420 else
@@ -610,6 +626,13 @@ export function NetworkManagerModel() {
610626 } ;
611627 }
612628
629+ if ( settings [ "802-11-wireless" ] ) {
630+ result [ "802-11-wireless" ] = {
631+ ssid : get ( "802-11-wireless" , "ssid" ) ,
632+ mode : get ( "802-11-wireless" , "mode" ) ,
633+ } ;
634+ }
635+
613636 return result ;
614637 }
615638
@@ -784,6 +807,20 @@ export function NetworkManagerModel() {
784807 delete result . wireguard ;
785808 }
786809
810+ if ( settings [ "802-11-wireless" ] ) {
811+ set ( "802-11-wireless" , "ssid" , 'ay' , settings [ "802-11-wireless" ] . ssid ) ;
812+ set ( "802-11-wireless" , "mode" , 's' , settings [ "802-11-wireless" ] . mode ) ;
813+ } else {
814+ delete result [ "802-11-wireless" ] ;
815+ }
816+
817+ if ( settings [ "802-11-wireless-security" ] ) {
818+ set ( "802-11-wireless-security" , "key-mgmt" , 's' , settings [ "802-11-wireless-security" ] [ "key-mgmt" ] ) ;
819+ set ( "802-11-wireless-security" , "psk" , 's' , settings [ "802-11-wireless-security" ] . psk ) ;
820+ } else {
821+ delete result [ "802-11-wireless-security" ] ;
822+ }
823+
787824 return result ;
788825 }
789826
@@ -860,6 +897,21 @@ export function NetworkManagerModel() {
860897 }
861898 }
862899
900+ function access_point_mode_to_text ( mode ) {
901+ switch ( mode ) {
902+ // NM_802_11_MODE_ADHOC
903+ case 1 : return _ ( "Adhoc" ) ;
904+ // NM_802_11_MODE_INFRA
905+ case 2 : return _ ( "Infra" ) ;
906+ // NM_802_11_MODE_AP
907+ case 3 : return _ ( "AP" ) ;
908+ // NM_802_11_MODE_MESH
909+ case 4 : return _ ( "Mesh" ) ;
910+ // subsumes NM_802_11_MODE_UNKNOWN
911+ default : return _ ( "Unknown" ) ;
912+ }
913+ }
914+
863915 const connections_by_uuid = { } ;
864916
865917 function set_settings ( obj , settings ) {
@@ -942,6 +994,37 @@ export function NetworkManagerModel() {
942994 }
943995 } ;
944996
997+ const type_AccessPoint = {
998+ interfaces : [
999+ "org.freedesktop.NetworkManager.AccessPoint"
1000+ ] ,
1001+
1002+ props : {
1003+ Flags : { def : 0 } ,
1004+ WpaFlags : { def : 0 } ,
1005+ RsnFlags : { def : 0 } ,
1006+ Ssid : { conv : utils . ssid_from_nm , def : "" } ,
1007+ Frequency : { def : 0 } , // MHz
1008+ HwAddress : { def : "" } ,
1009+ Mode : { conv : access_point_mode_to_text , def : "" } ,
1010+ MaxBitrate : { def : 0 } , // Kbit/s
1011+ Bandwidth : { def : 0 } , // MHz
1012+ Strength : { def : 0 } ,
1013+ LastSeen : { def : - 1 } , // CLOCK_BOOTTIME seconds, -1 if never seen
1014+ } ,
1015+
1016+ exporters : [
1017+ function ( obj ) {
1018+ // Find connection for this SSID (undefined if none exists)
1019+ obj . Connection = ( self . get_settings ( ) ?. Connections || [ ] ) . find ( con => {
1020+ if ( con . Settings ?. [ "802-11-wireless" ] ?. ssid )
1021+ return utils . ssid_from_nm ( con . Settings [ "802-11-wireless" ] . ssid ) == obj . Ssid ;
1022+ return false ;
1023+ } ) ;
1024+ }
1025+ ]
1026+ } ;
1027+
9451028 const type_Connection = {
9461029 interfaces : [
9471030 "org.freedesktop.NetworkManager.Settings.Connection"
@@ -1052,7 +1135,8 @@ export function NetworkManagerModel() {
10521135 props : {
10531136 Connection : { conv : conv_Object ( type_Connection ) } ,
10541137 Ip4Config : { conv : conv_Object ( type_Ipv4Config ) } ,
1055- Ip6Config : { conv : conv_Object ( type_Ipv6Config ) }
1138+ Ip6Config : { conv : conv_Object ( type_Ipv6Config ) } ,
1139+ State : { def : 0 }
10561140 // See below for "Group"
10571141 } ,
10581142
@@ -1073,14 +1157,16 @@ export function NetworkManagerModel() {
10731157 "org.freedesktop.NetworkManager.Device.Bond" ,
10741158 "org.freedesktop.NetworkManager.Device.Team" ,
10751159 "org.freedesktop.NetworkManager.Device.Bridge" ,
1076- "org.freedesktop.NetworkManager.Device.Vlan"
1160+ "org.freedesktop.NetworkManager.Device.Vlan" ,
1161+ "org.freedesktop.NetworkManager.Device.Wireless"
10771162 ] ,
10781163
10791164 props : {
10801165 DeviceType : { conv : device_type_to_symbol } ,
10811166 Interface : { } ,
10821167 StateText : { prop : "State" , conv : device_state_to_text , def : _ ( "Unknown" ) } ,
10831168 State : { } ,
1169+ StateReason : { def : [ 0 , 0 ] } , // [state, reason] tuple
10841170 HwAddress : { } ,
10851171 AvailableConnections : { conv : conv_Array ( conv_Object ( type_Connection ) ) , def : [ ] } ,
10861172 ActiveConnection : { conv : conv_Object ( type_ActiveConnection ) } ,
@@ -1093,23 +1179,31 @@ export function NetworkManagerModel() {
10931179 Carrier : { def : true } ,
10941180 Speed : { } ,
10951181 Managed : { def : false } ,
1182+ // WiFi-specific properties
1183+ AccessPoints : { conv : conv_Array ( conv_Object ( type_AccessPoint ) ) , def : [ ] } ,
1184+ ActiveAccessPoint : { conv : conv_Object ( type_AccessPoint ) } ,
10961185 // See below for "Members"
10971186 } ,
10981187
10991188 prototype : {
11001189 activate : function ( connection , specific_object ) {
1190+ priv ( this ) . lastFailureReason = undefined ; // Clear stale failure reason from previous attempts
11011191 return call_object_method ( get_object ( "/org/freedesktop/NetworkManager" , type_Manager ) ,
11021192 "org.freedesktop.NetworkManager" , "ActivateConnection" ,
11031193 objpath ( connection ) , objpath ( this ) , objpath ( specific_object ) )
11041194 . then ( ( [ active_connection ] ) => active_connection ) ;
11051195 } ,
11061196
11071197 activate_with_settings : function ( settings , specific_object ) {
1198+ priv ( this ) . lastFailureReason = undefined ; // Clear stale failure reason from previous attempts
11081199 try {
11091200 return call_object_method ( get_object ( "/org/freedesktop/NetworkManager" , type_Manager ) ,
11101201 "org.freedesktop.NetworkManager" , "AddAndActivateConnection" ,
11111202 settings_to_nm ( settings ) , objpath ( this ) , objpath ( specific_object ) )
1112- . then ( ( [ path , active_connection ] ) => active_connection ) ;
1203+ . then ( ( [ path , active_connection_path ] ) => ( {
1204+ connection : get_object ( path , type_Connection ) ,
1205+ active_connection : get_object ( active_connection_path , type_ActiveConnection )
1206+ } ) ) ;
11131207 } catch ( e ) {
11141208 return Promise . reject ( e ) ;
11151209 }
@@ -1118,8 +1212,136 @@ export function NetworkManagerModel() {
11181212 disconnect : function ( ) {
11191213 return call_object_method ( this , 'org.freedesktop.NetworkManager.Device' , 'Disconnect' )
11201214 . then ( ( ) => undefined ) ;
1215+ } ,
1216+
1217+ // Request a WiFi scan to populate this.AccessPoints
1218+ request_scan : function ( ) {
1219+ utils . debug ( "request_scan: requesting scan for" , this . Interface ) ;
1220+ call_object_method ( this , 'org.freedesktop.NetworkManager.Device.Wireless' , 'RequestScan' , { } )
1221+ . catch ( error => {
1222+ // RequestScan can fail if a scan was recently done, that's OK
1223+ console . warn ( "request_scan: scan failed for" , this . Interface + ":" , error . toString ( ) ) ;
1224+ } ) ;
1225+ } ,
1226+
1227+ // Get and clear the last connection failure reason
1228+ consume_failure_reason : function ( ) {
1229+ const reason = priv ( this ) . lastFailureReason ;
1230+ priv ( this ) . lastFailureReason = undefined ;
1231+ return reason ;
1232+ } ,
1233+
1234+ // Mark that a pending connection is being cancelled by the user
1235+ cancel_pending_connection : function ( ) {
1236+ priv ( this ) . connectionCancelled = true ;
1237+ } ,
1238+
1239+ // Wait for a connection to complete
1240+ // For WiFi, pass expected_ssid to verify we connected to the right network
1241+ // Returns a Promise that resolves on success or cancel, rejects with {reason} on failure
1242+ wait_connection : function ( expected_ssid ) {
1243+ priv ( this ) . connectionCancelled = false ;
1244+ utils . debug ( "wait_connection: starting, iface:" , this . Interface , "expected:" , expected_ssid , "initial state:" , this . State ) ;
1245+ return new Promise ( ( resolve , reject ) => {
1246+ let activationStarted = false ;
1247+
1248+ const cleanup = ( ) => self . removeEventListener ( "changed" , check ) ;
1249+
1250+ const check = ( ) => {
1251+ utils . debug ( "wait_connection check: state:" , this . State , "ssid:" , this . ActiveAccessPoint ?. Ssid ,
1252+ "activeConn:" , ! ! this . ActiveConnection , "activationStarted:" , activationStarted ,
1253+ "lastFailureReason:" , priv ( this ) . lastFailureReason ,
1254+ "connectionCancelled:" , priv ( this ) . connectionCancelled ) ;
1255+
1256+ // captured a failure?
1257+ const reason = this . consume_failure_reason ( ) ;
1258+ if ( reason ) {
1259+ cleanup ( ) ;
1260+ console . warn ( "wait_connection: connection failed for" , this . Interface , "reason:" , reason ) ;
1261+ const error = new Error ( "Connection failed" ) ;
1262+ error . reason = reason ;
1263+ reject ( error ) ;
1264+ return ;
1265+ }
1266+
1267+ // https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceState
1268+ switch ( this . State ) {
1269+ case 100 : // NM_DEVICE_STATE_ACTIVATED
1270+ if ( ! expected_ssid || this . ActiveAccessPoint ?. Ssid === expected_ssid ) {
1271+ utils . debug ( "wait_connection: success" ) ;
1272+ cleanup ( ) ;
1273+ resolve ( ) ;
1274+ }
1275+ break ;
1276+
1277+ case 30 : // NM_DEVICE_STATE_DISCONNECTED; initial state, so wait for activation to start
1278+ case 120 : // NM_DEVICE_STATE_FAILED
1279+ // Disconnected/failed after activation started means user cancelled or failure without reason
1280+ if ( activationStarted && ! this . ActiveConnection ) {
1281+ cleanup ( ) ;
1282+ if ( priv ( this ) . connectionCancelled ) {
1283+ utils . debug ( "wait_connection: cancelled by user" ) ;
1284+ resolve ( ) ;
1285+ } else {
1286+ console . warn ( "wait_connection: connection failed for" , this . Interface , "without captured reason" ) ;
1287+ reject ( new Error ( "Connection failed" ) ) ;
1288+ }
1289+ }
1290+ break ;
1291+
1292+ case 20 : // NM_DEVICE_STATE_UNAVAILABLE
1293+ case 110 : // NM_DEVICE_STATE_DEACTIVATING
1294+ break ;
1295+
1296+ // any other state means we're activating
1297+ default :
1298+ if ( ! activationStarted ) {
1299+ utils . debug ( "wait_connection: activation started" ) ;
1300+ activationStarted = true ;
1301+ }
1302+ }
1303+ } ;
1304+
1305+ self . addEventListener ( "changed" , check ) ;
1306+ check ( ) ; // Check current state immediately in case already connected
1307+ } ) ;
11211308 }
1122- }
1309+ } ,
1310+
1311+ exporters : [
1312+ function ( obj ) {
1313+ if ( obj . DeviceType === '802-11-wireless' ) {
1314+ // When a hidden network (no SSID broadcast) has a saved connection, NetworkManager
1315+ // duplicates it in AccessPoints: once without SSID (from the beacon), and once with
1316+ // SSID (synthesized from the saved connection). Both have the same HwAddress (MAC).
1317+ // We deduplicate by MAC first, preferring the one with a known connection
1318+ const apByMac = new Map ( ) ;
1319+ ( obj . AccessPoints || [ ] ) . forEach ( ap => {
1320+ utils . debug ( `AP: Ssid '${ ap . Ssid } ' HwAddress: ${ ap . HwAddress } Strength: ${ ap . Strength } hasConnection: ${ ! ! ap . Connection } ` ) ;
1321+ if ( ! apByMac . get ( ap . HwAddress ) || ap . Connection )
1322+ apByMac . set ( ap . HwAddress , ap ) ;
1323+ } ) ;
1324+
1325+ // Deduplicate visible APs by SSID, keeping the strongest signal for each network.
1326+ // Count remaining hidden APs (those without SSID after MAC deduplication).
1327+ const apBySsid = new Map ( ) ;
1328+ let hiddenCount = 0 ;
1329+ Array . from ( apByMac . values ( ) ) . forEach ( ap => {
1330+ if ( ap . Ssid ) {
1331+ const existing = apBySsid . get ( ap . Ssid ) ;
1332+ if ( ! existing || ap . Strength > existing . Strength ) {
1333+ apBySsid . set ( ap . Ssid , ap ) ;
1334+ }
1335+ } else {
1336+ hiddenCount ++ ;
1337+ }
1338+ } ) ;
1339+ obj . visibleSsids = Array . from ( apBySsid . values ( ) ) ;
1340+ obj . hiddenAPCount = hiddenCount ;
1341+ utils . debug ( "Device exporter:" , obj . Interface , "has" , obj . visibleSsids . length , "visible SSIDs and" , obj . hiddenAPCount , "hidden APs" ) ;
1342+ }
1343+ }
1344+ ]
11231345 } ;
11241346
11251347 // The 'Interface' type does not correspond to any NetworkManager
@@ -1362,7 +1584,8 @@ export function NetworkManagerModel() {
13621584 type_Ipv4Config ,
13631585 type_Ipv6Config ,
13641586 type_Connection ,
1365- type_ActiveConnection
1587+ type_ActiveConnection ,
1588+ type_AccessPoint
13661589 ] ) ;
13671590
13681591 get_object ( "/org/freedesktop/NetworkManager" , type_Manager ) ;
0 commit comments