feat(session): every reconnect starts a fresh session
SOCKS5 Reconnect re-attached to the session the device already held. It was the only path where a session began without an ad and without a nonce binding it to one, and supporting it cost three parameters - resume, release, resetSupportClaim - plus a carve-out in rule 4 that let _fetchActiveSession accept whatever nonce the server happened to return. It also wasn't the kindness it looked like. _handle_main calls create_or_replace with a fresh full window, so a new session hands the user *more* time, not less; re-attaching only preserved the remainder of a session whose proxy had just died. Reconnect now disconnects and connects again exactly as the button does, ad included. All four special cases are deleted and the nonce check is unconditional. Costs one rewarded ad to repair an orphaned proxy once ads are enabled. esef's call, and it buys a single way to start a session. Also fixes the 3.3.7 regression where Reconnect released the session and then asked for it back: there is no longer a path that does either. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
73d76c04f2
commit
3ec11a383d
7 files changed
+71
-47
No files matched your search
+16
-1
@@ -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
|
## [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
|
> 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
|
> 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
|
> been orphaned, and you lose the rest of that session rather than anything
|
||||||
|
|||||||
@@ -146,14 +146,13 @@ class HivemindService {
|
|||||||
_expectedNonce = null;
|
_expectedNonce = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches this device's VLESS URL.
|
// Fetches this device's VLESS URL. Every session starts the same way, so
|
||||||
// adNonce — wait for the session that ad created (SSV can lag, so poll longer)
|
// there is always a nonce to bind it to: either the one the ad minted, or one
|
||||||
// resume — re-attach to the session already held; any nonce accepted
|
// we mint here before firing the bypass (rule 87). The re-attach path that
|
||||||
// neither — ads off: mint a nonce and fire the bypass (rule 87)
|
// used to accept any nonce is gone - see rule 4.
|
||||||
static Future<String> fetchConfigDirectly({
|
static Future<String> fetchConfigDirectly({
|
||||||
void Function(int attempt, int total)? onAttempt,
|
void Function(int attempt, int total)? onAttempt,
|
||||||
String? adNonce,
|
String? adNonce,
|
||||||
bool resume = false,
|
|
||||||
}) async {
|
}) async {
|
||||||
// Capture the generation before the first await. Otherwise a disconnect
|
// Capture the generation before the first await. Otherwise a disconnect
|
||||||
// that lands while secure storage is resolving the device ID can be lost
|
// that lands while secure storage is resolving the device ID can be lost
|
||||||
@@ -162,11 +161,11 @@ class HivemindService {
|
|||||||
final deviceId = await CryptoService.getDeviceId();
|
final deviceId = await CryptoService.getDeviceId();
|
||||||
_throwIfCancelled(callId);
|
_throwIfCancelled(callId);
|
||||||
|
|
||||||
final nonce = resume ? null : (adNonce ?? newNonce());
|
final nonce = adNonce ?? newNonce();
|
||||||
_expectedNonce = nonce;
|
_expectedNonce = nonce;
|
||||||
|
|
||||||
if (!resume && adNonce == null) {
|
if (adNonce == null) {
|
||||||
await _runConfiguredBypass(deviceId, nonce!);
|
await _runConfiguredBypass(deviceId, nonce);
|
||||||
_throwIfCancelled(callId);
|
_throwIfCancelled(callId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,10 +228,9 @@ class HivemindService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nonce == null accepts whatever nonce the server holds (resume path).
|
|
||||||
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
||||||
String deviceId,
|
String deviceId,
|
||||||
String? nonce,
|
String nonce,
|
||||||
) async {
|
) async {
|
||||||
final statusUri = _publicUrl('/session/status').replace(
|
final statusUri = _publicUrl('/session/status').replace(
|
||||||
queryParameters: {'device_id': deviceId},
|
queryParameters: {'device_id': deviceId},
|
||||||
@@ -245,9 +243,7 @@ class HivemindService {
|
|||||||
|
|
||||||
final serverNonce = decoded['nonce'];
|
final serverNonce = decoded['nonce'];
|
||||||
if (_expectedNonce != nonce) return null;
|
if (_expectedNonce != nonce) return null;
|
||||||
if (nonce != null && (serverNonce is! String || serverNonce != nonce)) {
|
if (serverNonce is! String || serverNonce != nonce) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
|
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -158,9 +158,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetSupportClaim is false when re-attaching to a session already held:
|
Future<void> start() async {
|
||||||
// the top-up is once per server session and this flag is what enforces it.
|
|
||||||
Future<void> start({bool resetSupportClaim = true}) async {
|
|
||||||
_sessionEpoch++;
|
_sessionEpoch++;
|
||||||
_remainingSeconds = 0;
|
_remainingSeconds = 0;
|
||||||
_remainingAtLastSync = 0;
|
_remainingAtLastSync = 0;
|
||||||
@@ -178,12 +176,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_lastEndMessage = null;
|
_lastEndMessage = null;
|
||||||
NotificationService.reset();
|
NotificationService.reset();
|
||||||
|
|
||||||
if (resetSupportClaim) {
|
// Every session is new, so the once-per-session top-up resets with it.
|
||||||
_supportStateEpoch++;
|
_supportStateEpoch++;
|
||||||
_supportRewardClaimed = false;
|
_supportRewardClaimed = false;
|
||||||
_supportRewardStateLoaded = true;
|
_supportRewardStateLoaded = true;
|
||||||
unawaited(_persistSupportRewardState(false));
|
unawaited(_persistSupportRewardState(false));
|
||||||
}
|
|
||||||
|
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
_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,
|
Future<void> disconnect({String reason = 'User requested'}) async {
|
||||||
// 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<void> disconnect({
|
|
||||||
String reason = 'User requested',
|
|
||||||
bool release = true,
|
|
||||||
}) async {
|
|
||||||
// Only here, not in _doDisconnect: this is the one path the user actually
|
// 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
|
// asked for. Unawaited so a slow or unreachable API never holds up the
|
||||||
// teardown.
|
// teardown.
|
||||||
if (release) unawaited(HivemindService.releaseSession());
|
unawaited(HivemindService.releaseSession());
|
||||||
await _doDisconnect(reason);
|
await _doDisconnect(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> connect({String? adNonce, bool resume = false}) async {
|
Future<bool> connect({String? adNonce}) async {
|
||||||
if (_disposed ||
|
if (_disposed ||
|
||||||
_userDisconnecting ||
|
_userDisconnecting ||
|
||||||
_status == VpnStatus.connected ||
|
_status == VpnStatus.connected ||
|
||||||
@@ -266,7 +266,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
realUrl = await HivemindService.fetchConfigDirectly(
|
realUrl = await HivemindService.fetchConfigDirectly(
|
||||||
adNonce: adNonce,
|
adNonce: adNonce,
|
||||||
resume: resume,
|
|
||||||
onAttempt: (attempt, total) {
|
onAttempt: (attempt, total) {
|
||||||
if (!_isCurrentConnect(connectEpoch)) return;
|
if (!_isCurrentConnect(connectEpoch)) return;
|
||||||
_setStatus(
|
_setStatus(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||||
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
||||||
@@ -67,20 +68,27 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
|||||||
widget.onChanged?.call(next);
|
widget.onChanged?.call(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recovers an orphaned proxy: tear the runtime down and re-attach to the
|
// Recovers an orphaned proxy by ending the session and starting a new one,
|
||||||
// session already held. No ad, no new session.
|
// 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<void> _reconnect() async {
|
Future<void> _reconnect() async {
|
||||||
if (_reconnecting) return;
|
if (_reconnecting) return;
|
||||||
final vpn = context.read<VpnConnection>();
|
final vpn = context.read<VpnConnection>();
|
||||||
final timer = context.read<SessionTimer>();
|
final timer = context.read<SessionTimer>();
|
||||||
|
final ad = context.read<AdManager>();
|
||||||
|
|
||||||
setState(() => _reconnecting = true);
|
setState(() => _reconnecting = true);
|
||||||
try {
|
try {
|
||||||
await timer.disconnect(release: false);
|
await timer.disconnect();
|
||||||
if (!mounted) return;
|
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 (!mounted) return;
|
||||||
await timer.start(resetSupportClaim: false);
|
if (await vpn.connect(adNonce: adNonce)) {
|
||||||
|
if (!mounted) return;
|
||||||
|
await timer.start();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
+1
-1
@@ -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.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
version: 3.3.7+33
|
version: 3.3.8+34
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.0 <4.0.0"
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ void main() {
|
|||||||
// /session/stop takes a device_id as its only proof of ownership, so it
|
// /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
|
// must never be reachable from an error path. disconnect() is called only
|
||||||
// by the connect button; _doDisconnect is every other ending.
|
// by the connect button; _doDisconnect is every other ending.
|
||||||
final disconnectEntry = timer.indexOf('Future<void> disconnect(');
|
final disconnectEntry = timer.indexOf('Future<void> disconnect({');
|
||||||
final doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
|
final doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
|
||||||
final release = timer.indexOf('HivemindService.releaseSession()');
|
final release = timer.indexOf('HivemindService.releaseSession()');
|
||||||
expect(release, greaterThan(disconnectEntry));
|
expect(release, greaterThan(disconnectEntry));
|
||||||
@@ -132,21 +132,36 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SOCKS5 Reconnect re-attaches instead of handing the session back', () {
|
test('every session starts the same way - no re-attach path', () {
|
||||||
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
|
|
||||||
final mode =
|
final mode =
|
||||||
File('lib/screens/settings/in_settings/connection_mode.dart')
|
File('lib/screens/settings/in_settings/connection_mode.dart')
|
||||||
.readAsStringSync();
|
.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
|
// SOCKS5 Reconnect used to re-attach to the session already held. It was
|
||||||
// device already holds. Releasing it on the way out would destroy the very
|
// the only place a session began without an ad and without a nonce binding
|
||||||
// session the follow-up connect(resume: true) then asks for.
|
// it, and it needed three parameters and a carve-out in rule 4 to support.
|
||||||
expect(timer, contains('bool release = true'));
|
// Reconnect now ends the session and starts a fresh one, like the button.
|
||||||
expect(timer, contains('if (release) unawaited(HivemindService.releaseSession())'));
|
expect(mode, contains("ad.showAd('main')"));
|
||||||
expect(mode, contains('timer.disconnect(release: false)'));
|
expect(mode, contains('vpn.connect(adNonce: adNonce)'));
|
||||||
expect(mode, contains('vpn.connect(resume: true)'));
|
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.
|
// The special cases that only existed for it are gone.
|
||||||
expect(mode, contains('timer.start(resetSupportClaim: false)'));
|
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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user