diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift index 082c144..608b368 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift @@ -2,6 +2,8 @@ import VPNLib @MainActor final class PreviewFileSync: FileSyncDaemon { + var logFile: URL = .init(filePath: "~/log.txt")! + var sessionState: [VPNLib.FileSyncSession] = [] var state: DaemonState = .running @@ -10,7 +12,7 @@ final class PreviewFileSync: FileSyncDaemon { func refreshSessions() async {} - func start() async throws(DaemonError) { + func tryStart() async { state = .running } diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index 5a7257b..ff4fbe1 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift @@ -11,6 +11,8 @@ struct FileSyncConfig: View { @State private var loading: Bool = false @State private var deleteError: DaemonError? + @State private var isVisible: Bool = false + @State private var dontRetry: Bool = false var body: some View { Group { @@ -36,87 +38,140 @@ struct FileSyncConfig: View { .frame(minWidth: 400, minHeight: 200) .padding(.bottom, 25) .overlay(alignment: .bottom) { - VStack(alignment: .leading, spacing: 0) { - Divider() - HStack(spacing: 0) { - Button { - addingNewSession = true - } label: { - Image(systemName: "plus") - .frame(width: 24, height: 24) - }.disabled(vpn.menuState.agents.isEmpty) + tableFooter + } + // Only the table & footer should be disabled if the daemon has crashed + // otherwise the alert buttons will be disabled too + }.disabled(fileSync.state.isFailed) + .sheet(isPresented: $addingNewSession) { + FileSyncSessionModal() + .frame(width: 700) + }.sheet(item: $editingSession) { session in + FileSyncSessionModal(existingSession: session) + .frame(width: 700) + }.alert("Error", isPresented: Binding( + get: { deleteError != nil }, + set: { isPresented in + if !isPresented { + deleteError = nil + } + } + )) {} message: { + Text(deleteError?.description ?? "An unknown error occurred.") + }.alert("Error", isPresented: Binding( + // We only show the alert if the file config window is open + // Users will see the alert symbol on the menu bar to prompt them to + // open it. The requirement on `!loading` prevents the alert from + // re-opening immediately. + get: { !loading && isVisible && fileSync.state.isFailed }, + set: { isPresented in + if !isPresented { + if dontRetry { + dontRetry = false + return + } + loading = true + Task { + await fileSync.tryStart() + loading = false + } + } + } + )) { + Button("Retry") {} + // This gives the user an out if the daemon is crashing on launch, + // they can cancel the alert, and it will reappear if they re-open the + // file sync window. + Button("Cancel", role: .cancel) { + dontRetry = true + } + } message: { + Text(""" + File sync daemon failed. The daemon log file at\n\(fileSync.logFile.path)\nhas been opened. + """).onAppear { + // Open the log file in the default editor + NSWorkspace.shared.open(fileSync.logFile) + } + }.task { + // When the Window is visible, poll for session updates every + // two seconds. + while !Task.isCancelled { + if !fileSync.state.isFailed { + await fileSync.refreshSessions() + } + try? await Task.sleep(for: .seconds(2)) + } + }.onAppear { + isVisible = true + }.onDisappear { + isVisible = false + // If the failure alert is dismissed without restarting the daemon, + // (by clicking cancel) this makes it clear that the daemon + // is still in a failed state. + }.navigationTitle("Coder File Sync \(fileSync.state.isFailed ? "- Failed" : "")") + .disabled(loading) + } + + var tableFooter: some View { + VStack(alignment: .leading, spacing: 0) { + Divider() + HStack(spacing: 0) { + Button { + addingNewSession = true + } label: { + Image(systemName: "plus") + .frame(width: 24, height: 24) + }.disabled(vpn.menuState.agents.isEmpty) + Divider() + Button { + Task { + 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 + } + selection = nil + } + } label: { + Image(systemName: "minus").frame(width: 24, height: 24) + }.disabled(selection == nil) + if let selection { + if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) { Divider() Button { Task { + // TODO: Support pausing & resuming multiple sessions at once 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 + switch selectedSession.status { + case .paused: + try await fileSync.resumeSessions(ids: [selectedSession.id]) + default: + try await fileSync.pauseSessions(ids: [selectedSession.id]) } - selection = nil } } label: { - Image(systemName: "minus").frame(width: 24, height: 24) - }.disabled(selection == nil) - if let selection { - if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) { - Divider() - Button { - 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: - Image(systemName: "play").frame(width: 24, height: 24) - default: - Image(systemName: "pause").frame(width: 24, height: 24) - } - } + switch selectedSession.status { + case .paused: + Image(systemName: "play").frame(width: 24, height: 24) + default: + Image(systemName: "pause").frame(width: 24, height: 24) } } } - .buttonStyle(.borderless) } - .background(.primary.opacity(0.04)) - .fixedSize(horizontal: false, vertical: true) - } - }.sheet(isPresented: $addingNewSession) { - FileSyncSessionModal() - .frame(width: 700) - }.sheet(item: $editingSession) { session in - FileSyncSessionModal(existingSession: session) - .frame(width: 700) - }.alert("Error", isPresented: Binding( - get: { deleteError != nil }, - set: { isPresented in - if !isPresented { - deleteError = nil - } - } - )) {} message: { - Text(deleteError?.description ?? "An unknown error occurred.") - }.task { - while !Task.isCancelled { - await fileSync.refreshSessions() - try? await Task.sleep(for: .seconds(2)) } - }.disabled(loading) + .buttonStyle(.borderless) + } + .background(.primary.opacity(0.04)) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift index b3fa74e..207f0d9 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift @@ -68,11 +68,12 @@ struct VPNMenu: View { } label: { ButtonRowView { HStack { - // TODO: A future PR will provide users a way to recover from a daemon failure without - // needing to restart the app - if case .failed = fileSync.state, sessionsHaveError(fileSync.sessionState) { + if fileSync.state.isFailed || sessionsHaveError(fileSync.sessionState) { Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90") - .frame(width: 12, height: 12).help("One or more sync sessions have errors") + .frame(width: 12, height: 12) + .help(fileSync.state.isFailed ? + "The file sync daemon encountered an error" : + "One or more file sync sessions have errors") } Text("File sync") } diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift index cad7eac..bfae516 100644 --- a/Coder-Desktop/Coder-DesktopTests/Util.swift +++ b/Coder-Desktop/Coder-DesktopTests/Util.swift @@ -29,6 +29,8 @@ class MockVPNService: VPNService, ObservableObject { @MainActor class MockFileSyncDaemon: FileSyncDaemon { + var logFile: URL = .init(filePath: "~/log.txt") + var sessionState: [VPNLib.FileSyncSession] = [] func refreshSessions() async {} @@ -37,9 +39,7 @@ class MockFileSyncDaemon: FileSyncDaemon { var state: VPNLib.DaemonState = .running - func start() async throws(VPNLib.DaemonError) { - return - } + func tryStart() async {} func stop() async {} diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 2adce4b..1bac93c 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -10,7 +10,8 @@ import SwiftUI public protocol FileSyncDaemon: ObservableObject { var state: DaemonState { get } var sessionState: [FileSyncSession] { get } - func start() async throws(DaemonError) + var logFile: URL { get } + func tryStart() async func stop() async func refreshSessions() async func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError) @@ -43,6 +44,8 @@ public class MutagenDaemon: FileSyncDaemon { private let mutagenDataDirectory: URL private let mutagenDaemonSocket: URL + public let logFile: URL + // Managing sync sessions could take a while, especially with prompting let sessionMgmtReqTimeout: TimeAmount = .seconds(15) @@ -50,6 +53,7 @@ public class MutagenDaemon: FileSyncDaemon { var client: DaemonClient? private var group: MultiThreadedEventLoopGroup? private var channel: GRPCChannel? + private var waitForExit: (@Sendable () async -> Void)? // Protect start & stop transitions against re-entrancy private let transition = AsyncSemaphore(value: 1) @@ -63,6 +67,7 @@ public class MutagenDaemon: FileSyncDaemon { self.mutagenPath = mutagenPath self.mutagenDataDirectory = mutagenDataDirectory mutagenDaemonSocket = mutagenDataDirectory.appending(path: "daemon").appending(path: "daemon.sock") + logFile = mutagenDataDirectory.appending(path: "daemon.log") // It shouldn't be fatal if the app was built without Mutagen embedded, // but file sync will be unavailable. if mutagenPath == nil { @@ -87,33 +92,41 @@ public class MutagenDaemon: FileSyncDaemon { } } - public func start() async throws(DaemonError) { + public func tryStart() async { + if case .failed = state { state = .stopped } + do throws(DaemonError) { + try await start() + } catch { + state = .failed(error) + } + } + + func start() async throws(DaemonError) { if case .unavailable = state { return } // Stop an orphaned daemon, if there is one try? await connect() await stop() + // Creating the same process twice from Swift will crash the MainActor, + // so we need to wait for an earlier process to die + await waitForExit?() + await transition.wait() defer { transition.signal() } logger.info("starting mutagen daemon") mutagenProcess = createMutagenProcess() - // swiftlint:disable:next large_tuple - let (standardOutput, standardError, waitForExit): (Pipe.AsyncBytes, Pipe.AsyncBytes, @Sendable () async -> Void) + let (standardError, waitForExit): (Pipe.AsyncBytes, @Sendable () async -> Void) do { - (standardOutput, standardError, waitForExit) = try mutagenProcess!.run() + (_, standardError, waitForExit) = try mutagenProcess!.run() } catch { throw .daemonStartFailure(error) } + self.waitForExit = waitForExit Task { - await streamHandler(io: standardOutput) - logger.info("standard output stream closed") - } - - Task { - await streamHandler(io: standardError) + await handleDaemonLogs(io: standardError) logger.info("standard error stream closed") } @@ -256,10 +269,30 @@ public class MutagenDaemon: FileSyncDaemon { } } - private func streamHandler(io: Pipe.AsyncBytes) async { + private func handleDaemonLogs(io: Pipe.AsyncBytes) async { + if !FileManager.default.fileExists(atPath: logFile.path) { + guard FileManager.default.createFile(atPath: logFile.path, contents: nil) else { + logger.error("Failed to create log file") + return + } + } + + guard let fileHandle = try? FileHandle(forWritingTo: logFile) else { + logger.error("Failed to open log file for writing") + return + } + for await line in io.lines { logger.info("\(line, privacy: .public)") + + do { + try fileHandle.write(contentsOf: Data("\(line)\n".utf8)) + } catch { + logger.error("Failed to write to daemon log file: \(error)") + } } + + try? fileHandle.close() } } @@ -282,7 +315,7 @@ public enum DaemonState { case .stopped: "Stopped" case let .failed(error): - "Failed: \(error)" + "\(error.description)" case .unavailable: "Unavailable" } @@ -300,6 +333,15 @@ public enum DaemonState { .gray } } + + // `if case`s are a pain to work with: they're not bools (such as for ORing) + // and you can't negate them without doing `if case .. {} else`. + public var isFailed: Bool { + if case .failed = self { + return true + } + return false + } } public enum DaemonError: Error {