From 8cd450d5b1259254fec4d2af203173595de0471e Mon Sep 17 00:00:00 2001 From: Naveax Date: Fri, 4 Sep 2026 00:07:09 +0300 Subject: [PATCH 1/2] fix(app): fail closed on invalid connection mode state Restore SOCKS5 only from the current proxy value and keep UI state unchanged when persistence fails. --- lib/logic/connection_settings.dart | 21 +++++++++++++------ .../settings/in_settings/connection_mode.dart | 17 +++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/lib/logic/connection_settings.dart b/lib/logic/connection_settings.dart index 6189360..e7a6e36 100644 --- a/lib/logic/connection_settings.dart +++ b/lib/logic/connection_settings.dart @@ -18,23 +18,32 @@ abstract final class ConnectionSettings { final prefs = await SharedPreferences.getInstance(); final storedMode = prefs.getString(_modeKey); - // Any unrecognized value (including the pre-V3 'auto') is TUN. 'proxy' is - // the only persisted value that selects the SOCKS5 mode. + // Only the explicit current proxy value restores SOCKS5. Unknown and + // obsolete values, including the former 'auto'/'ass' labels, fail closed + // to full-tunnel TUN instead of silently selecting a weaker route. _mode = storedMode == ConnectionMode.proxy.name ? ConnectionMode.proxy : ConnectionMode.tun; if (storedMode != _mode.name) { - await prefs.setString(_modeKey, _mode.name); + final migrated = await prefs.setString(_modeKey, _mode.name); + if (!migrated) { + throw StateError('Could not persist migrated connection mode.'); + } } _initialized = true; } - static Future setMode(ConnectionMode mode) async { + static Future setMode(ConnectionMode mode) async { + if (_initialized && _mode == mode) return true; + + final prefs = await SharedPreferences.getInstance(); + final saved = await prefs.setString(_modeKey, mode.name); + if (!saved) return false; + _mode = mode; _initialized = true; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_modeKey, mode.name); + return true; } } diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart index 021c3b0..dde1703 100644 --- a/lib/screens/settings/in_settings/connection_mode.dart +++ b/lib/screens/settings/in_settings/connection_mode.dart @@ -51,8 +51,14 @@ class _ConnectionModeTileState extends State { return; } - await ConnectionSettings.setMode(next); + final saved = await ConnectionSettings.setMode(next); if (!mounted) return; + if (!saved) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not save connection mode.')), + ); + return; + } setState(() { _mode = next; @@ -61,10 +67,6 @@ class _ConnectionModeTileState extends State { widget.onChanged?.call(next); } - /// Recovers from an orphaned proxy: tear the runtime down and start a fresh - /// session so a readable set of credentials exists again. No rewarded ad — - /// the user is repairing a connection they already paid for, not opening a - /// new one. Future _reconnect() async { if (_reconnecting) return; final vpn = context.read(); @@ -168,11 +170,6 @@ class _ConnectionModeTileState extends State { builder: (context, vpn, _) { final session = vpn.activeSocksSession; if (session == null) { - // The proxy can be running while this session is unknown: - // Android killed the app process, the foreground service kept - // the listener alive, and its credentials existed only in the - // memory that died with it. Saying "Connect" there would be a - // lie — the user is already connected. final orphaned = vpn.status == VpnStatus.connected && vpn.adoptedRunningRuntime; if (orphaned) { -- 2.54.0 From f26c459f6c9962574f4b1ba1a5846834a096ba37 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sun, 6 Sep 2026 11:41:15 +0300 Subject: [PATCH 2/2] fix(app): do not throw from ConnectionSettings.initialize The migration write is best-effort again. `_mode` is already the safe TUN default in memory by the time it runs, so a failed write leaves nothing insecure -- it only means the migration is retried on the next launch. Throwing there was a startup brick rather than a fail-closed guard. `initialize()` is awaited from `main()` at lib/main.dart:19, before `runApp()`, with no try/catch and no `runZonedGuarded`; `FlutterError.onError` and `PlatformDispatcher.onError` are installed later and would not catch it either. The branch also runs on every fresh install, since `storedMode` is null and `null != 'tun'`. A `setString` returning false would therefore have meant a black screen with no error UI, repeating every launch, in exchange for no security benefit. Also restores two comments this PR removed from code it does not otherwise touch -- the `_reconnect` note on why the repair path shows no rewarded ad, and the explanation of how an orphaned proxy outlives the app process -- and drops a stray 'ass' from the legacy-values comment. The `setMode` half of the PR is unchanged: persisting before mutating memory and surfacing failures to the UI is the right behaviour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019HT2x2JvJNg7XNsJubfHFf --- lib/logic/connection_settings.dart | 12 +++++++++--- .../settings/in_settings/connection_mode.dart | 9 +++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/logic/connection_settings.dart b/lib/logic/connection_settings.dart index e7a6e36..658e9c2 100644 --- a/lib/logic/connection_settings.dart +++ b/lib/logic/connection_settings.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; enum ConnectionMode { tun, proxy } @@ -19,16 +20,21 @@ abstract final class ConnectionSettings { final storedMode = prefs.getString(_modeKey); // Only the explicit current proxy value restores SOCKS5. Unknown and - // obsolete values, including the former 'auto'/'ass' labels, fail closed - // to full-tunnel TUN instead of silently selecting a weaker route. + // obsolete values, including the pre-V3 'auto' label, fail closed to + // full-tunnel TUN instead of silently selecting a weaker route. _mode = storedMode == ConnectionMode.proxy.name ? ConnectionMode.proxy : ConnectionMode.tun; if (storedMode != _mode.name) { + // Best-effort. _mode is already the safe TUN default in memory, so a + // failed write leaves nothing insecure — it only means the migration + // is retried next launch. initialize() runs from main() before + // runApp(), with no zone guard, so throwing here would black-screen + // the app on the fresh-install path for no security gain. final migrated = await prefs.setString(_modeKey, _mode.name); if (!migrated) { - throw StateError('Could not persist migrated connection mode.'); + debugPrint('[ConnectionSettings] Could not persist migrated mode.'); } } diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart index dde1703..46519d7 100644 --- a/lib/screens/settings/in_settings/connection_mode.dart +++ b/lib/screens/settings/in_settings/connection_mode.dart @@ -67,6 +67,10 @@ class _ConnectionModeTileState extends State { widget.onChanged?.call(next); } + /// Recovers from an orphaned proxy: tear the runtime down and start a fresh + /// session so a readable set of credentials exists again. No rewarded ad — + /// the user is repairing a connection they already paid for, not opening a + /// new one. Future _reconnect() async { if (_reconnecting) return; final vpn = context.read(); @@ -170,6 +174,11 @@ class _ConnectionModeTileState extends State { builder: (context, vpn, _) { final session = vpn.activeSocksSession; if (session == null) { + // The proxy can be running while this session is unknown: + // Android killed the app process, the foreground service kept + // the listener alive, and its credentials existed only in the + // memory that died with it. Saying "Connect" there would be a + // lie — the user is already connected. final orphaned = vpn.status == VpnStatus.connected && vpn.adoptedRunningRuntime; if (orphaned) { -- 2.54.0