feat(network): scan LAN first with tunnel domain fallback (https://tis-rms.cc.cd/api) and preserve credentials on failure
This commit is contained in:
1 parent
c8f215eaaf
commit
e87a771a02
4 files changed
+117
-90
No files matched your search
@@ -4,12 +4,13 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ApiConstants {
|
||||
static const int port = 18484;
|
||||
static const String tunnelUrl = 'https://tis-rms.cc.cd/api';
|
||||
static const String vpsUrl = 'http://198.252.107.197:$port/api';
|
||||
static const String localhostUrl = 'http://127.0.0.1:$port/api';
|
||||
|
||||
// Runtime-mutable base URL — set by ServerDiscoveryService before first use.
|
||||
// Default: remote internet/configured VPS server IP or domain.
|
||||
static String _baseUrl = vpsUrl;
|
||||
// Default: tunnel domain or discovered local server.
|
||||
static String _baseUrl = tunnelUrl;
|
||||
|
||||
static String get baseUrl => _baseUrl;
|
||||
|
||||
@@ -38,7 +39,6 @@ class ApiConstants {
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
options.baseUrl = _baseUrl;
|
||||
options.headers['Bypass-Tunnel-Reminder'] = 'true';
|
||||
|
||||
// Prevent unauthenticated or malformed token requests from hitting the server
|
||||
final authHeader = options.headers['Authorization']?.toString() ?? '';
|
||||
|
||||
@@ -33,18 +33,30 @@ class ServerDiscoveryService {
|
||||
|
||||
static Future<bool> ping(String baseUrl) async {
|
||||
try {
|
||||
final rootUrl = baseUrl
|
||||
.replaceAll(RegExp(r'/+$'), '')
|
||||
.replaceAll(RegExp(r'/api$'), '');
|
||||
final clean = baseUrl.replaceAll(RegExp(r'/+$'), '');
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: rootUrl,
|
||||
connectTimeout: _pingTimeout,
|
||||
receiveTimeout: _pingTimeout,
|
||||
sendTimeout: _pingTimeout,
|
||||
),
|
||||
);
|
||||
final response = await dio.get('/');
|
||||
return response.headers.value('x-tis-rms') == 'true';
|
||||
final response = await dio.get(clean);
|
||||
if (response.headers.value('x-tis-rms') == 'true' ||
|
||||
(response.statusCode == 200 &&
|
||||
response.data is Map &&
|
||||
response.data['message']?.toString().contains('TIS RMS') == true)) {
|
||||
return true;
|
||||
}
|
||||
final rootUrl = clean.replaceAll(RegExp(r'/api$'), '');
|
||||
if (rootUrl.isNotEmpty && rootUrl != clean) {
|
||||
final rootResponse = await dio.get(rootUrl);
|
||||
return rootResponse.headers.value('x-tis-rms') == 'true' ||
|
||||
(rootResponse.statusCode == 200 &&
|
||||
rootResponse.data is Map &&
|
||||
rootResponse.data['message']?.toString().contains('TIS RMS') == true);
|
||||
}
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProv
|
||||
await prefs.remove('saved_password');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final user = ref.read(authProvider).value;
|
||||
final isDesktop = MediaQuery.of(context).size.width >= 800;
|
||||
|
||||
@@ -515,17 +516,26 @@ class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProv
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
|
||||
await ServerDiscoveryService.save(ApiConstants.baseUrl);
|
||||
if (mounted) setState(() {});
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Use Tunnel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
ApiConstants.setBaseUrl(ApiConstants.vpsUrl);
|
||||
await ServerDiscoveryService.save(ApiConstants.baseUrl);
|
||||
if (mounted) setState(() {});
|
||||
Navigator.pop(ctx);
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Use VPS'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -550,7 +560,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProv
|
||||
await ServerDiscoveryService.save(ApiConstants.baseUrl);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
Navigator.pop(ctx);
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
@@ -623,7 +633,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProv
|
||||
'Remember Me',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -888,15 +898,29 @@ class _NetworkScanDialogState extends State<_NetworkScanDialog> {
|
||||
title: 'Server Connected',
|
||||
message: 'Connected to local server: $found',
|
||||
);
|
||||
} else {
|
||||
final tunnelAlive =
|
||||
await ServerDiscoveryService.ping(ApiConstants.tunnelUrl);
|
||||
if (tunnelAlive) {
|
||||
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
|
||||
await ServerDiscoveryService.save(ApiConstants.baseUrl);
|
||||
if (!mounted) return;
|
||||
showSuccessDialog(
|
||||
context,
|
||||
title: 'Connected to Tunnel',
|
||||
message:
|
||||
'Local server not found. Connected to tunnel domain: ${ApiConstants.tunnelUrl}',
|
||||
);
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Server Not Found',
|
||||
'No TIS RMS server found on local network.',
|
||||
'No TIS RMS server found on local network and tunnel domain (${ApiConstants.tunnelUrl}) is unreachable.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:io' show Platform, exit;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart' show SystemNavigator;
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -85,7 +86,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
// ── Server resolution logic ────────────────────────────────────────────────
|
||||
|
||||
Future<void> _resolveServer() async {
|
||||
// 1. Check saved URL first (if user explicitly selected/saved a VPS or LAN IP)
|
||||
// 1. Check saved URL first (if user explicitly selected/saved a VPS, LAN IP, or Tunnel URL)
|
||||
final saved = await ServerDiscoveryService.getSaved();
|
||||
if (saved != null) {
|
||||
setState(() => _statusText = 'Connecting to saved server…');
|
||||
@@ -94,8 +95,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
ApiConstants.setBaseUrl(saved);
|
||||
return;
|
||||
}
|
||||
// Saved server is unreachable — clear it and continue discovery
|
||||
await ServerDiscoveryService.clear();
|
||||
// Saved server is unreachable — don't wipe credentials, continue to LAN scan & tunnel fallback
|
||||
}
|
||||
|
||||
// 2. Automatically scan local network first by default (LAN Discovery)
|
||||
@@ -103,7 +103,6 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
}
|
||||
|
||||
Future<void> _runScan() async {
|
||||
// Get own IP / subnet prefixes first
|
||||
setState(() {
|
||||
_statusText = 'Detecting network…';
|
||||
_scanProgress = null;
|
||||
@@ -111,17 +110,10 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
|
||||
final prefixes = await ServerDiscoveryService.getSubnetPrefixes();
|
||||
|
||||
if (prefixes.isEmpty) {
|
||||
// No LAN interface found — go straight to manual entry
|
||||
await _showManualEntryDialog(
|
||||
reason: 'No Wi-Fi or LAN connection detected.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (prefixes.isNotEmpty) {
|
||||
final subnetLabel = prefixes.map((p) => '${p}0/24').join(', ');
|
||||
setState(() {
|
||||
_statusText = 'Scanning $subnetLabel…';
|
||||
_statusText = 'Scanning LAN ($subnetLabel)…';
|
||||
_scanProgress = 0.0;
|
||||
});
|
||||
|
||||
@@ -140,22 +132,42 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
ApiConstants.setBaseUrl(found);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusText = 'Server found: $found';
|
||||
_statusText = 'Local server found: $found';
|
||||
_scanProgress = 1.0;
|
||||
});
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
} else {
|
||||
await _showManualEntryDialog(
|
||||
reason: 'No TIS RMS server was found on your network.',
|
||||
);
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manual entry dialog ────────────────────────────────────────────────────
|
||||
// 3. If local server was not found on LAN, connect to tunnel domain fallback
|
||||
setState(() {
|
||||
_statusText = 'Connecting to tunnel domain…';
|
||||
_scanProgress = null;
|
||||
});
|
||||
|
||||
Future<void> _showManualEntryDialog({required String reason}) async {
|
||||
final controller = TextEditingController();
|
||||
final tunnelAlive =
|
||||
await ServerDiscoveryService.ping(ApiConstants.tunnelUrl);
|
||||
if (tunnelAlive) {
|
||||
await ServerDiscoveryService.save(ApiConstants.tunnelUrl);
|
||||
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusText = 'Connected to tunnel server';
|
||||
_scanProgress = 1.0;
|
||||
});
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. If the tunnel domain is ALSO not working, show error dialog with Close App (without clearing remember me credentials)
|
||||
if (!mounted) return;
|
||||
await _showConnectionFailedDialog();
|
||||
}
|
||||
|
||||
Future<void> _showConnectionFailedDialog() async {
|
||||
if (!mounted) return;
|
||||
|
||||
await showDialog<void>(
|
||||
@@ -164,68 +176,47 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
icon: const Icon(
|
||||
Icons.wifi_find,
|
||||
color: AppColors.primaryGreen,
|
||||
size: 36,
|
||||
Icons.cloud_off_rounded,
|
||||
color: Colors.redAccent,
|
||||
size: 40,
|
||||
),
|
||||
title: const Text(
|
||||
'Server Not Found',
|
||||
'Server Connection Failed',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: Column(
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
reason,
|
||||
'No local TIS RMS server was detected on your network and the tunnel domain (${ApiConstants.tunnelUrl}) is currently unreachable.\n\nPlease check your network connection or ensure the server is online.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Server IP / Domain',
|
||||
hintText: '198.252.107.197 or domain.com',
|
||||
prefixIcon: const Icon(Icons.dns_outlined),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Your saved credentials remain safe.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryGreen,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
ServerDiscoveryService.save(ApiConstants.vpsUrl);
|
||||
ApiConstants.setBaseUrl(ApiConstants.vpsUrl);
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: const Text('Use VPS (Testing)'),
|
||||
),
|
||||
if (!kIsWeb &&
|
||||
(Platform.isWindows || Platform.isLinux || Platform.isMacOS))
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
ServerDiscoveryService.save(ApiConstants.localhostUrl);
|
||||
ApiConstants.setBaseUrl(ApiConstants.localhostUrl);
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: const Text('Use Localhost'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(ctx).pop();
|
||||
// Re-run scan
|
||||
await _runScan();
|
||||
_initializeApp();
|
||||
},
|
||||
child: const Text(
|
||||
'Retry Scan',
|
||||
'Retry Connection',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryGreen,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -234,28 +225,28 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
backgroundColor: Colors.redAccent,
|
||||
),
|
||||
onPressed: () {
|
||||
final ip = controller.text.trim();
|
||||
if (ip.isNotEmpty) {
|
||||
final url = ip.startsWith('http')
|
||||
? ip
|
||||
: ip.contains(':')
|
||||
? 'http://$ip'
|
||||
: 'http://$ip:${ApiConstants.port}';
|
||||
ServerDiscoveryService.save(url);
|
||||
ApiConstants.setBaseUrl(url);
|
||||
}
|
||||
Navigator.of(ctx).pop();
|
||||
// Close app without clearing remember me credentials
|
||||
_closeApp();
|
||||
},
|
||||
child: const Text('Connect'),
|
||||
child: const Text('Close App'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _closeApp() {
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
SystemNavigator.pop();
|
||||
} else if (!kIsWeb &&
|
||||
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
|
||||
Reference in new issue
Block a user