summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2026-04-16 17:38:25 +0200
committerDavid Lönnhager <david.l@mullvad.net>2026-04-21 13:14:45 +0200
commitec5d7184949114556484a86b5b2395b7ecd80c7a (patch)
treeb7b93f17fd3f3318776eb17f1f99228fe416be66
parentb6d3f17768ffdd7ecb8ddaaed1c781cf94d75e5a (diff)
downloadmullvadvpn-ec5d7184949114556484a86b5b2395b7ecd80c7a.tar.xz
mullvadvpn-ec5d7184949114556484a86b5b2395b7ecd80c7a.zip
Fix duplicate connected/disconnected desktop notifications
The daemon sends multiple consecutive 'connected' tunnel state events (first without exit IP, then with IP from am.i.mullvad.net), which caused duplicate "Connected to ..." notifications to appear.
-rw-r--r--CHANGELOG.md2
-rw-r--r--desktop/packages/mullvad-vpn/src/main/notification-controller.ts17
-rw-r--r--desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts57
3 files changed, 76 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3dd7dbef06..9efecec0bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,8 @@ Line wrap the file at 100 chars. Th
(`After=local-fs.target`). This was assumed before, but not required (and is still not required).
### Fixed
+- Fix duplicate "Connected"/"Disconnected" desktop notifications caused by the daemon sending
+ multiple consecutive tunnel state events for the same state.
- Fix QUIC obfuscation not always being used if relays only had IPv6 addresses for QUIC.
- Fix a bug with Shadowsocks-based API access methods where some ciphers were configurable by
Mullvad VPN clients while not being supported by the system service.
diff --git a/desktop/packages/mullvad-vpn/src/main/notification-controller.ts b/desktop/packages/mullvad-vpn/src/main/notification-controller.ts
index 7480c8c579..d86f60c12c 100644
--- a/desktop/packages/mullvad-vpn/src/main/notification-controller.ts
+++ b/desktop/packages/mullvad-vpn/src/main/notification-controller.ts
@@ -55,6 +55,7 @@ enum NotificationSuppressReason {
export default class NotificationController {
private reconnecting = false;
+ private lastTunnelState?: TunnelState['state'];
private presentedNotifications: { [key: string]: boolean } = {};
private activeNotifications: Set<Notification> = new Set();
@@ -111,10 +112,16 @@ export default class NotificationController {
this.reconnecting =
tunnelState.state === 'disconnecting' && tunnelState.details === 'reconnect';
+ const suppress = this.shouldSuppressDuplicateTunnelState(tunnelState);
+ this.lastTunnelState = tunnelState.state;
+
if (notificationProvider) {
const notification = notificationProvider.getSystemNotification();
if (notification) {
+ if (suppress) {
+ return false;
+ }
return this.notify(notification, isWindowVisible, areSystemNotificationsEnabled);
} else {
log.error(
@@ -213,6 +220,16 @@ export default class NotificationController {
}
}
+ // The daemon can emit multiple consecutive events for the same tunnel state (e.g. a second
+ // 'connected' event once the exit IP becomes known). Suppress the repeat to avoid duplicate
+ // desktop notifications.
+ private shouldSuppressDuplicateTunnelState(tunnelState: TunnelState): boolean {
+ if (tunnelState.state !== this.lastTunnelState) {
+ return false;
+ }
+ return tunnelState.state === 'connected' || tunnelState.state === 'disconnected';
+ }
+
private notifyImpl(systemNotification: SystemNotification): Notification {
// Remove notifications in the same category if specified
if (systemNotification.category !== undefined) {
diff --git a/desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts b/desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts
index 9f86dc4afe..19f2362ba5 100644
--- a/desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts
@@ -109,6 +109,63 @@ describe('System notifications', () => {
expect(result).toBe(false);
});
+ it('should only show one notification when two consecutive connected events arrive', () => {
+ const controller = createController();
+
+ const connectedState: TunnelState = {
+ state: 'connected',
+ details: {
+ endpoint: {
+ address: '1.2.3.4:1234',
+ protocol: 'udp',
+ quantumResistant: false,
+ daita: false,
+ },
+ },
+ };
+
+ const result1 = controller.notifyTunnelState(connectedState, false, false, true, true);
+ const result2 = controller.notifyTunnelState(connectedState, false, false, true, true);
+
+ expect(result1).toBe(true); // first transition: notify
+ expect(result2).toBe(false); // still connected: suppress
+ });
+
+ it('should only show one notification when two consecutive disconnected events arrive', () => {
+ const controller = createController();
+
+ const disconnectedState: TunnelState = { state: 'disconnected', lockedDown: false };
+
+ const result1 = controller.notifyTunnelState(disconnectedState, false, false, true, true);
+ const result2 = controller.notifyTunnelState(disconnectedState, false, false, true, true);
+
+ expect(result1).toBe(true);
+ expect(result2).toBe(false);
+ });
+
+ it('should notify again after reconnecting', () => {
+ const controller = createController();
+
+ const connectedState: TunnelState = {
+ state: 'connected',
+ details: {
+ endpoint: {
+ address: '1.2.3.4:1234',
+ protocol: 'udp',
+ quantumResistant: false,
+ daita: false,
+ },
+ },
+ };
+ const connectingState: TunnelState = { state: 'connecting', featureIndicators: undefined };
+
+ controller.notifyTunnelState(connectedState, false, false, true, true);
+ controller.notifyTunnelState(connectingState, false, false, true, true); // leaves connected
+ const result = controller.notifyTunnelState(connectedState, false, false, true, true);
+
+ expect(result).toBe(true); // new connection: notify again
+ });
+
it('Tunnel state notifications should respect notification setting', () => {
const controller = createController();