From 96c8bb5db1a2e0ced999a366e3cf28f9895ec39f Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Tue, 24 Mar 2026 21:51:35 +0100
Subject: [PATCH] feat: Bonjour auto-discovery + VPN IP field in connection flow

---
 lib/screens/settings_screen.dart |   20 ++++++
 lib/models/server_config.dart    |    6 ++
 ios/Runner/Info.plist            |    3 
 lib/services/mqtt_service.dart   |   95 ++++++++++++++++++++++++-------
 pubspec.lock                     |   48 ++++++++++++++++
 pubspec.yaml                     |    1 
 6 files changed, 150 insertions(+), 23 deletions(-)

diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 59f2f28..8103c41 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -67,10 +67,11 @@
 		<true/>
 	</dict>
 	<key>NSLocalNetworkUsageDescription</key>
-	<string>PAILot connects to your local AI server</string>
+	<string>PAILot needs local network access to discover and connect to AIBroker</string>
 	<key>NSBonjourServices</key>
 	<array>
 		<string>_http._tcp</string>
+		<string>_mqtt._tcp</string>
 	</array>
 	<key>UISupportedInterfaceOrientations</key>
 	<array>
diff --git a/lib/models/server_config.dart b/lib/models/server_config.dart
index 14fa6df..59023cd 100644
--- a/lib/models/server_config.dart
+++ b/lib/models/server_config.dart
@@ -2,6 +2,7 @@
   final String host;
   final int port;
   final String? localHost;
+  final String? vpnHost;
   final String? macAddress;
   final String? mqttToken;
 
@@ -9,6 +10,7 @@
     required this.host,
     this.port = 8765,
     this.localHost,
+    this.vpnHost,
     this.macAddress,
     this.mqttToken,
   });
@@ -18,6 +20,7 @@
       'host': host,
       'port': port,
       if (localHost != null) 'localHost': localHost,
+      if (vpnHost != null) 'vpnHost': vpnHost,
       if (macAddress != null) 'macAddress': macAddress,
       if (mqttToken != null) 'mqttToken': mqttToken,
     };
@@ -28,6 +31,7 @@
       host: json['host'] as String? ?? '',
       port: json['port'] as int? ?? 8765,
       localHost: json['localHost'] as String?,
+      vpnHost: json['vpnHost'] as String?,
       macAddress: json['macAddress'] as String?,
       mqttToken: json['mqttToken'] as String?,
     );
@@ -37,6 +41,7 @@
     String? host,
     int? port,
     String? localHost,
+    String? vpnHost,
     String? macAddress,
     String? mqttToken,
   }) {
@@ -44,6 +49,7 @@
       host: host ?? this.host,
       port: port ?? this.port,
       localHost: localHost ?? this.localHost,
+      vpnHost: vpnHost ?? this.vpnHost,
       macAddress: macAddress ?? this.macAddress,
       mqttToken: mqttToken ?? this.mqttToken,
     );
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 0cfb0fc..2a41067 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -18,6 +18,7 @@
 class _SettingsScreenState extends ConsumerState<SettingsScreen> {
   final _formKey = GlobalKey<FormState>();
   late final TextEditingController _localHostController;
+  late final TextEditingController _vpnHostController;
   late final TextEditingController _remoteHostController;
   late final TextEditingController _portController;
   late final TextEditingController _macController;
@@ -30,6 +31,8 @@
     final config = ref.read(serverConfigProvider);
     _localHostController =
         TextEditingController(text: config?.localHost ?? '');
+    _vpnHostController =
+        TextEditingController(text: config?.vpnHost ?? '');
     _remoteHostController =
         TextEditingController(text: config?.host ?? '');
     _portController =
@@ -43,6 +46,7 @@
   @override
   void dispose() {
     _localHostController.dispose();
+    _vpnHostController.dispose();
     _remoteHostController.dispose();
     _portController.dispose();
     _macController.dispose();
@@ -59,6 +63,9 @@
       localHost: _localHostController.text.trim().isEmpty
           ? null
           : _localHostController.text.trim(),
+      vpnHost: _vpnHostController.text.trim().isEmpty
+          ? null
+          : _vpnHostController.text.trim(),
       macAddress: _macController.text.trim().isEmpty
           ? null
           : _macController.text.trim(),
@@ -153,6 +160,19 @@
               ),
               const SizedBox(height: 16),
 
+              // VPN address
+              Text('VPN Address',
+                  style: Theme.of(context).textTheme.bodyMedium),
+              const SizedBox(height: 4),
+              TextFormField(
+                controller: _vpnHostController,
+                decoration: const InputDecoration(
+                  hintText: '10.8.0.1 (OpenVPN static IP)',
+                ),
+                keyboardType: TextInputType.url,
+              ),
+              const SizedBox(height: 16),
+
               // Remote address
               Text('Remote Address',
                   style: Theme.of(context).textTheme.bodyMedium),
diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart
index 87eafc9..200ba3c 100644
--- a/lib/services/mqtt_service.dart
+++ b/lib/services/mqtt_service.dart
@@ -2,6 +2,7 @@
 import 'dart:convert';
 import 'dart:io';
 
+import 'package:bonsoir/bonsoir.dart';
 import 'package:flutter/widgets.dart';
 import 'package:path_provider/path_provider.dart' as pp;
 import 'package:mqtt_client/mqtt_client.dart';
@@ -102,29 +103,50 @@
     }
 
     final clientId = await _getClientId();
