diff --git a/CHANGELOG.md b/CHANGELOG.md index ca515b9..0daf3a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,24 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH --- +## [3.3.8] — 2026-10-17 + +### Changed +- **Reconnecting always starts a fresh session.** In SOCKS5 mode, the + **Reconnect** button used to re-attach to the session you already had. It now + ends that session and starts a new one, exactly as the connect button does — + one way to begin a session instead of two, and you get a full window back + rather than the remainder of a broken one. + +### Fixed +- **SOCKS5 "Reconnect" works again.** In 3.3.7 it ended your session and then + asked for the session it had just ended, failing with "Session not activated". + +--- + ## [3.3.7] — 2026-10-17 -> **Known issue — fixed in the next release.** In SOCKS5 mode, the **Reconnect** +> **Known issue — fixed in 3.3.8.** In SOCKS5 mode, the **Reconnect** > button in Settings ends your session instead of re-attaching to it, and then > fails with "Session not activated". It only appears when a proxy session has > been orphaned, and you lose the rest of that session rather than anything diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index 81111df..bf62bca 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -146,14 +146,13 @@ class HivemindService { _expectedNonce = null; } - // Fetches this device's VLESS URL. - // adNonce — wait for the session that ad created (SSV can lag, so poll longer) - // resume — re-attach to the session already held; any nonce accepted - // neither — ads off: mint a nonce and fire the bypass (rule 87) + // Fetches this device's VLESS URL. Every session starts the same way, so + // there is always a nonce to bind it to: either the one the ad minted, or one + // we mint here before firing the bypass (rule 87). The re-attach path that + // used to accept any nonce is gone - see rule 4. static Future fetchConfigDirectly({ void Function(int attempt, int total)? onAttempt, String? adNonce, - bool resume = false, }) async { // Capture the generation before the first await. Otherwise a disconnect // that lands while secure storage is resolving the device ID can be lost @@ -162,11 +161,11 @@ class HivemindService { final deviceId = await CryptoService.getDeviceId(); _throwIfCancelled(callId); - final nonce = resume ? null : (adNonce ?? newNonce()); + final nonce = adNonce ?? newNonce(); _expectedNonce = nonce; - if (!resume && adNonce == null) { - await _runConfiguredBypass(deviceId, nonce!); + if (adNonce == null) { + await _runConfiguredBypass(deviceId, nonce); _throwIfCancelled(callId); } @@ -229,10 +228,9 @@ class HivemindService { } } - // nonce == null accepts whatever nonce the server holds (resume path). static Future<_HivemindSessionConfig?> _fetchActiveSession( String deviceId, - String? nonce, + String nonce, ) async { final statusUri = _publicUrl('/session/status').replace( queryParameters: {'device_id': deviceId}, @@ -245,9 +243,7 @@ class HivemindService { final serverNonce = decoded['nonce']; if (_expectedNonce != nonce) return null; - if (nonce != null && (serverNonce is! String || serverNonce != nonce)) { - return null; - } + if (serverNonce is! String || serverNonce != nonce) return null; if (decoded['active'] != true || decoded['vless_uuid'] == null) { return null; diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 0b99cc1..65203c4 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -158,9 +158,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } } - // resetSupportClaim is false when re-attaching to a session already held: - // the top-up is once per server session and this flag is what enforces it. - Future start({bool resetSupportClaim = true}) async { + Future start() async { _sessionEpoch++; _remainingSeconds = 0; _remainingAtLastSync = 0; @@ -178,12 +176,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _lastEndMessage = null; NotificationService.reset(); - if (resetSupportClaim) { - _supportStateEpoch++; - _supportRewardClaimed = false; - _supportRewardStateLoaded = true; - unawaited(_persistSupportRewardState(false)); - } + // Every session is new, so the once-per-session top-up resets with it. + _supportStateEpoch++; + _supportRewardClaimed = false; + _supportRewardStateLoaded = true; + unawaited(_persistSupportRewardState(false)); _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), _tick); @@ -240,17 +237,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } } - // release is false when the caller intends to re-attach to the same session, - // as the SOCKS5 Reconnect does: handing it back here would destroy the very - // session the follow-up connect(resume: true) is about to ask for. - Future disconnect({ - String reason = 'User requested', - bool release = true, - }) async { + Future disconnect({String reason = 'User requested'}) async { // Only here, not in _doDisconnect: this is the one path the user actually // asked for. Unawaited so a slow or unreachable API never holds up the // teardown. - if (release) unawaited(HivemindService.releaseSession()); + unawaited(HivemindService.releaseSession()); await _doDisconnect(reason); } diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 788b8cf..00ebe4d 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -200,7 +200,7 @@ class VpnConnection extends ChangeNotifier { } } - Future connect({String? adNonce, bool resume = false}) async { + Future connect({String? adNonce}) async { if (_disposed || _userDisconnecting || _status == VpnStatus.connected || @@ -266,7 +266,6 @@ class VpnConnection extends ChangeNotifier { try { realUrl = await HivemindService.fetchConfigDirectly( adNonce: adNonce, - resume: resume, onAttempt: (attempt, total) { if (!_isCurrentConnect(connectEpoch)) return; _setStatus( diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart index e91f3ad..f9ed06f 100644 --- a/lib/screens/settings/in_settings/connection_mode.dart +++ b/lib/screens/settings/in_settings/connection_mode.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:revoltvpn/logic/ad_manager.dart'; import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/local_socks_tester.dart'; @@ -67,20 +68,27 @@ class _ConnectionModeTileState extends State { widget.onChanged?.call(next); } - // Recovers an orphaned proxy: tear the runtime down and re-attach to the - // session already held. No ad, no new session. + // Recovers an orphaned proxy by ending the session and starting a new one, + // exactly as the connect button does. Re-attaching to the session already + // held was the one place a session began without an ad and without a nonce + // to bind it to; a fresh one is the same path everywhere and hands back a + // full window rather than the remainder of a broken one. Future _reconnect() async { if (_reconnecting) return; final vpn = context.read(); final timer = context.read(); + final ad = context.read(); setState(() => _reconnecting = true); try { - await timer.disconnect(release: false); + await timer.disconnect(); if (!mounted) return; - if (await vpn.connect(resume: true)) { + final adNonce = await ad.showAd('main'); + if (adNonce == null && AdManager.adsEnabled) return; + if (!mounted) return; + if (await vpn.connect(adNonce: adNonce)) { if (!mounted) return; - await timer.start(resetSupportClaim: false); + await timer.start(); } } finally { if (mounted) { diff --git a/pubspec.yaml b/pubspec.yaml index 4d9c1d0..2beaf14 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.3.7+33 +version: 3.3.8+34 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/test/session_lifecycle_regression_test.dart b/test/session_lifecycle_regression_test.dart index 3819ced..6f84e29 100644 --- a/test/session_lifecycle_regression_test.dart +++ b/test/session_lifecycle_regression_test.dart @@ -116,7 +116,7 @@ void main() { // /session/stop takes a device_id as its only proof of ownership, so it // must never be reachable from an error path. disconnect() is called only // by the connect button; _doDisconnect is every other ending. - final disconnectEntry = timer.indexOf('Future disconnect('); + final disconnectEntry = timer.indexOf('Future disconnect({'); final doDisconnect = timer.indexOf('Future _doDisconnect('); final release = timer.indexOf('HivemindService.releaseSession()'); expect(release, greaterThan(disconnectEntry)); @@ -132,21 +132,36 @@ void main() { ); }); - test('SOCKS5 Reconnect re-attaches instead of handing the session back', () { - final timer = File('lib/logic/session_timer.dart').readAsStringSync(); + test('every session starts the same way - no re-attach path', () { final mode = File('lib/screens/settings/in_settings/connection_mode.dart') .readAsStringSync(); + final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync(); + final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync(); + final timer = File('lib/logic/session_timer.dart').readAsStringSync(); - // Reconnect repairs an orphaned proxy by re-attaching to the session the - // device already holds. Releasing it on the way out would destroy the very - // session the follow-up connect(resume: true) then asks for. - expect(timer, contains('bool release = true')); - expect(timer, contains('if (release) unawaited(HivemindService.releaseSession())')); - expect(mode, contains('timer.disconnect(release: false)')); - expect(mode, contains('vpn.connect(resume: true)')); + // SOCKS5 Reconnect used to re-attach to the session already held. It was + // the only place a session began without an ad and without a nonce binding + // it, and it needed three parameters and a carve-out in rule 4 to support. + // Reconnect now ends the session and starts a fresh one, like the button. + expect(mode, contains("ad.showAd('main')")); + expect(mode, contains('vpn.connect(adNonce: adNonce)')); + expect(mode, isNot(contains('resume: true'))); + expect(mode, isNot(contains('release: false'))); + expect(mode, isNot(contains('resetSupportClaim'))); - // And it must not hand back a second support top-up for the same session. - expect(mode, contains('timer.start(resetSupportClaim: false)')); + // The special cases that only existed for it are gone. + expect(hivemind, isNot(contains('bool resume'))); + expect(vpn, isNot(contains('bool resume'))); + expect(timer, isNot(contains('bool release'))); + expect(timer, isNot(contains('resetSupportClaim'))); + + // So the nonce is now unconditional: no path accepts whatever the server + // happens to hold. + expect( + hivemind, + contains('if (serverNonce is! String || serverNonce != nonce) return null;'), + reason: 'the nonce check must not be skippable', + ); }); }