From cb25fe59d78ee7718fe628317cf2cd84c143fcef Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Mon, 24 Mar 2025 15:36:09 +1100
Subject: [PATCH 1/6] chore: create & delete sync sessions over gRPC

---
 .../VPNLib/FileSync/FileSyncDaemon.swift      | 47 +----------
 .../VPNLib/FileSync/FileSyncManagement.swift  | 80 +++++++++++++++++++
 2 files changed, 81 insertions(+), 46 deletions(-)
 create mode 100644 Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift

diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index eafd4dc..1dd6b95 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -162,7 +162,7 @@ public class MutagenDaemon: FileSyncDaemon {
             // Already connected
             return
         }
-        group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
+        group = MultiThreadedEventLoopGroup(numberOfThreads: 2)
         do {
             channel = try GRPCChannelPool.with(
                 target: .unixDomainSocket(mutagenDaemonSocket.path),
@@ -252,51 +252,6 @@ public class MutagenDaemon: FileSyncDaemon {
             logger.info("\(line, privacy: .public)")
         }
     }
-
-    public func refreshSessions() async {
-        guard case .running = state else { return }
-        // TODO: Implement
-    }
-
-    public func createSession(
-        localPath _: String,
-        agentHost _: String,
-        remotePath _: String
-    ) async throws(DaemonError) {
-        if case .stopped = state {
-            do throws(DaemonError) {
-                try await start()
-            } catch {
-                state = .failed(error)
-                throw error
-            }
-        }
-        // TODO: Add session
-    }
-
-    public func deleteSessions(ids _: [String]) async throws(DaemonError) {
-        // TODO: Delete session
-        await stopIfNoSessions()
-    }
-
-    private func stopIfNoSessions() async {
-        let sessions: Synchronization_ListResponse
-        do {
-            sessions = try await client!.sync.list(Synchronization_ListRequest.with { req in
-                req.selection = .with { selection in
-                    selection.all = true
-                }
-            })
-        } catch {
-            state = .failed(.daemonStartFailure(error))
-            return
-        }
-        // If there's no configured sessions, the daemon doesn't need to be running
-        if sessions.sessionStates.isEmpty {
-            logger.info("No sync sessions found")
-            await stop()
-        }
-    }
 }
 
 struct DaemonClient {
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
new file mode 100644
index 0000000..e654866
--- /dev/null
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
@@ -0,0 +1,80 @@
+public extension MutagenDaemon {
+    func refreshSessions() async {
+        guard case .running = state else { return }
+        let sessions: Synchronization_ListResponse
+        do {
+            sessions = try await client!.sync.list(Synchronization_ListRequest.with { req in
+                req.selection = .with { selection in
+                    selection.all = true
+                }
+            })
+        } catch {
+            state = .failed(.grpcFailure(error))
+            return
+        }
+        sessionState = sessions.sessionStates.map { FileSyncSession(state: $0) }
+        if sessionState.isEmpty {
+            logger.info("No sync sessions found")
+            await stop()
+        }
+    }
+
+    func createSession(
+        localPath: String,
+        agentHost: String,
+        remotePath: String
+    ) async throws(DaemonError) {
+        if case .stopped = state {
+            do throws(DaemonError) {
+                try await start()
+            } catch {
+                state = .failed(error)
+                throw error
+            }
+        }
+        let (stream, promptID) = try await host()
+        defer { stream.cancel() }
+        let req = Synchronization_CreateRequest.with { req in
+            req.prompter = promptID
+            req.specification = .with { spec in
+                spec.alpha = .with { alpha in
+                    alpha.protocol = .local
+                    alpha.path = localPath
+                }
+                spec.beta = .with { beta in
+                    beta.protocol = .ssh
+                    beta.host = agentHost
+                    beta.path = remotePath
+                }
+                // TODO: Ingest a config from somewhere
+                spec.configuration = Synchronization_Configuration()
+                spec.configurationAlpha = Synchronization_Configuration()
+                spec.configurationBeta = Synchronization_Configuration()
+            }
+        }
+        do {
+            _ = try await client!.sync.create(req)
+        } catch {
+            throw .grpcFailure(error)
+        }
+        await refreshSessions()
+    }
+
+    func deleteSessions(ids: [String]) async throws(DaemonError) {
+        // Terminating sessions does not require prompting
+        let (stream, promptID) = try await host(allowPrompts: false)
+        defer { stream.cancel() }
+        guard case .running = state else { return }
+        do {
+            _ = try await client!.sync.terminate(Synchronization_TerminateRequest.with { req in
+                req.prompter = promptID
+                req.selection = .with { selection in
+                    selection.specifications = ids
+                }
+            })
+        } catch {
+            throw .grpcFailure(error)
+        }
+        await refreshSessions()
+    }
+}

From 127807063eae536e4c9c6ba253b64836ee6f35af Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Mon, 24 Mar 2025 23:32:25 +1100
Subject: [PATCH 2/6] pausing & unpausing

---
 .../Preview Content/PreviewFileSync.swift     |  4 +++
 .../Views/FileSync/FileSyncConfig.swift       | 10 +++++-
 .../VPNLib/FileSync/FileSyncDaemon.swift      |  2 ++
 .../VPNLib/FileSync/FileSyncManagement.swift  | 34 +++++++++++++++++++
 4 files changed, 49 insertions(+), 1 deletion(-)

diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift
index 8db30e3..082c144 100644
--- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift	
+++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift	
@@ -21,4 +21,8 @@ final class PreviewFileSync: FileSyncDaemon {
     func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
 
     func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
+
+    func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
+
+    func resumeSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
 }
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
index dc83c17..1abc8e8 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
@@ -65,7 +65,15 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
                             if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
                                 Divider()
                                 Button {
-                                    // TODO: Pause & Unpause
+                                    Task {
+                                        // TODO: Support pausing & resuming multiple selections
+                                        switch selectedSession.status {
+                                        case .paused:
+                                            try await fileSync.resumeSessions(ids: [selectedSession.id])
+                                        default:
+                                            try await fileSync.pauseSessions(ids: [selectedSession.id])
+                                        }
+                                    }
                                 } label: {
                                     switch selectedSession.status {
                                     case .paused:
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index 1dd6b95..641f4e5 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -15,6 +15,8 @@ public protocol FileSyncDaemon: ObservableObject {
     func refreshSessions() async
     func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError)
     func deleteSessions(ids: [String]) async throws(DaemonError)
+    func pauseSessions(ids: [String]) async throws(DaemonError)
+    func resumeSessions(ids: [String]) async throws(DaemonError)
 }
 
 @MainActor
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
index e654866..1bc4c0c 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
@@ -77,4 +77,38 @@ public extension MutagenDaemon {
         }
         await refreshSessions()
     }
+
+    func pauseSessions(ids: [String]) async throws(DaemonError) {
+        let (stream, promptID) = try await host()
+        defer { stream.cancel() }
+        guard case .running = state else { return }
+        do {
+            _ = try await client!.sync.pause(Synchronization_PauseRequest.with { req in
+                req.prompter = promptID
+                req.selection = .with { selection in
+                    selection.specifications = ids
+                }
+            })
+        } catch {
+            throw .grpcFailure(error)
+        }
+        await refreshSessions()
+    }
+
+    func resumeSessions(ids: [String]) async throws(DaemonError) {
+        let (stream, promptID) = try await host()
+        defer { stream.cancel() }
+        guard case .running = state else { return }
+        do {
+            _ = try await client!.sync.resume(Synchronization_ResumeRequest.with { req in
+                req.prompter = promptID
+                req.selection = .with { selection in
+                    selection.specifications = ids
+                }
+            })
+        } catch {
+            throw .grpcFailure(error)
+        }
+        await refreshSessions()
+    }
 }

