diff options
| author | David Lönnhager <david.l@mullvad.net> | 2026-04-16 17:38:25 +0200 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2026-04-21 13:14:45 +0200 |
| commit | ec5d7184949114556484a86b5b2395b7ecd80c7a (patch) | |
| tree | b7b93f17fd3f3318776eb17f1f99228fe416be66 | |
| parent | b6d3f17768ffdd7ecb8ddaaed1c781cf94d75e5a (diff) | |
| download | mullvadvpn-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.md | 2 | ||||
| -rw-r--r-- | desktop/packages/mullvad-vpn/src/main/notification-controller.ts | 17 | ||||
| -rw-r--r-- | desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts | 57 |
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(); |
