From 54609a31191fe69e1e9cb3d98f9774bac3353e05 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Fri, 31 Jul 2026 22:41:29 +0300 Subject: [PATCH] audit #13: notification fix, sync guard, bypass gate, responsive layout, dynamic version, dart format, clock_display to backups, stats race fix, new locks, nginx swarm block --- .gitignore | 9 +- CHANGELOG.md | 15 ++- .../components => backups}/clock_display.dart | 0 lib/components/sidebar_drawer.dart | 3 +- lib/logic/app_config.example.dart | 7 ++ lib/logic/hivemind_service.dart | 2 +- lib/logic/session_timer.dart | 11 +- lib/logic/vpn_connection.dart | 33 ++++-- lib/main.dart | 8 ++ lib/screens/main_screen.dart | 103 ++++++++++-------- server_hivemind_5_0.py | 8 +- 11 files changed, 132 insertions(+), 67 deletions(-) rename {lib/components => backups}/clock_display.dart (100%) diff --git a/.gitignore b/.gitignore index 12a0276..3eebdbf 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ .idea/ # The .vscode folder contains configure files for the Visual Studio Code -# editor. Although we may keep it, we often ignore it. .vscode/ # Python cache & User files @@ -92,3 +91,11 @@ hivemind/ # Thin entry point (imports from hivemind/) server_hivemind_5_0.py +devtools_options.yaml +.codewhale/state/subagents.v1.lock + +#ai +.codewhale/ + +#backup for files i might use later +backup/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b1534ef..c3e1f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,20 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH ## [2.0.7] — Unreleased -### Added +### Fixed (audit #13 — 2026-07-31) +- **Notification bug** — `SessionTimer.stop()` now cancels the persistent Android notification. Previously it lingered after user-initiated disconnect. +- **Concurrent sync guard** — `_syncInProgress` flag in `_syncWithHivemind()` prevents duplicate HTTP requests from overlapping. +- **Debug bypass gate** — `AppConfig.enableAdBypass` (in `app_config.example.dart`) replaces `kDebugMode` for the fake AdMob callback. Tree-shaken in release. + +### Changed +- **Responsive layout** — main screen uses `LayoutBuilder` with proportional fractions instead of hardcoded pixel positions. Pixel-identical on reference device, scales to any screen. +- **Dynamic version** — sidebar footer reads `ReVoltApp.appVersion` from `PackageInfo` instead of hardcoded `v2.0.7`. +- **Swarm/log blocked** — nginx explicitly returns 404 for `/api/swarm` and `/api/log`. Monitoring runs locally via `swarm_watch.py` on `127.0.0.1:5000`. + +### Server fixes (internal) +- `/session/stop` fetches Xray stats BEFORE popping session — prevents race where final byte count is lost +- `XrayManager._stats_lock` serializes diagnostic counter (non-atomic `+=` race) +- `ManagementLoop._start_lock` prevents TOCTOU on daemon startup - **Manual update checker** — "Check for updates" button in sidebar - Uses GitHub Releases API to find the latest version - Up to date → snackbar "✓ Up to date" diff --git a/lib/components/clock_display.dart b/backups/clock_display.dart similarity index 100% rename from lib/components/clock_display.dart rename to backups/clock_display.dart diff --git a/lib/components/sidebar_drawer.dart b/lib/components/sidebar_drawer.dart index 5576195..14badfd 100644 --- a/lib/components/sidebar_drawer.dart +++ b/lib/components/sidebar_drawer.dart @@ -6,6 +6,7 @@ import 'package:revoltvpn/components/sidebar_contents/info_button.dart'; import 'package:revoltvpn/components/sidebar_contents/discord_link_button.dart'; import 'package:revoltvpn/components/sidebar_contents/website_button.dart'; import 'package:revoltvpn/logic/updater.dart'; +import 'package:revoltvpn/main.dart'; class SidebarDrawer extends StatelessWidget { const SidebarDrawer({super.key}); @@ -108,7 +109,7 @@ class SidebarDrawer extends StatelessWidget { const Padding( padding: EdgeInsets.all(20), - child: Text('ReVoltVPN · v2.0.7', textAlign: TextAlign.center, + child: Text('ReVoltVPN · v${ReVoltApp.appVersion}', textAlign: TextAlign.center, style: TextStyle(color: AppColors.slate, fontSize: 11, letterSpacing: 1), ), ), diff --git a/lib/logic/app_config.example.dart b/lib/logic/app_config.example.dart index f5a5414..e77ac15 100644 --- a/lib/logic/app_config.example.dart +++ b/lib/logic/app_config.example.dart @@ -46,4 +46,11 @@ abstract final class AppConfig { // ── AdMob ────────────────────────────────────────────────────────────── static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE + + // ── Debug ────────────────────────────────────────────────────────────── + /// When true, the client skips the real AdMob flow and sends a fake SSV + /// callback directly to the server (requires ADMOB_BYPASS=True server-side). + /// Set to false in production — the Dart compiler tree-shakes the bypass + /// code entirely when this is a compile-time constant false. + static const bool enableAdBypass = false; } \ No newline at end of file diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index e1274cd..a10c9b9 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -37,7 +37,7 @@ class HivemindService { final url = _url('/session/status?device_id=$deviceId'); // ── AD BYPASS (debug only) ──────────────────────────────────────── - if (kDebugMode && !skipAdBypass) { + if (AppConfig.enableAdBypass && !skipAdBypass) { try { final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce}); final fakeUrl = _url( diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index ee23475..fb43272 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -20,6 +20,7 @@ class SessionTimer extends ChangeNotifier { int _consecutiveFailures = 0; bool _isDisconnecting = false; bool _userInitiatedStop = false; + bool _syncInProgress = false; int? _currentPort; @@ -183,9 +184,10 @@ class SessionTimer extends ChangeNotifier { Future _syncWithHivemind() async { - // Never sync while the user is explicitly disconnecting. - if (_isDisconnecting) return; - + // Never sync while the user is explicitly disconnecting, + // or while another sync is already in flight. + if (_isDisconnecting || _syncInProgress) return; + _syncInProgress = true; try { final deviceId = await CryptoService.getDeviceId(); final url = Uri.parse( @@ -248,6 +250,8 @@ class SessionTimer extends ChangeNotifier { } catch (e) { debugPrint('Hivemind sync error: $e'); _markSyncFailure(); + } finally { + _syncInProgress = false; } } @@ -268,6 +272,7 @@ class SessionTimer extends ChangeNotifier { _currentSpeedKBps = 0.0; _hasSyncedOnce = false; _remainingSeconds = 0; + VpnNotificationManager.cancel(); notifyListeners(); } diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 7f2c66b..8f891fe 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -60,7 +60,8 @@ class VpnConnection extends ChangeNotifier { // Periodic server health check _checkHealth(); - _healthTimer = Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth()); + _healthTimer = + Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth()); // Check if VPN was already running (app killed and reopened) try { @@ -102,7 +103,8 @@ class VpnConnection extends ChangeNotifier { } } - Future connect({bool skipAdBypass = false, bool quickReconnect = false}) async { + Future connect( + {bool skipAdBypass = false, bool quickReconnect = false}) async { if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { return false; } @@ -130,7 +132,8 @@ class VpnConnection extends ChangeNotifier { quickReconnect: quickReconnect, onAttempt: (attempt, total) { if (!quickReconnect) { - _setStatus(VpnStatus.connecting, 'Contacting server ($attempt/$total)…'); + _setStatus( + VpnStatus.connecting, 'Contacting server ($attempt/$total)…'); } }, ); @@ -140,11 +143,15 @@ class VpnConnection extends ChangeNotifier { if (raw.contains('Cancelled')) { // User tapped disconnect — silently abort return false; - } else if (raw.contains('timed out') || raw.contains('Session not activated')) { - _errorMessage = 'The server did not respond in time.\nCheck your connection and try again.'; + } else if (raw.contains('timed out') || + raw.contains('Session not activated')) { + _errorMessage = + 'The server did not respond in time.\nCheck your connection and try again.'; _setStatus(VpnStatus.error, 'Server unreachable'); - } else if (raw.contains('SocketException') || raw.contains('Failed host lookup')) { - _errorMessage = 'Cannot reach the server.\nMake sure you have internet access.'; + } else if (raw.contains('SocketException') || + raw.contains('Failed host lookup')) { + _errorMessage = + 'Cannot reach the server.\nMake sure you have internet access.'; _setStatus(VpnStatus.error, 'No internet'); } else { _errorMessage = raw; @@ -186,7 +193,8 @@ class VpnConnection extends ChangeNotifier { } if (!sessionOk) { - debugPrint('[VPN] Tunnel started but no server session — tearing down.'); + debugPrint( + '[VPN] Tunnel started but no server session — tearing down.'); try { await _vless.stopVless(); } catch (_) {} @@ -209,7 +217,8 @@ class VpnConnection extends ChangeNotifier { Future disconnect({bool skipCleanup = false}) async { _cancelled = true; HivemindService.cancel(); // abort any in-progress polling - if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) { + if (_status == VpnStatus.disconnected || + _status == VpnStatus.disconnecting) { return; } @@ -223,9 +232,9 @@ class VpnConnection extends ChangeNotifier { try { await _vless.stopVless().timeout( - const Duration(seconds: 5), - onTimeout: () => debugPrint('[VPN] stopVless() timed out.'), - ); + const Duration(seconds: 5), + onTimeout: () => debugPrint('[VPN] stopVless() timed out.'), + ); } catch (e) { debugPrint('[VPN] VLESS stop error: $e'); } diff --git a/lib/main.dart b/lib/main.dart index 8e247c4..b8d1473 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/session_timer.dart'; @@ -35,12 +36,19 @@ void main() async { await ConsentManager.requestConsentIfNeeded(); await MobileAds.instance.initialize(); + final packageInfo = await PackageInfo.fromPlatform(); + ReVoltApp.appVersion = packageInfo.version; + runApp(const ReVoltApp()); } class ReVoltApp extends StatelessWidget { const ReVoltApp({super.key}); + /// Set at startup from PackageInfo — used by sidebar and anywhere + /// the app version needs to be displayed. + static String appVersion = ''; + @override Widget build(BuildContext context) { return MultiProvider( diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 4f93753..726097e 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -29,61 +29,70 @@ class MainScreen extends StatelessWidget { ), ), child: SafeArea( - child: Stack( - children: [ - // ── Lightning — behind rain so drops stay visible during flash ── - const Positioned.fill(child: LightningEffect()), + child: LayoutBuilder( + builder: (context, constraints) { + // Proportional positioning — fractions of available height. + // On the reference device (~700 dp) this produces the exact + // original pixel positions (100, 175, 250, 520) and scales + // proportionally on taller/shorter screens. + final h = constraints.maxHeight; + return Stack( + children: [ + // ── Lightning — behind rain so drops stay visible during flash ── + const Positioned.fill(child: LightningEffect()), - // ── Rain effect — behind all UI, baked into the background ── - const Positioned.fill(child: RainEffect()), + // ── Rain effect — behind all UI, baked into the background ── + const Positioned.fill(child: RainEffect()), - Positioned( - top: 0, - right: 4, - child: Builder( - builder: (ctx) => IconButton( - icon: const Icon(Icons.menu, color: AppColors.textWhite, size: 28), - onPressed: () => Scaffold.of(ctx).openEndDrawer(), - tooltip: 'Menu', splashRadius: 24, + Positioned( + top: 0, + right: 4, + child: Builder( + builder: (ctx) => IconButton( + icon: const Icon(Icons.menu, color: AppColors.textWhite, size: 28), + onPressed: () => Scaffold.of(ctx).openEndDrawer(), + tooltip: 'Menu', splashRadius: 24, + ), + ), ), - ), - ), - Positioned( - top: 100, // <-- CHANGE ME: timer distance from top - left: 0, - right: 0, - child: const Center(child: TimerBox()), - ), + Positioned( + top: h * 0.143, + left: 0, + right: 0, + child: const Center(child: TimerBox()), + ), - Positioned( - top: 175, // <-- CHANGE ME: support button from top - left: 0, - right: 0, - child: const Center(child: SupportButton()), - ), + Positioned( + top: h * 0.250, + left: 0, + right: 0, + child: const Center(child: SupportButton()), + ), - Positioned( - top: 250, // <-- CHANGE ME: connect button from top - left: 0, - right: 0, - child: const Center(child: ConnectButton()), - ), + Positioned( + top: h * 0.357, + left: 0, + right: 0, + child: const Center(child: ConnectButton()), + ), - Positioned( - top: 520, // <-- CHANGE ME: speed box from top - left: 0, - right: 0, - child: const Center(child: SpeedBox()), - ), + Positioned( + top: h * 0.743, + left: 0, + right: 0, + child: const Center(child: SpeedBox()), + ), - Positioned( - bottom: 16, // <-- CHANGE ME: status bar from bottom - left: 0, - right: 0, - child: const StatusBar(), - ), - ], + const Positioned( + bottom: 16, + left: 0, + right: 0, + child: StatusBar(), + ), + ], + ); + }, ), ), ), diff --git a/server_hivemind_5_0.py b/server_hivemind_5_0.py index 4dd442f..5a4ca5c 100644 --- a/server_hivemind_5_0.py +++ b/server_hivemind_5_0.py @@ -13,7 +13,13 @@ gunicorn (production):: See ``hivemind/config.py`` for all tunables. """ -from hivemind.main import app +from hivemind.main import app, start_management + +# The management daemon starts automatically when gunicorn imports +# hivemind.main:app. For direct runs (python server_hivemind_5_0.py), +# we call start_management() explicitly. It's idempotent — safe to +# call even if the import-time start already fired. +start_management() if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)