From c9e297e5c521dc8081ad6ea8a1e86d7d9e174818 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Wed, 19 Mar 2025 13:53:44 +1100
Subject: [PATCH] fix: start coder connect on launch after SE is installed

---
 .../Coder-Desktop/Coder_DesktopApp.swift      | 11 +++++++---
 .../Coder-Desktop/VPN/VPNService.swift        | 22 ++++++++++++++-----
 .../Coder-DesktopTests/LoginFormTests.swift   |  6 +++++
 3 files changed, 31 insertions(+), 8 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index a8d0c946..091a1c25 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -37,6 +37,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
         vpn = CoderVPNService()
         state = AppState(onChange: vpn.configureTunnelProviderProtocol)
         fileSyncDaemon = MutagenDaemon()
+        if state.startVPNOnLaunch {
+            vpn.startWhenReady = true
+        }
+        vpn.installSystemExtension()
     }
 
     func applicationDidFinishLaunching(_: Notification) {
@@ -68,9 +72,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
             if await !vpn.loadNetworkExtensionConfig() {
                 state.reconfigure()
             }
-            if state.startVPNOnLaunch {
-                await vpn.start()
-            }
         }
         // TODO: Start the daemon only once a file sync is configured
         Task {
@@ -78,6 +79,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
         }
     }
 
+    deinit {
+        NotificationCenter.default.removeObserver(self)
+    }
+
     // This function MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)`
     // or return `.terminateNow`
     func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply {
diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
index ca0a8ff3..22a3ad8b 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
@@ -18,6 +18,16 @@ enum VPNServiceState: Equatable {
     case disconnecting
     case connected
     case failed(VPNServiceError)
+
+    var canBeStarted: Bool {
+        switch self {
+        // A tunnel failure should not prevent a reconnect attempt
+        case .disabled, .failed:
+            true
+        default:
+            false
+        }
+    }
 }
 
 enum VPNServiceError: Error, Equatable {
@@ -54,11 +64,18 @@ final class CoderVPNService: NSObject, VPNService {
         guard neState == .enabled || neState == .disabled else {
             return .failed(.networkExtensionError(neState))
         }
+        if startWhenReady, tunnelState.canBeStarted {
+            startWhenReady = false
+            Task { await start() }
+        }
         return tunnelState
     }
 
     @Published var menuState: VPNMenuState = .init()
 
+    // Whether the VPN should start as soon as possible
+    var startWhenReady: Bool = false
+
     // systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get
     // garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework
     // only stores a weak reference to the delegate.
@@ -68,11 +85,6 @@ final class CoderVPNService: NSObject, VPNService {
 
     override init() {
         super.init()
-        installSystemExtension()
-    }
-
-    deinit {
-        NotificationCenter.default.removeObserver(self)
     }
 
     func start() async {
diff --git a/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift b/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift
index a07ced3f..26f5883d 100644
--- a/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift
@@ -107,6 +107,12 @@ struct LoginTests {
             data: [.get: Client.encoder.encode(buildInfo)]
         ).register()
 
+        try Mock(
+            url: url.appendingPathComponent("/api/v2/users/me"),
+            statusCode: 200,
+            data: [.get: Client.encoder.encode(User(id: UUID(), username: "username"))]
+        ).register()
+
         try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 try view.find(ViewType.TextField.self).setInput(url.absoluteString)