Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions assets/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,9 @@ msgstr "Connected"
msgid "status_off"
msgstr "Disconnected"

msgid "select_location"
msgstr "Select Location"

msgid "select_your_server_location"
msgstr "Select your server location"

Expand Down
120 changes: 109 additions & 11 deletions lib/features/system_tray/provider/system_tray_notifier.dart
Original file line number Diff line number Diff line change
@@ -1,50 +1,90 @@
import 'dart:io';

import 'package:lantern/core/models/available_servers.dart';
import 'package:lantern/core/models/macos_extension_state.dart';
import 'package:lantern/features/vpn/provider/available_servers_notifier.dart';
import 'package:lantern/features/vpn/provider/vpn_notifier.dart';
import 'package:lantern/features/window/provider/window_notifier.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:window_manager/window_manager.dart';

import '../../../core/common/common.dart';
import '../../../core/services/injection_container.dart';
import '../../macos_extension/provider/macos_extension_notifier.dart';
import '../../vpn/provider/server_location_notifier.dart';

part 'system_tray_notifier.g.dart';

@Riverpod(keepAlive: true)
class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener {
late VPNStatus _currentStatus;
VPNStatus _currentStatus = VPNStatus.disconnected;
bool _isUserPro = false;
List<Location_> _locations = [];

bool get isConnected => _currentStatus == VPNStatus.connected;

@override
Future<void> build() async {
if (!PlatformUtils.isDesktop) return;
_currentStatus = ref.read(vpnProvider);
_initializeState();
_setupListeners();
_setupTrayManager();
await updateTrayMenu();
}

void _setupTrayManager() {
trayManager.addListener(this);
ref.onDispose(() => trayManager.removeListener(this));
}

void _initializeState() {
_currentStatus = ref.read(vpnProvider);
_isUserPro = ref.read(isUserProProvider);
}

void _setupListeners() {
_listenToVPNStatus();
_listenToProStatus();
_listenToAvailableServers();
}

void _listenToVPNStatus() {
ref.listen<VPNStatus>(
vpnProvider,
(previous, next) async {
_currentStatus = next;
// Refresh menu on change
await updateTrayMenu();
},
);
}

_isUserPro = ref.read(isUserProProvider);
void _listenToProStatus() {
ref.listen<bool>(
isUserProProvider,
(previous, next) async {
_isUserPro = next;
await updateTrayMenu();
},
);

ref.onDispose(() {
trayManager.removeListener(this);
});

trayManager.addListener(this);
await updateTrayMenu();
}

bool get isConnected => _currentStatus == VPNStatus.connected;
void _listenToAvailableServers() {
ref.listen<AsyncValue<AvailableServers>>(
availableServersProvider,
(previous, next) async {
final data = next.value;
_locations = data?.lantern.locations.values.toList() ?? [];
_locations.sort((a, b) {
final cmp = a.country.compareTo(b.country);
if (cmp != 0) return cmp;
return a.city.compareTo(b.city);
});
await updateTrayMenu();
},
);
}

Future<void> toggleVPN() async {
final notifier = ref.read(vpnProvider.notifier);
Expand All @@ -55,6 +95,45 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener {
}
}

/// Handle location selection from tray menu
Future<void> _onLocationSelected(Location_ location) async {
/// Check if extension is installed and up to date before connecting
if (PlatformUtils.isMacOS) {
final systemExtensionStatus = ref.read(macosExtensionProvider);
if (systemExtensionStatus.status != SystemExtensionStatus.installed &&
systemExtensionStatus.status != SystemExtensionStatus.activated) {
windowManager.show();
appRouter.push(const MacOSExtensionDialog());
return;
}
}

final result = await ref.read(vpnProvider.notifier).connectToServer(
ServerLocationType.lanternLocation,
location.tag,
);
result.fold(
(failure) => appLogger
.error('Failed to connect: ${failure.localizedErrorMessage}'),
(success) {
appLogger.info('Connecting to ${location.country} - ${location.city}');
_saveServerLocation(location);
},
Comment on lines 99 to 121
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tray path updates the saved/selected server location immediately on connectToServer success. Elsewhere (e.g. server selection UI) the app waits until the VPN status actually becomes connected before persisting the chosen location, to avoid showing/saving a location if the connection ultimately fails. Consider aligning this flow by updating serverLocationProvider only after the VPN reports connected (or after verifying the switch completed).

Copilot uses AI. Check for mistakes.
);
Comment on lines 115 to 122
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

result.fold mixes a synchronous left handler with an async right handler; the Future returned from the success branch is not awaited, so updateServerLocation may run later (or fail) without being observed. Prefer making both branches async and awaiting the fold result (or rewrite to an explicit if (result.isLeft/Right) flow) so errors are handled deterministically.

Copilot uses AI. Check for mistakes.
}

Future<void> _saveServerLocation(Location_ location) async {
final savedServerLocation =
sl<LocalStorageService>().getSavedServerLocations();
final serverLocation = savedServerLocation.lanternLocation(
server: location,
autoSelect: false,
);
await ref
.read(serverLocationProvider.notifier)
.updateServerLocation(serverLocation);
}

Future<void> updateTrayMenu() async {
final menu = Menu(
items: [
Expand All @@ -75,6 +154,24 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener {
onClick: (_) => toggleVPN(),
),
MenuItem.separator(),
if (_isUserPro && _locations.isNotEmpty)
MenuItem.submenu(
key: 'select_location',
label: 'select_location'.i18n,
icon: AppImagePaths.nonProfit,
submenu: Menu(
items: _locations.map((location) {
final displayName = location.city.isNotEmpty
? '${location.country} - ${location.city}'
: location.country;
return MenuItem(
key: 'location_${location.tag}',
label: displayName,
onClick: (_) => _onLocationSelected(location),
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MenuItem.onClick is invoked from a synchronous tray event handler; returning a Future from (_) => _onLocationSelected(location) means errors can surface as unhandled async exceptions. Consider invoking the async method without returning its future and handling/logging errors within _onLocationSelected (or via an explicit fire-and-forget helper).

Suggested change
onClick: (_) => _onLocationSelected(location),
onClick: (_) {
_onLocationSelected(location).catchError((error, stackTrace) {
stderr.writeln('Error selecting location $displayName: $error');
stderr.writeln(stackTrace);
});
},

Copilot uses AI. Check for mistakes.
);
}).toList(),
),
),
if (!_isUserPro)
MenuItem(
key: 'upgrade_to_pro',
Expand Down Expand Up @@ -135,6 +232,7 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener {
: AppImagePaths.lanternDisconnected;
}

/// Tray Event Handlers
@override
Future<void> onTrayIconMouseDown() async {
if (Platform.isMacOS) {
Expand Down
Loading