Repository: coder/coder-desktop-macos
Pull request #119: chore: create & delete sync sessions over gRPC
Author: login: ethanndickson
Created: 2025-03-24T07:06:09Z | Merged: 2025-03-28T02:56:21Z

Closes #63.

Linked issue #63: Add basic file sync support using GUI with manually entered paths
Add basic support to sync directories with a GUI to enter sync paths and view existing file syncs. Users must enter paths manually; no GUI at this stage.

<img width="478" alt="Image" src="https://github.com/user-attachments/assets/bc63f466-d867-4003-9dff-f51bfcb4d2a4" />

## Not in scope

 * Metadata about existing syncs like status, size, etc
 * GUI for picking folders

## Dependencies

 * coder/internal#379
 * coder/coder#16490
 * #60 
 * coder/internal#381

Diff:
diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift
index 8db30e3c..082c144f 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 dc83c17a..5a7257b0 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: {
@@ -65,7 +69,17 @@ 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 sessions at once
+                                        loading = true
+                                        defer { loading = false }
+                                        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/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
index c0c7a35b..d3981723 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.")
             }
@@ -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 e38fe330..cad7eaca 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 eafd4dc7..2adce4b2 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
@@ -41,6 +43,9 @@ public class MutagenDaemon: FileSyncDaemon {
     private let mutagenDataDirectory: URL
     private let mutagenDaemonSocket: URL
 
+    // 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?
     private var group: MultiThreadedEventLoopGroup?
@@ -75,6 +80,10 @@ public class MutagenDaemon: FileSyncDaemon {
                 return
             }
             await refreshSessions()
+            if sessionState.isEmpty {
+                logger.info("No sync sessions found on startup, stopping daemon")
+                await stop()
+            }
         }
     }
 
@@ -162,7 +171,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 +261,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 00000000..c826fa76
--- /dev/null
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
@@ -0,0 +1,120 @@
+import NIOCore
+
+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) }
+    }
+
+    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 {
+            // 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)
+        }
+        await refreshSessions()
+    }
+
+    func deleteSessions(ids: [String]) async throws(DaemonError) {
+        // 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 }
+        do {
+            _ = try await client!.sync.terminate(Synchronization_TerminateRequest.with { req in
+                req.prompter = promptID
+                req.selection = .with { selection in
+                    selection.specifications = ids
+                }
+            }, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
+        } catch {
+            throw .grpcFailure(error)
+        }
+        await refreshSessions()
+    }
+
+    func pauseSessions(ids: [String]) async throws(DaemonError) {
+        // 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 {
+            _ = try await client!.sync.pause(Synchronization_PauseRequest.with { req in
+                req.prompter = promptID
+                req.selection = .with { selection in
+                    selection.specifications = ids
+                }
+            }, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
+        } catch {
+            throw .grpcFailure(error)
+        }
+        await refreshSessions()
+    }
+
+    func resumeSessions(ids: [String]) async throws(DaemonError) {
+        // 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 {
+            _ = try await client!.sync.resume(Synchronization_ResumeRequest.with { req in
+                req.prompter = promptID
+                req.selection = .with { selection in
+                    selection.specifications = ids
+                }
+            }, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
+        } catch {
+            throw .grpcFailure(error)
+        }
+        await refreshSessions()
+    }
+}


Reviews:
user: login: ethanndickson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T13:20:22Z
user: login: ethanndickson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T13:52:48Z
user: login: deansheather
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T12:23:01Z

Review comments:
diff_hunk: @@ -162,7 +168,7 @@ public class MutagenDaemon: FileSyncDaemon {
             // Already connected
             return
         }
-        group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
+        group = MultiThreadedEventLoopGroup(numberOfThreads: 2)
path: Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
user: login: ethanndickson
body: We always have two outgoing gRPC requests at once, the prompter, and the actual request we're trying to make. This creates a second OS thread for gRPC to use.
created_at: 2025-03-24T13:20:19Z
author_association: MEMBER
diff_hunk: @@ -162,7 +168,7 @@ public class MutagenDaemon: FileSyncDaemon {
             // Already connected
             return
         }
-        group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
+        group = MultiThreadedEventLoopGroup(numberOfThreads: 2)
path: Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
user: login: ethanndickson
body: tbh 2 os threads might be overkill
created_at: 2025-03-24T13:52:48Z
author_association: MEMBER

PR comments:
user: login: ethanndickson
created_at: 2025-03-24T07:06:21Z
body: * **#126** <a href="https://app.graphite.dev/github/pr/coder/coder-desktop-macos/126?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#124** <a href="https://app.graphite.dev/github/pr/coder/coder-desktop-macos/124?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#122** <a href="https://app.graphite.dev/github/pr/coder/coder-desktop-macos/122?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#119** <a href="https://app.graphite.dev/github/pr/coder/coder-desktop-macos/119?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/coder/coder-desktop-macos/119?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#118** <a href="https://app.graphite.dev/github/pr/coder/coder-desktop-macos/118?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#117** <a href="https://app.graphite.dev/github/pr/coder/coder-desktop-macos/117?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#116** <a href="https://app.graphite.dev/github/pr/coder/coder-desktop-macos/116?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#115** <a href="https://app.graphite.dev/github/pr/coder/coder-desktop-macos/115?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: MEMBER
user: login: ethanndickson
created_at: 2025-03-28T02:47:02Z
body: ### Merge activity

* **Mar 27, 10:47 PM EDT**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/coder/coder-desktop-macos/119).
* **Mar 27, 10:54 PM EDT**: [Graphite](https://app.graphite.dev/github/pr/coder/coder-desktop-macos/119) rebased this pull request as part of a merge.
* **Mar 27, 10:56 PM EDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/coder/coder-desktop-macos/119).

author_association: MEMBER