| 2026-03-25 | Matthias Nott | ![]() |
| 2026-03-25 | Matthias Nott | ![]() |
| 2026-03-25 | Matthias Nott | ![]() |
| 2026-03-25 | Matthias Nott | ![]() |
| 2026-03-25 | Matthias Nott | ![]() |
| 2026-03-25 | Matthias Nott | ![]() |
| 2026-03-25 | Matthias Nott | ![]() |
| 2026-03-25 | Matthias Nott | ![]() |
| 2026-03-25 | Matthias Nott | ![]() |
| 2026-03-25 | Matthias Nott | ![]() |
| 2026-03-25 | Matthias Nott | ![]() |
TODO-appstore.md
.. .. @@ -0,0 +1,54 @@ 1 +# PAILot App Store Readiness Checklist2 +3 +Date: 2026-03-254 +Source: Security & Code Quality Review5 +6 +## CRITICAL (Must fix before submission)7 +8 +- [x] **C1: Remove NSAllowsArbitraryLoads** — ATS bypass, Apple will reject. Use NSAllowsLocalNetworking only *(fixed 2026-03-25)*9 +- [x] **C2: Add TLS to MQTT** — All conversations and auth token travel in plaintext. Set `client.secure = true`, configure TLS on AIBroker broker *(fixed 2026-03-25 — self-signed cert auto-generated at ~/.aibroker/tls/, onBadCertificate accepts it; TODO: pin cert fingerprint)*10 +- [x] **C3: Remove debug log files in production** — `mqtt_debug.log` and `_chatLog` write truncated message content to Documents. Wrap in `kDebugMode` or remove entirely *(fixed 2026-03-25)*11 +12 +## HIGH (Should fix before submission)13 +14 +- [x] **H1: Unbounded image cache** — `_imageCache` in message_bubble.dart grows without limit. Add LRU eviction (cap at 50) *(fixed 2026-03-25)*15 +- [x] **H2: Audio temp files never cleaned** — `_base64ToFile` creates .m4a files never deleted. Clean up after playback completes *(fixed 2026-03-25)*16 +- [x] **H3: TextEditingController leak** — Rename dialog in session_drawer.dart creates controller but never disposes it *(fixed 2026-03-25)*17 +- [x] **H4: Input validation on settings** — No validation on host IPs, port range, MAC format. Add regex validators *(fixed 2026-03-25)*18 +- [x] **H5: LifecycleObserver never removed** — AudioService.init() adds observer but dispose() doesn't remove it *(fixed 2026-03-25)*19 +- [ ] **H6: MQTT token in memory** — Acceptable for personal use, document as known limitation20 +21 +## MEDIUM (Improve before submission)22 +23 +- [x] **M1: Subnet scan hammers 254 hosts** — Batched in groups of 20 with early exit *(fixed 2026-03-25)*24 +- [x] **M2: No loadMore debounce** — Added isLoadingMore guard *(fixed 2026-03-25)*25 +- [ ] **M3: NavigateNotifier global singleton** — Mutable static, stale reference risk. Move to Riverpod provider26 +- [ ] **M4: Unbounded _seenSeqs set** — O(n log n) eviction. Use FIFO deque instead27 +- [ ] **M5: Screenshots bloat iCloud backup** — Store images as files, not base64 in JSON. Or exclude from backup28 +- [x] **M6: Unused import** — Already removed *(fixed 2026-03-25)*29 +- [x] **M7: Silent error swallowing** — Added debugPrint on config load failure *(fixed 2026-03-25)*30 +31 +## LOW (Nice-to-haves)32 +33 +- [ ] **L1: PrivacyInfo.xcprivacy** — Required since 2024 for UserDefaults and FileTimestamp APIs34 +- [ ] **L2: Privacy policy URL** — Required for microphone/camera access apps35 +- [x] **L3: Unused dependencies** — Removed web_socket_channel and wakelock_plus *(fixed 2026-03-25)*36 +- [x] **L4: Unnecessary _http._tcp** — Removed from NSBonjourServices *(fixed 2026-03-25)*37 +- [x] **L5: Typing indicator timeout** — Auto-clear after 10s *(fixed 2026-03-25)*38 +- [ ] **L6: Version number** — Default 1.0.0+1, set correctly before submission39 +- [ ] **L7: App icon** — Verify meets Apple guidelines (no alpha channel, correct sizes)40 +41 +## App Store Requirements42 +43 +| Requirement | Status | Action |44 +|------------|--------|--------|45 +| NSMicrophoneUsageDescription | PASS | - |46 +| NSCameraUsageDescription | PASS | - |47 +| NSPhotoLibraryUsageDescription | PASS | - |48 +| NSLocalNetworkUsageDescription | PASS | - |49 +| NSBonjourServices | PASS | Fixed - removed _http._tcp |50 +| NSAppTransportSecurity | PASS | Fixed - removed NSAllowsArbitraryLoads |51 +| UIBackgroundModes: audio | PASS | - |52 +| Privacy Policy | FAIL | Fix L2 |53 +| PrivacyInfo.xcprivacy | FAIL | Fix L1 |54 +| TLS for network | PASS | Fixed C2 - self-signed cert, onBadCertificate=true |ios/Podfile.lock
.. .. @@ -2,6 +2,9 @@ 2 2 - audioplayers_darwin (0.0.1): 3 3 - Flutter 4 4 - FlutterMacOS 5 + - bonsoir_darwin (0.0.1):6 + - Flutter7 + - FlutterMacOS5 8 - device_info_plus (0.0.1): 6 9 - Flutter 7 10 - DKImagePickerController/Core (4.3.9): .. .. @@ -43,8 +46,6 @@ 43 46 - Flutter 44 47 - image_picker_ios (0.0.1): 45 48 - Flutter 46 - - package_info_plus (0.4.5):47 - - Flutter48 49 - permission_handler_apple (9.3.0): 49 50 - Flutter 50 51 - record_ios (1.2.0): .. .. @@ -60,23 +61,20 @@ 60 61 - SwiftyGif (5.4.5) 61 62 - vibration (1.7.5): 62 63 - Flutter 63 - - wakelock_plus (0.0.1):64 - - Flutter65 64 66 65 DEPENDENCIES: 67 66 - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) 67 + - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)68 68 - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) 69 69 - file_picker (from `.symlinks/plugins/file_picker/ios`) 70 70 - Flutter (from `Flutter`) 71 71 - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) 72 72 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) 73 - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)74 73 - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) 75 74 - record_ios (from `.symlinks/plugins/record_ios/ios`) 76 75 - share_plus (from `.symlinks/plugins/share_plus/ios`) 77 76 - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) 78 77 - vibration (from `.symlinks/plugins/vibration/ios`) 79 - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)80 78 81 79 SPEC REPOS: 82 80 trunk: .. .. @@ -88,6 +86,8 @@ 88 86 EXTERNAL SOURCES: 89 87 audioplayers_darwin: 90 88 :path: ".symlinks/plugins/audioplayers_darwin/darwin" 89 + bonsoir_darwin:90 + :path: ".symlinks/plugins/bonsoir_darwin/darwin"91 91 device_info_plus: 92 92 :path: ".symlinks/plugins/device_info_plus/ios" 93 93 file_picker: .. .. @@ -98,8 +98,6 @@ 98 98 :path: ".symlinks/plugins/flutter_secure_storage/ios" 99 99 image_picker_ios: 100 100 :path: ".symlinks/plugins/image_picker_ios/ios" 101 - package_info_plus:102 - :path: ".symlinks/plugins/package_info_plus/ios"103 101 permission_handler_apple: 104 102 :path: ".symlinks/plugins/permission_handler_apple/ios" 105 103 record_ios: .. .. @@ -110,11 +108,10 @@ 110 108 :path: ".symlinks/plugins/shared_preferences_foundation/darwin" 111 109 vibration: 112 110 :path: ".symlinks/plugins/vibration/ios" 113 - wakelock_plus:114 - :path: ".symlinks/plugins/wakelock_plus/ios"115 111 116 112 SPEC CHECKSUMS: 117 113 audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 114 + bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e118 115 device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe 119 116 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c 120 117 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 .. .. @@ -122,7 +119,6 @@ 122 119 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 123 120 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 124 121 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 125 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499126 122 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d 127 123 record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 128 124 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf .. .. @@ -130,7 +126,6 @@ 130 126 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb 131 127 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 132 128 vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888 133 - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556134 129 135 130 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e 136 131 ios/Runner/Info.plist
.. .. @@ -61,8 +61,6 @@ 61 61 <string>PAILot needs camera access to take photos</string> 62 62 <key>NSAppTransportSecurity</key> 63 63 <dict> 64 - <key>NSAllowsArbitraryLoads</key>65 - <true/>66 64 <key>NSAllowsLocalNetworking</key> 67 65 <true/> 68 66 </dict> .. .. @@ -70,7 +68,6 @@ 70 68 <string>PAILot needs local network access to discover and connect to AIBroker</string> 71 69 <key>NSBonjourServices</key> 72 70 <array> 73 - <string>_http._tcp</string>74 71 <string>_mqtt._tcp</string> 75 72 </array> 76 73 <key>UISupportedInterfaceOrientations</key> lib/providers/providers.dart
.. .. @@ -39,7 +39,9 @@ 39 39 if (json != null) { 40 40 state = ServerConfig.fromJson(jsonDecode(json) as Map<String, dynamic>); 41 41 } 42 - } catch (_) {}42 + } catch (e) {43 + debugPrint('ServerConfig load failed: $e');44 + }43 45 } 44 46 45 47 Future<void> save(ServerConfig config) async { .. .. @@ -58,6 +60,8 @@ 58 60 final wsStatusProvider = 59 61 StateProvider<ConnectionStatus>((ref) => ConnectionStatus.disconnected); 60 62 63 +final connectionDetailProvider = StateProvider<String>((ref) => '');64 +61 65 // --- Sessions --- 62 66 63 67 final sessionsProvider = StateProvider<List<Session>>((ref) => []); lib/screens/chat_screen.dart
.. .. @@ -1,6 +1,8 @@ 1 +import 'dart:async';1 2 import 'dart:convert'; 2 3 import 'dart:io'; 3 4 5 +import 'package:flutter/foundation.dart';4 6 import 'package:path_provider/path_provider.dart'; 5 7 6 8 import 'package:flutter/material.dart'; .. .. @@ -36,6 +38,8 @@ 36 38 } 37 39 38 40 Future<void> _chatLog(String msg) async { 41 + debugPrint('[Chat] $msg');42 + if (!kDebugMode) return;39 43 try { 40 44 final dir = await getApplicationDocumentsDirectory(); 41 45 final file = File('${dir.path}/mqtt_debug.log'); .. .. @@ -60,6 +64,7 @@ 60 64 final List<Map<String, dynamic>> _pendingMessages = []; 61 65 final Map<String, List<Message>> _catchUpPending = {}; 62 66 List<String>? _cachedSessionOrder; 67 + Timer? _typingTimer;63 68 64 69 @override 65 70 void initState() { .. .. @@ -129,10 +134,13 @@ 129 134 } 130 135 } 131 136 137 + bool _isLoadingMore = false;132 138 void _onScroll() { 133 - if (_scrollController.position.pixels >=134 - _scrollController.position.maxScrollExtent - 100) {135 - ref.read(messagesProvider.notifier).loadMore();139 + if (!_isLoadingMore &&140 + _scrollController.position.pixels >=141 + _scrollController.position.maxScrollExtent - 100) {142 + _isLoadingMore = true;143 + ref.read(messagesProvider.notifier).loadMore().then((_) => _isLoadingMore = false);136 144 } 137 145 } 138 146 .. .. @@ -160,6 +168,14 @@ 160 168 _ws!.onStatusChanged = (status) { 161 169 if (mounted) { 162 170 ref.read(wsStatusProvider.notifier).state = status; 171 + if (status == ConnectionStatus.connected) {172 + ref.read(connectionDetailProvider.notifier).state = '';173 + }174 + }175 + };176 + _ws!.onStatusDetail = (detail) {177 + if (mounted) {178 + ref.read(connectionDetailProvider.notifier).state = detail;163 179 } 164 180 }; 165 181 _ws!.onMessage = _handleMessage; .. .. @@ -236,6 +252,15 @@ 236 252 // Strict: only show typing for the ACTIVE session, ignore all others 237 253 if (activeId != null && typingSession == activeId) { 238 254 ref.read(isTypingProvider.notifier).state = typing; 255 + // Auto-clear after 10s in case typing_end is missed256 + if (typing) {257 + _typingTimer?.cancel();258 + _typingTimer = Timer(const Duration(seconds: 10), () {259 + if (mounted) ref.read(isTypingProvider.notifier).state = false;260 + });261 + } else {262 + _typingTimer?.cancel();263 + }239 264 } 240 265 case 'typing_end': 241 266 final endSession = msg['sessionId'] as String?; .. .. @@ -1301,6 +1326,7 @@ 1301 1326 final messages = ref.watch(messagesProvider); 1302 1327 final wsStatus = ref.watch(wsStatusProvider); 1303 1328 final isTyping = ref.watch(isTypingProvider); 1329 + final connectionDetail = ref.watch(connectionDetailProvider);1304 1330 final sessions = ref.watch(sessionsProvider); 1305 1331 final activeSession = ref.watch(activeSessionProvider); 1306 1332 final unreadCounts = ref.watch(unreadCountsProvider); .. .. @@ -1319,9 +1345,20 @@ 1319 1345 _scaffoldKey.currentState?.openDrawer(); 1320 1346 }, 1321 1347 ), 1322 - title: Text(1323 - activeSession?.name ?? 'PAILot',1324 - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),1348 + title: Column(1349 + crossAxisAlignment: CrossAxisAlignment.center,1350 + mainAxisSize: MainAxisSize.min,1351 + children: [1352 + Text(1353 + activeSession?.name ?? 'PAILot',1354 + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),1355 + ),1356 + if (connectionDetail.isNotEmpty && wsStatus != ConnectionStatus.connected)1357 + Text(1358 + connectionDetail,1359 + style: TextStyle(fontSize: 11, color: Colors.grey.shade400),1360 + ),1361 + ],1325 1362 ), 1326 1363 actions: [ 1327 1364 StatusDot(status: wsStatus), lib/screens/settings_screen.dart
.. .. @@ -1,5 +1,6 @@ 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 +import 'package:shared_preferences/shared_preferences.dart';3 4 4 5 import '../models/server_config.dart'; 5 6 import '../providers/providers.dart'; .. .. @@ -157,6 +158,11 @@ 157 158 hintText: '192.168.1.100', 158 159 ), 159 160 keyboardType: TextInputType.url, 161 + validator: (v) {162 + if (v == null || v.trim().isEmpty) return null;163 + final ip = RegExp(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$');164 + return ip.hasMatch(v.trim()) ? null : 'Enter a valid IP address';165 + },160 166 ), 161 167 const SizedBox(height: 16), 162 168 .. .. @@ -170,6 +176,11 @@ 170 176 hintText: '10.8.0.1 (OpenVPN static IP)', 171 177 ), 172 178 keyboardType: TextInputType.url, 179 + validator: (v) {180 + if (v == null || v.trim().isEmpty) return null;181 + final ip = RegExp(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$');182 + return ip.hasMatch(v.trim()) ? null : 'Enter a valid IP address';183 + },173 184 ), 174 185 const SizedBox(height: 16), 175 186 .. .. @@ -197,6 +208,14 @@ 197 208 hintText: '8765', 198 209 ), 199 210 keyboardType: TextInputType.number, 211 + validator: (v) {212 + if (v == null || v.trim().isEmpty) return 'Required';213 + final port = int.tryParse(v.trim());214 + if (port == null || port < 1 || port > 65535) {215 + return 'Port must be 1–65535';216 + }217 + return null;218 + },200 219 ), 201 220 const SizedBox(height: 16), 202 221 .. .. @@ -209,6 +228,14 @@ 209 228 decoration: const InputDecoration( 210 229 hintText: 'AA:BB:CC:DD:EE:FF', 211 230 ), 231 + validator: (v) {232 + if (v == null || v.trim().isEmpty) return null;233 + final mac = RegExp(234 + r'^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$');235 + return mac.hasMatch(v.trim())236 + ? null237 + : 'Enter a valid MAC address (AA:BB:CC:DD:EE:FF)';238 + },212 239 ), 213 240 const SizedBox(height: 16), 214 241 .. .. @@ -249,6 +276,44 @@ 249 276 label: const Text('Wake Mac'), 250 277 ), 251 278 const SizedBox(height: 12), 279 +280 + // Reset TLS Trust button281 + OutlinedButton.icon(282 + onPressed: () async {283 + final confirmed = await showDialog<bool>(284 + context: context,285 + builder: (ctx) => AlertDialog(286 + title: const Text('Reset Server Trust?'),287 + content: const Text(288 + 'This clears the saved server certificate fingerprint. '289 + 'Use this if you reinstalled AIBroker or changed servers. '290 + 'The app will trust the next server it connects to.',291 + ),292 + actions: [293 + TextButton(294 + onPressed: () => Navigator.pop(ctx, false),295 + child: const Text('Cancel'),296 + ),297 + TextButton(298 + onPressed: () => Navigator.pop(ctx, true),299 + child: const Text('Reset', style: TextStyle(color: AppColors.error)),300 + ),301 + ],302 + ),303 + );304 + if (confirmed == true && mounted) {305 + // Access MqttService through the provider and reset trust306 + final prefs = await SharedPreferences.getInstance();307 + await prefs.remove('trustedCertFingerprint');308 + ScaffoldMessenger.of(context).showSnackBar(309 + const SnackBar(content: Text('Server trust reset. Reconnect to trust the new server.')),310 + );311 + }312 + },313 + icon: const Icon(Icons.shield_outlined),314 + label: const Text('Reset Server Trust'),315 + ),316 + const SizedBox(height: 12),252 317 ], 253 318 ), 254 319 ), lib/services/audio_service.dart
.. .. @@ -28,9 +28,16 @@ 28 28 // Autoplay suppression 29 29 static bool _isBackgrounded = false; 30 30 31 + // Track last played temp file so it can be cleaned up when the track ends32 + static String? _lastPlaybackTempPath;33 +34 + // Lifecycle observer stored so we can remove it in dispose()35 + static _LifecycleObserver? _lifecycleObserver;36 +31 37 /// Initialize the audio service and set up lifecycle observer. 32 38 static void init() { 33 - WidgetsBinding.instance.addObserver(_LifecycleObserver());39 + _lifecycleObserver = _LifecycleObserver();40 + WidgetsBinding.instance.addObserver(_lifecycleObserver!);34 41 35 42 // Configure audio session for background playback 36 43 _player.setAudioContext(AudioContext( .. .. @@ -52,6 +59,13 @@ 52 59 } 53 60 54 61 static void _onTrackComplete() { 62 + // Clean up the temp file that just finished playing63 + final prev = _lastPlaybackTempPath;64 + _lastPlaybackTempPath = null;65 + if (prev != null) {66 + File(prev).delete().ignore();67 + }68 +55 69 if (_queue.isNotEmpty) { 56 70 _playNextInQueue(); 57 71 } else { .. .. @@ -68,6 +82,7 @@ 68 82 } 69 83 70 84 final path = _queue.removeAt(0); 85 + _lastPlaybackTempPath = path;71 86 try { 72 87 // Brief pause between tracks — iOS audio player needs time to reset 73 88 await _player.stop(); .. .. @@ -143,10 +158,12 @@ 143 158 144 159 if (source.startsWith('/')) { 145 160 await _player.play(DeviceFileSource(source)); 161 + // File path owned by caller — not tracked for deletion146 162 } else { 147 163 // base64 data — write to temp file first 148 164 final path = await _base64ToFile(source); 149 165 if (path == null) return; 166 + _lastPlaybackTempPath = path;150 167 await _player.play(DeviceFileSource(path)); 151 168 } 152 169 _isPlaying = true; .. .. @@ -159,6 +176,7 @@ 159 176 final path = await _base64ToFile(base64Audio); 160 177 if (path == null) return; 161 178 179 + _lastPlaybackTempPath = path;162 180 await _player.play(DeviceFileSource(path)); 163 181 _isPlaying = true; 164 182 onPlaybackStateChanged?.call(); .. .. @@ -177,6 +195,7 @@ 177 195 debugPrint('AudioService: queued (queue size: ${_queue.length})'); 178 196 } else { 179 197 // Nothing playing — start immediately 198 + _lastPlaybackTempPath = path;180 199 try { 181 200 await _player.play(DeviceFileSource(path)); 182 201 _isPlaying = true; .. .. @@ -250,6 +269,10 @@ 250 269 } 251 270 252 271 static Future<void> dispose() async { 272 + if (_lifecycleObserver != null) {273 + WidgetsBinding.instance.removeObserver(_lifecycleObserver!);274 + _lifecycleObserver = null;275 + }253 276 await cancelRecording(); 254 277 await stopPlayback(); 255 278 _recorder.dispose(); lib/services/mqtt_service.dart
.. .. @@ -1,8 +1,11 @@ 1 1 import 'dart:async'; 2 2 import 'dart:convert'; 3 3 import 'dart:io'; 4 +import 'dart:typed_data';5 +import 'package:crypto/crypto.dart';4 6 5 7 import 'package:bonsoir/bonsoir.dart'; 8 +import 'package:flutter/foundation.dart';6 9 import 'package:flutter/widgets.dart'; 7 10 import 'package:path_provider/path_provider.dart' as pp; 8 11 import 'package:mqtt_client/mqtt_client.dart'; .. .. @@ -21,8 +24,10 @@ 21 24 reconnecting, 22 25 } 23 26 24 -// Debug log to file (survives release builds)27 +// Debug log — writes to file only in debug builds, always prints via debugPrint25 28 Future<void> _mqttLog(String msg) async { 29 + debugPrint('[MQTT] $msg');30 + if (!kDebugMode) return;26 31 try { 27 32 final dir = await pp.getApplicationDocumentsDirectory(); 28 33 final file = File('${dir.path}/mqtt_debug.log'); .. .. @@ -44,6 +49,7 @@ 44 49 ConnectionStatus _status = ConnectionStatus.disconnected; 45 50 bool _intentionalClose = false; 46 51 String? _clientId; 52 + String? _lastDiscoveredHost;47 53 StreamSubscription? _updatesSub; 48 54 49 55 // Message deduplication .. .. @@ -53,6 +59,7 @@ 53 59 54 60 // Callbacks 55 61 void Function(ConnectionStatus status)? onStatusChanged; 62 + void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..."56 63 void Function(Map<String, dynamic> message)? onMessage; 57 64 void Function()? onOpen; 58 65 void Function()? onClose; .. .. @@ -95,6 +102,9 @@ 95 102 _intentionalClose = false; 96 103 _setStatus(ConnectionStatus.connecting); 97 104 105 + // Load trusted cert fingerprint for TOFU verification106 + if (_trustedFingerprint == null) await _loadTrustedFingerprint();107 +98 108 // Send Wake-on-LAN if MAC configured 99 109 if (config.macAddress != null && config.macAddress!.isNotEmpty) { 100 110 try { .. .. @@ -104,49 +114,51 @@ 104 114 105 115 final clientId = await _getClientId(); 106 116 107 - // Connection order: local → Bonjour → VPN → remote108 - final attempts = <MapEntry<String, int>>[]; // host → timeout ms109 - if (config.localHost != null && config.localHost!.isNotEmpty) {110 - attempts.add(MapEntry(config.localHost!, 2500));111 - }112 - // Bonjour placeholder — inserted dynamically below113 - if (config.vpnHost != null && config.vpnHost!.isNotEmpty) {114 - attempts.add(MapEntry(config.vpnHost!, 3000));115 - }116 - if (config.host.isNotEmpty) {117 - attempts.add(MapEntry(config.host, 5000));118 - }119 - _mqttLog('MQTT: attempts=${attempts.map((e) => e.key).join(", ")} port=${config.port}');117 + // Probe all hosts in parallel to find which one responds, then connect to the winner118 + final hosts = <String>{};119 + if (config.localHost != null && config.localHost!.isNotEmpty) hosts.add(config.localHost!);120 + if (_lastDiscoveredHost != null) hosts.add(_lastDiscoveredHost!);121 + if (config.vpnHost != null && config.vpnHost!.isNotEmpty) hosts.add(config.vpnHost!);122 + if (config.host.isNotEmpty) hosts.add(config.host);123 + _mqttLog('MQTT: probing ${hosts.length} hosts in parallel: ${hosts.join(", ")}');124 + onStatusDetail?.call('Probing ${hosts.length} hosts...');120 125 121 - // Try configured local host first122 - for (final attempt in attempts) {123 - if (_intentionalClose) return;124 - _mqttLog('MQTT: trying ${attempt.key}:${config.port}');125 - try {126 - if (await _tryConnect(attempt.key, clientId, timeout: attempt.value)) return;127 - } catch (e) {128 - _mqttLog('MQTT: ${attempt.key} error=$e');129 - }130 -131 - // After local host fails, try Bonjour discovery before VPN/remote132 - if (attempt.key == config.localHost && !_intentionalClose) {133 - _mqttLog('MQTT: trying Bonjour discovery...');134 - final bonjourHost = await _discoverViaMdns();135 - if (bonjourHost != null && !_intentionalClose) {136 - _mqttLog('MQTT: Bonjour found $bonjourHost');137 - try {138 - if (await _tryConnect(bonjourHost, clientId, timeout: 3000)) return;139 - } catch (e) {140 - _mqttLog('MQTT: Bonjour host $bonjourHost error=$e');126 + // Probe all configured hosts in parallel — first to respond wins127 + String? winner;128 + if (hosts.isNotEmpty) {129 + final probes = hosts.map((h) => _probeHost(h, config.port)).toList();130 + // Also start discovery in parallel131 + if (_lastDiscoveredHost == null) {132 + probes.add(() async {133 + final discovered = await _discoverViaMdns();134 + if (discovered != null) {135 + _lastDiscoveredHost = discovered;136 + return discovered;141 137 } 142 - } else {143 - _mqttLog('MQTT: Bonjour discovery returned nothing');144 - }138 + return null;139 + }());140 + }141 + final results = await Future.wait(probes);142 + winner = results.firstWhere((r) => r != null, orElse: () => null);143 + } else if (_lastDiscoveredHost == null) {144 + // No configured hosts — try discovery only145 + winner = await _discoverViaMdns();146 + if (winner != null) _lastDiscoveredHost = winner;147 + }148 +149 + if (winner != null && !_intentionalClose) {150 + _mqttLog('MQTT: probe winner: $winner, connecting...');151 + onStatusDetail?.call('Connecting to $winner...');152 + try {153 + if (await _tryConnect(winner, clientId, timeout: 5000)) return;154 + } catch (e) {155 + _mqttLog('MQTT: connect to $winner failed: $e');145 156 } 146 157 } 147 158 148 159 // All hosts failed — retry after delay 149 160 _mqttLog('MQTT: all attempts failed, retrying in 5s'); 161 + onStatusDetail?.call('No server found, retrying...');150 162 _setStatus(ConnectionStatus.reconnecting); 151 163 Future.delayed(const Duration(seconds: 5), () { 152 164 if (!_intentionalClose && _status != ConnectionStatus.connected) { .. .. @@ -156,8 +168,10 @@ 156 168 } 157 169 158 170 /// Discover AIBroker on local network via Bonjour/mDNS. 171 + /// Falls back to subnet scan if Bonjour fails (iOS blocks mDNS on Personal Hotspot).159 172 /// Returns the IP address or null if not found within timeout. 160 173 Future<String?> _discoverViaMdns({Duration timeout = const Duration(seconds: 3)}) async { 174 + // Try Bonjour first161 175 try { 162 176 final discovery = BonsoirDiscovery(type: '_mqtt._tcp'); 163 177 await discovery.initialize(); .. .. @@ -187,9 +201,125 @@ 187 201 await sub?.cancel(); 188 202 await discovery.stop(); 189 203 190 - return ip;204 + if (ip != null) return ip;191 205 } catch (e) { 192 206 _mqttLog('MQTT: Bonjour discovery error: $e'); 207 + }208 +209 + // Fallback: scan local subnet for MQTT port (handles Personal Hotspot)210 + _mqttLog('MQTT: Bonjour failed, trying subnet scan...');211 + onStatusDetail?.call('Scanning local network...');212 + return _scanSubnetForMqtt();213 + }214 +215 + /// Scan the local subnet for an MQTT broker by probing the configured port.216 + /// Useful when iOS Personal Hotspot blocks mDNS.217 + Future<String?> _scanSubnetForMqtt() async {218 + try {219 + // Get device's own IP to determine the subnet220 + final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);221 + for (final iface in interfaces) {222 + for (final addr in iface.addresses) {223 + final parts = addr.address.split('.');224 + if (parts.length != 4) continue;225 + // Skip loopback226 + if (parts[0] == '127') continue;227 + // Only scan small subnets (hotspot = /28, max 14 hosts)228 + final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';229 + _mqttLog('MQTT: scanning $subnet.0/24 on ${iface.name}');230 +231 + // Probe in batches of 20 to avoid flooding the network.232 + // Early exit on first hit.233 + for (int batch = 1; batch <= 254; batch += 20) {234 + final end = (batch + 19).clamp(1, 254);235 + final futures = <Future<String?>>[];236 + for (int i = batch; i <= end; i++) {237 + final probe = '$subnet.$i';238 + if (probe == addr.address) continue;239 + futures.add(_probeHost(probe, config.port));240 + }241 + final results = await Future.wait(futures);242 + final found = results.firstWhere((r) => r != null, orElse: () => null);243 + if (found != null) {244 + _mqttLog('MQTT: subnet scan found broker at $found');245 + return found;246 + }247 + }248 + }249 + }250 + } catch (e) {251 + _mqttLog('MQTT: subnet scan error: $e');252 + }253 + return null;254 + }255 +256 + // --- TOFU (Trust On First Use) certificate pinning ---257 +258 + String? _trustedFingerprint; // Loaded from SharedPreferences at startup259 +260 + /// Load the trusted cert fingerprint from storage.261 + Future<void> _loadTrustedFingerprint() async {262 + final prefs = await SharedPreferences.getInstance();263 + _trustedFingerprint = prefs.getString('trustedCertFingerprint');264 + if (_trustedFingerprint != null) {265 + _mqttLog('TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...');266 + }267 + }268 +269 + /// Compute SHA-256 fingerprint of a certificate's DER bytes.270 + String _certFingerprint(X509Certificate cert) {271 + final der = cert.der;272 + final digest = sha256.convert(der);273 + return digest.toString();274 + }275 +276 + /// TOFU verification: accept on first use, reject if fingerprint changes.277 + bool _verifyCertTofu(dynamic certificate) {278 + if (certificate is! X509Certificate) return true; // Can't verify, accept279 +280 + final fingerprint = _certFingerprint(certificate);281 +282 + if (_trustedFingerprint == null) {283 + // First connection — trust and save284 + _trustedFingerprint = fingerprint;285 + SharedPreferences.getInstance().then((prefs) {286 + prefs.setString('trustedCertFingerprint', fingerprint);287 + });288 + _mqttLog('TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...');289 + return true;290 + }291 +292 + if (_trustedFingerprint == fingerprint) {293 + return true; // Known cert, trusted294 + }295 +296 + // Fingerprint mismatch — possible MITM or server reinstall297 + _mqttLog('TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...');298 + // Reject the connection. User must reset trust in settings.299 + return false;300 + }301 +302 + /// Reset the trusted cert fingerprint (e.g., after server reinstall).303 + Future<void> resetTrustedCert() async {304 + _trustedFingerprint = null;305 + final prefs = await SharedPreferences.getInstance();306 + await prefs.remove('trustedCertFingerprint');307 + _mqttLog('TOFU: trust reset');308 + }309 +310 + /// Probe a single host:port with a TLS connection attempt (1s timeout).311 + /// Uses SecureSocket since the broker now requires TLS.312 + Future<String?> _probeHost(String host, int port) async {313 + try {314 + final socket = await SecureSocket.connect(315 + host,316 + port,317 + timeout: const Duration(seconds: 1),318 + onBadCertificate: (_) => true, // Accept self-signed cert during scan319 + );320 + await socket.close();321 + return host;322 + } catch (_) {193 323 return null; 194 324 } 195 325 } .. .. @@ -203,6 +333,15 @@ 203 333 // client.maxConnectionAttempts is final — can't set it 204 334 client.logging(on: false); 205 335 336 + // TLS with TOFU (Trust On First Use) cert pinning.337 + // First connection: accept cert, save its SHA-256 fingerprint.338 + // Future connections: only accept certs matching the saved fingerprint.339 + client.secure = true;340 + client.securityContext = SecurityContext(withTrustedRoots: true);341 + client.onBadCertificate = (dynamic certificate) {342 + return _verifyCertTofu(certificate);343 + };344 +206 345 client.onConnected = _onConnected; 207 346 client.onDisconnected = _onDisconnected; 208 347 client.onAutoReconnect = _onAutoReconnect; lib/widgets/message_bubble.dart
.. .. @@ -270,13 +270,17 @@ 270 270 return const Text('Image unavailable'); 271 271 } 272 272 273 - // Cache decoded bytes to prevent flicker on rebuild274 - final bytes = _imageCache.putIfAbsent(message.id, () {273 + // Cache decoded bytes to prevent flicker on rebuild; evict oldest if over 50 entries274 + if (!_imageCache.containsKey(message.id)) {275 + if (_imageCache.length >= 50) {276 + _imageCache.remove(_imageCache.keys.first);277 + }275 278 final raw = message.imageBase64!; 276 - return Uint8List.fromList(base64Decode(279 + _imageCache[message.id] = Uint8List.fromList(base64Decode(277 280 raw.contains(',') ? raw.split(',').last : raw, 278 281 )); 279 - });282 + }283 + final bytes = _imageCache[message.id]!;280 284 281 285 return Column( 282 286 crossAxisAlignment: CrossAxisAlignment.start, lib/widgets/session_drawer.dart
.. .. @@ -233,7 +233,6 @@ 233 233 ), 234 234 ], 235 235 ), 236 - );237 - // Dispose controller when dialog is dismissed236 + ).then((_) => controller.dispose());238 237 } 239 238 } macos/Flutter/GeneratedPluginRegistrant.swift
.. .. @@ -6,25 +6,23 @@ 6 6 import Foundation 7 7 8 8 import audioplayers_darwin 9 +import bonsoir_darwin9 10 import device_info_plus 10 11 import file_picker 11 12 import file_selector_macos 12 13 import flutter_secure_storage_macos 13 -import package_info_plus14 14 import record_macos 15 15 import share_plus 16 16 import shared_preferences_foundation 17 -import wakelock_plus18 17 19 18 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 20 19 AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) 20 + SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))21 21 DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) 22 22 FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) 23 23 FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) 24 24 FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) 25 - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))26 25 RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) 27 26 SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) 28 27 SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) 29 - WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))30 28 } pubspec.lock
.. .. @@ -170,7 +170,7 @@ 170 170 source: hosted 171 171 version: "0.3.5+2" 172 172 crypto: 173 - dependency: transitive173 + dependency: "direct main"174 174 description: 175 175 name: crypto 176 176 sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf .. .. @@ -608,22 +608,6 @@ 608 608 url: "https://pub.dev" 609 609 source: hosted 610 610 version: "9.3.0" 611 - package_info_plus:612 - dependency: transitive613 - description:614 - name: package_info_plus615 - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d616 - url: "https://pub.dev"617 - source: hosted618 - version: "9.0.0"619 - package_info_plus_platform_interface:620 - dependency: transitive621 - description:622 - name: package_info_plus_platform_interface623 - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"624 - url: "https://pub.dev"625 - source: hosted626 - version: "3.2.1"627 611 path: 628 612 dependency: transitive 629 613 description: .. .. @@ -1069,22 +1053,6 @@ 1069 1053 url: "https://pub.dev" 1070 1054 source: hosted 1071 1055 version: "15.0.2" 1072 - wakelock_plus:1073 - dependency: "direct main"1074 - description:1075 - name: wakelock_plus1076 - sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39"1077 - url: "https://pub.dev"1078 - source: hosted1079 - version: "1.5.1"1080 - wakelock_plus_platform_interface:1081 - dependency: transitive1082 - description:1083 - name: wakelock_plus_platform_interface1084 - sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03"1085 - url: "https://pub.dev"1086 - source: hosted1087 - version: "1.4.0"1088 1056 web: 1089 1057 dependency: transitive 1090 1058 description: .. .. @@ -1093,22 +1061,6 @@ 1093 1061 url: "https://pub.dev" 1094 1062 source: hosted 1095 1063 version: "1.1.1" 1096 - web_socket:1097 - dependency: transitive1098 - description:1099 - name: web_socket1100 - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"1101 - url: "https://pub.dev"1102 - source: hosted1103 - version: "1.0.1"1104 - web_socket_channel:1105 - dependency: "direct main"1106 - description:1107 - name: web_socket_channel1108 - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c81109 - url: "https://pub.dev"1110 - source: hosted1111 - version: "3.0.3"1112 1064 win32: 1113 1065 dependency: transitive 1114 1066 description: pubspec.yaml
.. .. @@ -13,7 +13,6 @@ 13 13 flutter_riverpod: ^2.6.1 14 14 riverpod_annotation: ^2.6.1 15 15 go_router: ^14.8.1 16 - web_socket_channel: ^3.0.217 16 path_provider: ^2.1.0 18 17 shared_preferences: ^2.5.3 19 18 record: ^6.2.0 .. .. @@ -21,7 +20,6 @@ 21 20 permission_handler: ^11.4.0 22 21 image_picker: ^1.1.2 23 22 flutter_secure_storage: ^9.2.4 24 - wakelock_plus: ^1.2.825 23 vibration: ^2.0.1 26 24 share_plus: ^12.0.1 27 25 udp: ^5.0.3 .. .. @@ -32,6 +30,7 @@ 32 30 file_picker: ^10.3.10 33 31 flutter_markdown: ^0.7.7+1 34 32 bonsoir: ^6.0.2 33 + crypto: ^3.0.735 34 36 35 dev_dependencies: 37 36 flutter_test: windows/flutter/generated_plugin_registrant.cc
.. .. @@ -7,6 +7,7 @@ 7 7 #include "generated_plugin_registrant.h" 8 8 9 9 #include <audioplayers_windows/audioplayers_windows_plugin.h> 10 +#include <bonsoir_windows/bonsoir_windows_plugin_c_api.h>10 11 #include <file_selector_windows/file_selector_windows.h> 11 12 #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> 12 13 #include <permission_handler_windows/permission_handler_windows_plugin.h> .. .. @@ -17,6 +18,8 @@ 17 18 void RegisterPlugins(flutter::PluginRegistry* registry) { 18 19 AudioplayersWindowsPluginRegisterWithRegistrar( 19 20 registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); 21 + BonsoirWindowsPluginCApiRegisterWithRegistrar(22 + registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi"));20 23 FileSelectorWindowsRegisterWithRegistrar( 21 24 registry->GetRegistrarForPlugin("FileSelectorWindows")); 22 25 FlutterSecureStorageWindowsPluginRegisterWithRegistrar( windows/flutter/generated_plugins.cmake
.. .. @@ -4,6 +4,7 @@ 4 4 5 5 list(APPEND FLUTTER_PLUGIN_LIST 6 6 audioplayers_windows 7 + bonsoir_windows7 8 file_selector_windows 8 9 flutter_secure_storage_windows 9 10 permission_handler_windows