-    final hosts = _getHosts();
-    _mqttLog('MQTT: hosts=${hosts.join(", ")} port=${config.port}');
 
-    for (final host in hosts) {
+    // Connection order: local → Bonjour → VPN → remote
+    final attempts = <MapEntry<String, int>>[];  // host → timeout ms
+    if (config.localHost != null && config.localHost!.isNotEmpty) {
+      attempts.add(MapEntry(config.localHost!, 2500));
+    }
+    // Bonjour placeholder — inserted dynamically below
+    if (config.vpnHost != null && config.vpnHost!.isNotEmpty) {
+      attempts.add(MapEntry(config.vpnHost!, 3000));
+    }
+    if (config.host.isNotEmpty) {
+      attempts.add(MapEntry(config.host, 5000));
+    }
+    _mqttLog('MQTT: attempts=${attempts.map((e) => e.key).join(", ")} port=${config.port}');
+
+    // Try configured local host first
+    for (final attempt in attempts) {
       if (_intentionalClose) return;
-
-      _mqttLog('MQTT: trying $host:${config.port}');
+      _mqttLog('MQTT: trying ${attempt.key}:${config.port}');
       try {
-        final connected = await _tryConnect(
-          host,
-          clientId,
-          timeout: host == hosts.first && hosts.length > 1 ? 2500 : 5000,
-        );
-        _mqttLog('MQTT: $host result=$connected');
-        if (connected) return;
+        if (await _tryConnect(attempt.key, clientId, timeout: attempt.value)) return;
       } catch (e) {
-        _mqttLog('MQTT: $host error=$e');
-        continue;
+        _mqttLog('MQTT: ${attempt.key} error=$e');
+      }
+
+      // After local host fails, try Bonjour discovery before VPN/remote
+      if (attempt.key == config.localHost && !_intentionalClose) {
+        _mqttLog('MQTT: trying Bonjour discovery...');
+        final bonjourHost = await _discoverViaMdns();
+        if (bonjourHost != null && !_intentionalClose) {
+          _mqttLog('MQTT: Bonjour found $bonjourHost');
+          try {
+            if (await _tryConnect(bonjourHost, clientId, timeout: 3000)) return;
+          } catch (e) {
+            _mqttLog('MQTT: Bonjour host $bonjourHost error=$e');
+          }
+        } else {
+          _mqttLog('MQTT: Bonjour discovery returned nothing');
+        }
       }
     }
 
     // All hosts failed — retry after delay
-    _mqttLog('MQTT: all hosts failed, retrying in 5s');
+    _mqttLog('MQTT: all attempts failed, retrying in 5s');
     _setStatus(ConnectionStatus.reconnecting);
     Future.delayed(const Duration(seconds: 5), () {
       if (!_intentionalClose && _status != ConnectionStatus.connected) {
@@ -133,14 +155,43 @@
     });
   }
 
-  /// Returns [localHost, remoteHost] for dual-connect attempts.
-  List<String> _getHosts() {
-    if (config.localHost != null &&
-        config.localHost!.isNotEmpty &&
-        config.localHost != config.host) {
-      return [config.localHost!, config.host];
+  /// Discover AIBroker on local network via Bonjour/mDNS.
+  /// Returns the IP address or null if not found within timeout.
+  Future<String?> _discoverViaMdns({Duration timeout = const Duration(seconds: 3)}) async {
+    try {
+      final discovery = BonsoirDiscovery(type: '_mqtt._tcp');
+      await discovery.initialize();
+
+      final completer = Completer<String?>();
+      StreamSubscription? sub;
+
+      sub = discovery.eventStream?.listen((event) {
+        switch (event) {
+          case BonsoirDiscoveryServiceResolvedEvent():
+            final ip = event.service.host;
+            _mqttLog('MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}');
+            if (ip != null && ip.isNotEmpty && !completer.isCompleted) {
+              completer.complete(ip);
+            }
+          case BonsoirDiscoveryServiceFoundEvent():
+            _mqttLog('MQTT: Bonjour found: ${event.service.name}');
+          default:
+            break;
+        }
+      });
+
+      await discovery.start();
+
+      final ip = await completer.future.timeout(timeout, onTimeout: () => null);
+
+      await sub?.cancel();
+      await discovery.stop();
+
+      return ip;
+    } catch (e) {
+      _mqttLog('MQTT: Bonjour discovery error: $e');
+      return null;
     }
-    return [config.host];
   }
 
   Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async {
diff --git a/pubspec.lock b/pubspec.lock
index ec7686d..9b819d8 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -73,6 +73,54 @@
       url: "https://pub.dev"
     source: hosted
     version: "4.3.0"
+  bonsoir:
+    dependency: "direct main"
+    description:
+      name: bonsoir
+      sha256: "42f2c1eb55e833bcb541dfcb759851da0a703106646a0cf15a16c6de21f4a5a4"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.2"
+  bonsoir_android:
+    dependency: transitive
+    description:
+      name: bonsoir_android
+      sha256: e19728f94a0d9813abf9e2edf644fede008e58ef539865a1be86ac5d8994154e
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.1"
+  bonsoir_darwin:
+    dependency: transitive
+    description:
+      name: bonsoir_darwin
+      sha256: e242a03a019fd474be657715826cfc13e43d02c88e46ec5611a20b9d4f72854d
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.1"
+  bonsoir_linux:
+    dependency: transitive
+    description:
+      name: bonsoir_linux
+      sha256: "9c326c572c241c6a38ab7a8a5dba27c82917ec12504f84308ce3b5706619e8d3"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.2"
+  bonsoir_platform_interface:
+    dependency: transitive
+    description:
+      name: bonsoir_platform_interface
+      sha256: "3fa0c46b30eb2a2f48be6fa53591a5c0425bf00520be761b61763e58b51814ff"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.1"
+  bonsoir_windows:
+    dependency: transitive
+    description:
+      name: bonsoir_windows
+      sha256: "34c54802baaa2f00e3c4ab7ea46888f2a829876753778e2f40e3f273c3382d34"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.1"
   boolean_selector:
     dependency: transitive
     description:
diff --git a/pubspec.yaml b/pubspec.yaml
index f4da55f..c54e2d0 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -31,6 +31,7 @@
   collection: ^1.19.1
   file_picker: ^10.3.10
   flutter_markdown: ^0.7.7+1
+  bonsoir: ^6.0.2
 
 dev_dependencies:
   flutter_test:

--
Gitblit v1.3.1