From dfd1bc8eaf3f2207895fdaa1f5d58a67e61a5229 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Mon, 24 Mar 2025 23:34:51 +1100
Subject: [PATCH 3/6] fixup

---
 .../Views/FileSync/FileSyncConfig.swift           | 10 ++++++++--
 .../Views/FileSync/FileSyncSessionModal.swift     |  1 -
 Coder-Desktop/Coder-DesktopTests/Util.swift       |  4 ++++
 .../VPNLib/FileSync/FileSyncDaemon.swift          |  4 ++++
 .../VPNLib/FileSync/FileSyncManagement.swift      | 15 ++++++++-------
 5 files changed, 24 insertions(+), 10 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
index 1abc8e8..5a7257b 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
@@ -51,11 +51,15 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
                                 loading = true
                                 defer { loading = false }
                                 do throws(DaemonError) {
+                                    // TODO: Support selecting & deleting multiple sessions at once
                                     try await fileSync.deleteSessions(ids: [selection!])
+                                    if fileSync.sessionState.isEmpty {
+                                        // Last session was deleted, stop the daemon
+                                        await fileSync.stop()
+                                    }
                                 } catch {
                                     deleteError = error
                                 }
-                                await fileSync.refreshSessions()
                                 selection = nil
                             }
                         } label: {
@@ -66,7 +70,9 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
                                 Divider()
                                 Button {
                                     Task {
-                                        // TODO: Support pausing & resuming multiple selections
+                                        // TODO: Support pausing & resuming multiple sessions at once
+                                        loading = true
+                                        defer { loading = false }
                                         switch selectedSession.status {
                                         case .paused:
                                             try await fileSync.resumeSessions(ids: [selectedSession.id])
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
index c0c7a35..2539e9d 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
@@ -83,7 +83,6 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
         defer { loading = false }
         do throws(DaemonError) {
             if let existingSession {
-                // TODO: Support selecting & deleting multiple sessions at once
                 try await fileSync.deleteSessions(ids: [existingSession.id])
             }
             try await fileSync.createSession(
diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift
index e38fe33..cad7eac 100644
--- a/Coder-Desktop/Coder-DesktopTests/Util.swift
+++ b/Coder-Desktop/Coder-DesktopTests/Util.swift
@@ -48,6 +48,10 @@ class MockFileSyncDaemon: FileSyncDaemon {
     }
 
     func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
+
+    func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
+
+    func resumeSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
 }
 
 extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {}
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index 641f4e5..4fa7611 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -77,6 +77,10 @@ public class MutagenDaemon: FileSyncDaemon {
                 return
             }
             await refreshSessions()
+            if sessionState.isEmpty {
+                logger.info("No sync sessions found on startup, stopping daemon")
+                await stop()
+            }
         }
     }
 
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
index 1bc4c0c..8c4eb0c 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
@@ -13,10 +13,6 @@ public extension MutagenDaemon {
             return
         }
         sessionState = sessions.sessionStates.map { FileSyncSession(state: $0) }
-        if sessionState.isEmpty {
-            logger.info("No sync sessions found")
-            await stop()
-        }
     }
 
     func createSession(
@@ -61,7 +57,8 @@ public extension MutagenDaemon {
     }
 
     func deleteSessions(ids: [String]) async throws(DaemonError) {
-        // Terminating sessions does not require prompting
+        // Terminating sessions does not require prompting, according to the
+        // Mutagen CLI
         let (stream, promptID) = try await host(allowPrompts: false)
         defer { stream.cancel() }
         guard case .running = state else { return }
@@ -79,7 +76,9 @@ public extension MutagenDaemon {
     }
 
     func pauseSessions(ids: [String]) async throws(DaemonError) {
-        let (stream, promptID) = try await host()
+        // Pausing sessions does not require prompting, according to the
+        // Mutagen CLI
+        let (stream, promptID) = try await host(allowPrompts: false)
         defer { stream.cancel() }
         guard case .running = state else { return }
         do {
@@ -96,7 +95,9 @@ public extension MutagenDaemon {
     }
 
     func resumeSessions(ids: [String]) async throws(DaemonError) {
-        let (stream, promptID) = try await host()
+        // Resuming sessions does not require prompting, according to the
+        // Mutagen CLI
+        let (stream, promptID) = try await host(allowPrompts: false)
         defer { stream.cancel() }
         guard case .running = state else { return }
         do {

From 1b6444429a3f299bbf6270d3c60610bceb129eb7 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Tue, 25 Mar 2025 16:53:21 +1100
Subject: [PATCH 4/6] set generous timeouts on session requests

---
 Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift     |  3 +++
 Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift | 10 ++++++----
 2 files changed, 9 insertions(+), 4 deletions(-)

diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index 4fa7611..11b42af 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -43,6 +43,9 @@ public class MutagenDaemon: FileSyncDaemon {
     private let mutagenDataDirectory: URL
     private let mutagenDaemonSocket: URL
 
+    // Managing sync sessions can take a while, especially with prompting
+    let sessionMgmtReqTimeout: TimeAmount = .seconds(5)
+
     // Non-nil when the daemon is running
     var client: DaemonClient?
     private var group: MultiThreadedEventLoopGroup?
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
index 8c4eb0c..1be95a6 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
@@ -1,3 +1,5 @@
+import NIOCore
+
 public extension MutagenDaemon {
     func refreshSessions() async {
         guard case .running = state else { return }
@@ -49,7 +51,7 @@ public extension MutagenDaemon {
             }
         }
         do {
-            _ = try await client!.sync.create(req)
+            _ = try await client!.sync.create(req, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
         } catch {
             throw .grpcFailure(error)
         }
@@ -68,7 +70,7 @@ public extension MutagenDaemon {
                 req.selection = .with { selection in
                     selection.specifications = ids
                 }
-            })
+            }, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
         } catch {
             throw .grpcFailure(error)
         }
@@ -87,7 +89,7 @@ public extension MutagenDaemon {
                 req.selection = .with { selection in
                     selection.specifications = ids
                 }
-            })
+            }, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
         } catch {
             throw .grpcFailure(error)
         }
@@ -106,7 +108,7 @@ public extension MutagenDaemon {
                 req.selection = .with { selection in
                     selection.specifications = ids
                 }
-            })
+            }, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
         } catch {
             throw .grpcFailure(error)
         }

From 91a5b36307f4327ddf55ea95213f90fef0bfbcf7 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Wed, 26 Mar 2025 23:26:22 +1100
Subject: [PATCH 5/6] very important fix

---
 .../Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift     | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
index 2539e9d..d398172 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
@@ -68,7 +68,7 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
         }.disabled(loading)
             .alert("Error", isPresented: Binding(
                 get: { createError != nil },
-                set: { if $0 { createError = nil } }
+                set: { if !$0 { createError = nil } }
             )) {} message: {
                 Text(createError?.description ?? "An unknown error occurred.")
             }

From 58f9775efba9e7b229034fa99f7a827d52c6c3eb Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Wed, 26 Mar 2025 23:43:26 +1100
Subject: [PATCH 6/6] bump timeouts

---
 Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift     | 4 ++--
 Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift | 5 ++++-
 2 files changed, 6 insertions(+), 3 deletions(-)

diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index 11b42af..2adce4b 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -43,8 +43,8 @@ public class MutagenDaemon: FileSyncDaemon {
     private let mutagenDataDirectory: URL
     private let mutagenDaemonSocket: URL
 
-    // Managing sync sessions can take a while, especially with prompting
-    let sessionMgmtReqTimeout: TimeAmount = .seconds(5)
+    // Managing sync sessions could take a while, especially with prompting
+    let sessionMgmtReqTimeout: TimeAmount = .seconds(15)
 
     // Non-nil when the daemon is running
     var client: DaemonClient?
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
index 1be95a6..c826fa7 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
@@ -51,7 +51,10 @@ public extension MutagenDaemon {
             }
         }
         do {
-            _ = try await client!.sync.create(req, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
+            // The first creation will need to transfer the agent binary
+            // TODO: Because this is pretty long, we should show progress updates
+            // using the prompter messages
+            _ = try await client!.sync.create(req, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout * 4)))
         } catch {
             throw .grpcFailure(error)
         }