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:
ssbg04 committed 2026-08-18 21:13:58 +08:00
1 parent c8f215eaaf
commit e87a771a02
4 files changed
+135 -108

No files matched your search

+3 -3
View File
@@ -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;
}
+34 -10
View File
@@ -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),
),
),
],
@@ -889,12 +899,26 @@ class _NetworkScanDialogState extends State<_NetworkScanDialog> {
message: 'Connected to local server: $found',
);
} else {
if (!mounted) return;
showErrorDialog(
context,
'Server Not Found',
'No TIS RMS server found on local network.',
);
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 and tunnel domain (${ApiConstants.tunnelUrl}) is unreachable.',
);
}
}
}
@@ -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,51 +110,64 @@ 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.',
if (prefixes.isNotEmpty) {
final subnetLabel = prefixes.map((p) => '${p}0/24').join(', ');
setState(() {
_statusText = 'Scanning LAN ($subnetLabel)…';
_scanProgress = 0.0;
});
final found = await ServerDiscoveryService.discover(
onProgress: (subnet, scanned, total) {
if (!mounted) return;
setState(() {
_statusText = 'Scanning ${subnet}x … ($scanned/$total)';
_scanProgress = scanned / total;
});
},
);
return;
if (found != null) {
await ServerDiscoveryService.save(found);
ApiConstants.setBaseUrl(found);
if (mounted) {
setState(() {
_statusText = 'Local server found: $found';
_scanProgress = 1.0;
});
}
await Future.delayed(const Duration(milliseconds: 500));
return;
}
}
final subnetLabel = prefixes.map((p) => '${p}0/24').join(', ');
// 3. If local server was not found on LAN, connect to tunnel domain fallback
setState(() {
_statusText = 'Scanning $subnetLabel…';
_scanProgress = 0.0;
_statusText = 'Connecting to tunnel domain…';
_scanProgress = null;
});
final found = await ServerDiscoveryService.discover(
onProgress: (subnet, scanned, total) {
if (!mounted) return;
setState(() {
_statusText = 'Scanning ${subnet}x … ($scanned/$total)';
_scanProgress = scanned / total;
});
},
);
if (found != null) {
await ServerDiscoveryService.save(found);
ApiConstants.setBaseUrl(found);
final tunnelAlive =
await ServerDiscoveryService.ping(ApiConstants.tunnelUrl);
if (tunnelAlive) {
await ServerDiscoveryService.save(ApiConstants.tunnelUrl);
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
if (mounted) {
setState(() {
_statusText = 'Server found: $found';
_statusText = 'Connected to tunnel server';
_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;
}
// 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();
}
// ── Manual entry dialog ────────────────────────────────────────────────────
Future<void> _showManualEntryDialog({required String reason}) async {
final controller = TextEditingController();
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