diff --git a/SimplyTrack/Services/Browsers/ArcBrowser.swift b/SimplyTrack/Services/Browsers/ArcBrowser.swift index 6c67675..8df45eb 100644 --- a/SimplyTrack/Services/Browsers/ArcBrowser.swift +++ b/SimplyTrack/Services/Browsers/ArcBrowser.swift @@ -39,8 +39,8 @@ class ArcBrowser: BaseBrowser { /// Checks if Arc is currently in incognito mode. /// Uses the 'incognito' property available in Arc's AppleScript interface. /// Note: Permissions are already verified by getCurrentURL() call, so no need to re-check. - /// - Returns: true if incognito mode is detected, false otherwise - override func isInPrivateBrowsingMode() -> Bool { + /// - Returns: true if incognito mode is detected, false if regular browsing is detected, nil if detection failed + override func isInPrivateBrowsingMode() -> Bool? { let script = """ tell application "Arc" if (count of windows) > 0 then @@ -56,7 +56,7 @@ class ArcBrowser: BaseBrowser { guard let result = scriptResult.result, let isIncognito = Bool(result.lowercased()) else { - return false + return nil } return isIncognito diff --git a/SimplyTrack/Services/Browsers/BraveBrowser.swift b/SimplyTrack/Services/Browsers/BraveBrowser.swift index 3ca8f11..0a2b3cc 100644 --- a/SimplyTrack/Services/Browsers/BraveBrowser.swift +++ b/SimplyTrack/Services/Browsers/BraveBrowser.swift @@ -29,8 +29,8 @@ class BraveBrowser: BaseBrowser { /// Checks if Brave is currently in incognito mode. /// Uses the 'mode' property available in Brave's AppleScript interface (same as Chrome). /// Note: Permissions are already verified by getCurrentURL() call, so no need to re-check. - /// - Returns: true if incognito mode is detected, false otherwise - override func isInPrivateBrowsingMode() -> Bool { + /// - Returns: true if incognito mode is detected, false if regular browsing is detected, nil if detection failed + override func isInPrivateBrowsingMode() -> Bool? { let script = """ tell application "Brave Browser" if (count of windows) > 0 then @@ -44,7 +44,7 @@ class BraveBrowser: BaseBrowser { guard let result = scriptResult.result, let isIncognito = Bool(result.lowercased()) else { - return false + return nil } return isIncognito diff --git a/SimplyTrack/Services/Browsers/BrowserInterface.swift b/SimplyTrack/Services/Browsers/BrowserInterface.swift index 1e37cca..4cd4715 100644 --- a/SimplyTrack/Services/Browsers/BrowserInterface.swift +++ b/SimplyTrack/Services/Browsers/BrowserInterface.swift @@ -29,13 +29,15 @@ protocol BrowserInterface { func getCurrentURL() -> String? /// Checks if the current active tab/window is in private browsing mode. - /// - Returns: true if private browsing is detected, false otherwise - func isInPrivateBrowsingMode() -> Bool + /// - Returns: true if private browsing is detected, false if regular browsing is detected, nil if detection failed + func isInPrivateBrowsingMode() -> Bool? } /// Base implementation providing common AppleScript execution functionality. /// Browser-specific classes can inherit from this to get shared AppleScript execution logic. class BaseBrowser: BrowserInterface { + private static let appleScriptTimeoutSeconds = 3 + let bundleId: String let displayName: String @@ -60,6 +62,8 @@ class BaseBrowser: BrowserInterface { // Handle permission-related errors if scriptResult.errorCode == -1743 || scriptResult.errorCode == -1744 { PermissionManager.shared.handleBrowserPermissionResult(success: false) + } else if scriptResult.errorCode == -1712 { + logger.debug("Browser (\(self.displayName)) AppleScript timed out") } else if scriptResult.errorCode == -1719 { // Invalid index - transient race condition when tabs/windows change during polling. // Silently ignore; the next polling cycle will succeed. @@ -85,7 +89,7 @@ class BaseBrowser: BrowserInterface { } /// Default implementation - subclasses should override - func isInPrivateBrowsingMode() -> Bool { + func isInPrivateBrowsingMode() -> Bool? { fatalError("Subclasses must implement isInPrivateBrowsingMode()") } @@ -95,7 +99,12 @@ class BaseBrowser: BrowserInterface { /// - Returns: AppleScriptResult containing the result, error code, and error details internal func executeAppleScript(_ script: String) -> AppleScriptResult { var error: NSDictionary? - let appleScript = NSAppleScript(source: script) + let wrappedScript = """ + with timeout of \(Self.appleScriptTimeoutSeconds) seconds + \(script) + end timeout + """ + let appleScript = NSAppleScript(source: wrappedScript) let result = appleScript?.executeAndReturnError(&error) let errorCode = error?["NSAppleScriptErrorNumber"] as? Int diff --git a/SimplyTrack/Services/Browsers/ChromeBrowser.swift b/SimplyTrack/Services/Browsers/ChromeBrowser.swift index d07e792..21a073e 100644 --- a/SimplyTrack/Services/Browsers/ChromeBrowser.swift +++ b/SimplyTrack/Services/Browsers/ChromeBrowser.swift @@ -30,8 +30,8 @@ class ChromeBrowser: BaseBrowser { /// Checks if Chrome is currently in incognito mode. /// Uses the reliable 'mode' property available in Chrome's AppleScript interface. /// Note: Permissions are already verified by getCurrentURL() call, so no need to re-check. - /// - Returns: true if incognito mode is detected, false otherwise - override func isInPrivateBrowsingMode() -> Bool { + /// - Returns: true if incognito mode is detected, false if regular browsing is detected, nil if detection failed + override func isInPrivateBrowsingMode() -> Bool? { let script = """ tell application "Google Chrome" if (count of windows) > 0 then @@ -45,7 +45,7 @@ class ChromeBrowser: BaseBrowser { guard let result = scriptResult.result, let isIncognito = Bool(result.lowercased()) else { - return false + return nil } return isIncognito diff --git a/SimplyTrack/Services/Browsers/EdgeBrowser.swift b/SimplyTrack/Services/Browsers/EdgeBrowser.swift index e8ca090..afccef3 100644 --- a/SimplyTrack/Services/Browsers/EdgeBrowser.swift +++ b/SimplyTrack/Services/Browsers/EdgeBrowser.swift @@ -30,8 +30,8 @@ class EdgeBrowser: BaseBrowser { /// Checks if Edge is currently in InPrivate mode. /// Uses the 'mode' property similar to Chrome, since Edge is Chromium-based. /// Note: Permissions are already verified by getCurrentURL() call, so no need to re-check. - /// - Returns: true if InPrivate mode is detected, false otherwise - override func isInPrivateBrowsingMode() -> Bool { + /// - Returns: true if InPrivate mode is detected, false if regular browsing is detected, nil if detection failed + override func isInPrivateBrowsingMode() -> Bool? { let script = """ tell application "Microsoft Edge" if (count of windows) > 0 then @@ -45,7 +45,7 @@ class EdgeBrowser: BaseBrowser { guard let result = scriptResult.result, let isInPrivate = Bool(result.lowercased()) else { - return false + return nil } return isInPrivate diff --git a/SimplyTrack/Services/Browsers/FirefoxBrowser.swift b/SimplyTrack/Services/Browsers/FirefoxBrowser.swift index 1f01a1f..b4bac7c 100644 --- a/SimplyTrack/Services/Browsers/FirefoxBrowser.swift +++ b/SimplyTrack/Services/Browsers/FirefoxBrowser.swift @@ -91,6 +91,8 @@ class FirefoxBrowser: BaseBrowser { PermissionManager.shared.handleBrowserError( "Firefox requires additional setup for website tracking: open about:config in Firefox and set accessibility.force_disabled to -1." ) + } else if scriptResult.errorCode == -1712 { + logger.debug("Firefox Accessibility AppleScript timed out") } else if scriptResult.errorCode == -1743 || scriptResult.errorCode == -1744 { PermissionManager.shared.handleSystemEventsPermissionResult(success: false) } else if scriptResult.errorCode == -1719 || scriptResult.errorCode == -1728 { @@ -126,8 +128,8 @@ class FirefoxBrowser: BaseBrowser { /// Checks if Firefox is currently in private browsing mode. /// Firefox appends a localized private browsing indicator to the window title. - /// - Returns: true if private browsing is detected, false otherwise - override func isInPrivateBrowsingMode() -> Bool { + /// - Returns: true if private browsing is detected, false if regular browsing is detected, nil if detection failed + override func isInPrivateBrowsingMode() -> Bool? { let script = """ tell application "Firefox" to return name of front window """ @@ -135,7 +137,7 @@ class FirefoxBrowser: BaseBrowser { let scriptResult = executeAppleScript(script) guard let windowName = scriptResult.result else { - return false + return nil } return windowName.hasSuffix(Self.privateBrowsingSuffix) diff --git a/SimplyTrack/Services/Browsers/SafariBrowser.swift b/SimplyTrack/Services/Browsers/SafariBrowser.swift index 14305ce..5e582fd 100644 --- a/SimplyTrack/Services/Browsers/SafariBrowser.swift +++ b/SimplyTrack/Services/Browsers/SafariBrowser.swift @@ -32,8 +32,8 @@ class SafariBrowser: BaseBrowser { /// Checks if Safari is currently in private browsing mode. /// Uses System Events to check for private window menu item. - /// - Returns: true if private browsing is detected, false otherwise - override func isInPrivateBrowsingMode() -> Bool { + /// - Returns: true if private browsing is detected, false if regular browsing is detected, nil if detection failed + override func isInPrivateBrowsingMode() -> Bool? { let systemEventsScript = """ tell application "System Events" tell process "Safari" @@ -53,7 +53,10 @@ class SafariBrowser: BaseBrowser { // Invalid index - transient race condition when menu items change during polling. // Silently ignore; the next polling cycle will succeed. logger.debug("Safari System Events transient error (invalid index): \(error.description)") - return false + return nil + } else if scriptResult.errorCode == -1712 { + logger.debug("Safari System Events AppleScript timed out") + return nil } else if scriptResult.errorCode == -1743 || scriptResult.errorCode == -1744 { // System Events permission errors PermissionManager.shared.handleSystemEventsPermissionResult(success: false) @@ -61,7 +64,7 @@ class SafariBrowser: BaseBrowser { // Log non-permission System Events errors logger.error("Safari System Events AppleScript error: \(error.description)") } - return false + return nil } // If we successfully executed System Events AppleScript, permissions are working @@ -74,6 +77,6 @@ class SafariBrowser: BaseBrowser { return isPrivate } - return false + return nil } } diff --git a/SimplyTrack/Services/Browsers/VivaldiBrowser.swift b/SimplyTrack/Services/Browsers/VivaldiBrowser.swift index 2b34033..2bd93f1 100644 --- a/SimplyTrack/Services/Browsers/VivaldiBrowser.swift +++ b/SimplyTrack/Services/Browsers/VivaldiBrowser.swift @@ -29,8 +29,8 @@ class VivaldiBrowser: BaseBrowser { /// Checks if Vivaldi is currently in private mode. /// Uses the 'mode' property available in Chromium-based browser AppleScript interfaces. /// Note: Permissions are already verified by getCurrentURL() call, so no need to re-check. - /// - Returns: true if private mode is detected, false otherwise - override func isInPrivateBrowsingMode() -> Bool { + /// - Returns: true if private mode is detected, false if regular browsing is detected, nil if detection failed + override func isInPrivateBrowsingMode() -> Bool? { let script = """ tell application id "com.vivaldi.Vivaldi" if (count of windows) > 0 then @@ -44,7 +44,7 @@ class VivaldiBrowser: BaseBrowser { guard let result = scriptResult.result, let isPrivate = Bool(result.lowercased()) else { - return false + return nil } return isPrivate diff --git a/SimplyTrack/Services/TrackingService.swift b/SimplyTrack/Services/TrackingService.swift index 3ff36f2..653bf15 100644 --- a/SimplyTrack/Services/TrackingService.swift +++ b/SimplyTrack/Services/TrackingService.swift @@ -47,6 +47,21 @@ class TrackingService { private var currentAppSession: UsageSession? private var currentWebsiteSession: UsageSession? + private var isWebsitePollInFlight = false + private var needsWebsitePollAfterCurrent = false + private var websitePollStartedAt: Date? + private var consecutiveWebsitePollFailures = 0 + private var lastSuccessfulWebsitePollAt: Date? + private var activeWebsitePollTasks = 0 + private var websitePollGeneration = 0 + private var websitePollCooldownUntil: Date? + private var websiteIconFetchesInFlight: Set = [] + + private static let websitePollGraceInterval: TimeInterval = 8.0 + private static let websitePollStaleInterval: TimeInterval = 10.0 + private static let websitePollCooldownInterval: TimeInterval = 5.0 + private static let websitePollFailureThreshold = 3 + private static let maxActiveWebsitePollTasks = 2 // MARK: - Icon Cache @@ -174,34 +189,113 @@ class TrackingService { updateAppSession(identifier: activeBundleId, name: activeName, now: now) } - // Track website usage asynchronously to avoid blocking app tracking + scheduleWebsitePoll(now: now) + } + + private func scheduleWebsitePoll(now: Date) { + if let websitePollCooldownUntil, now < websitePollCooldownUntil { + maybeEndWebsiteSessionAfterGrace(now: now) + return + } + + if isWebsitePollInFlight { + needsWebsitePollAfterCurrent = true + maybeHandleStaleWebsitePoll(now: now) + return + } + + guard activeWebsitePollTasks < Self.maxActiveWebsitePollTasks else { + maybeEndWebsiteSessionAfterGrace(now: now) + return + } + + isWebsitePollInFlight = true + websitePollStartedAt = now + activeWebsitePollTasks += 1 + websitePollGeneration += 1 + let pollGeneration = websitePollGeneration + Task { - if let websiteData = await webTrackingService.getCurrentWebsiteData() { - await MainActor.run { - // Queue website favicon if available - if let iconData = websiteData.iconData { - sessionPersistenceService.queueIconData( - identifier: websiteData.domain, - iconData: iconData - ) - } - - // Update or create website usage session - updateWebsiteSession( - identifier: websiteData.domain, - name: websiteData.domain, - now: now - ) - } - } else { - // No website detected, end current website session - await MainActor.run { - endWebsiteSession() + let websiteInfo = webTrackingService.getCurrentWebsiteInfo() + await MainActor.run { + self.finishWebsitePoll(websiteInfo: websiteInfo, generation: pollGeneration, now: Date()) + } + } + } + + private func finishWebsitePoll(websiteInfo: (domain: String, url: String)?, generation: Int, now: Date) { + activeWebsitePollTasks = max(0, activeWebsitePollTasks - 1) + guard generation == websitePollGeneration else { return } + + isWebsitePollInFlight = false + websitePollStartedAt = nil + + if let websiteInfo { + consecutiveWebsitePollFailures = 0 + lastSuccessfulWebsitePollAt = now + + updateWebsiteSession( + identifier: websiteInfo.domain, + name: websiteInfo.domain, + now: now + ) + scheduleWebsiteIconFetch(domain: websiteInfo.domain, sourceURL: websiteInfo.url) + } else { + consecutiveWebsitePollFailures += 1 + maybeEndWebsiteSessionAfterGrace(now: now) + } + + if needsWebsitePollAfterCurrent { + needsWebsitePollAfterCurrent = false + scheduleWebsitePoll(now: now) + } + } + + private func maybeHandleStaleWebsitePoll(now: Date) { + guard let startedAt = websitePollStartedAt else { return } + guard now.timeIntervalSince(startedAt) >= Self.websitePollStaleInterval else { return } + + logger.warning("Website poll stale; entering cooldown before recovery") + isWebsitePollInFlight = false + websitePollStartedAt = nil + needsWebsitePollAfterCurrent = false + consecutiveWebsitePollFailures += 1 + websitePollGeneration += 1 + websitePollCooldownUntil = now.addingTimeInterval(Self.websitePollCooldownInterval) + maybeEndWebsiteSessionAfterGrace(now: now) + } + + private func scheduleWebsiteIconFetch(domain: String, sourceURL: String) { + guard !websiteIconFetchesInFlight.contains(domain) else { return } + + websiteIconFetchesInFlight.insert(domain) + Task { + let iconData = await webTrackingService.getFaviconData(for: domain, sourceURL: sourceURL) + await MainActor.run { + self.websiteIconFetchesInFlight.remove(domain) + if let iconData { + self.sessionPersistenceService.queueIconData(identifier: domain, iconData: iconData) } } } } + private func maybeEndWebsiteSessionAfterGrace(now: Date) { + if consecutiveWebsitePollFailures >= Self.websitePollFailureThreshold { + endWebsiteSession() + return + } + + guard let lastSuccessfulWebsitePollAt else { + endWebsiteSession() + return + } + + if now.timeIntervalSince(lastSuccessfulWebsitePollAt) >= Self.websitePollGraceInterval { + endWebsiteSession() + } + } + // MARK: - Session Management private func updateAppSession(identifier: String, name: String, now: Date) { @@ -254,6 +348,7 @@ class TrackingService { /// Public method that can be called by AppDelegate during app termination. func endAllActiveSessions() async { let now = Date() + invalidateCurrentWebsitePoll() // End app session if active if let appSession = currentAppSession { @@ -270,6 +365,16 @@ class TrackingService { } } + private func invalidateCurrentWebsitePoll() { + websitePollGeneration += 1 + isWebsitePollInFlight = false + needsWebsitePollAfterCurrent = false + websitePollStartedAt = nil + websitePollCooldownUntil = nil + consecutiveWebsitePollFailures = 0 + lastSuccessfulWebsitePollAt = nil + } + // MARK: - Idle Detection private func getSystemIdleTime() -> TimeInterval { diff --git a/SimplyTrack/Services/WebTrackingService.swift b/SimplyTrack/Services/WebTrackingService.swift index 19418ef..cac6f7b 100644 --- a/SimplyTrack/Services/WebTrackingService.swift +++ b/SimplyTrack/Services/WebTrackingService.swift @@ -87,6 +87,17 @@ class WebTrackingService { /// Downloads and caches favicon if not already available. /// - Returns: Tuple containing domain and favicon data, nil if no website detected func getCurrentWebsiteData() async -> (domain: String, iconData: Data?)? { + guard let websiteInfo = getCurrentWebsiteInfo() else { + return nil + } + + let iconData = await getFaviconData(for: websiteInfo.domain, sourceURL: websiteInfo.url) + return (domain: websiteInfo.domain, iconData: iconData) + } + + /// Gets the current website domain and source URL without fetching favicon data. + /// - Returns: Tuple containing domain and URL, nil if no website detected + func getCurrentWebsiteInfo() -> (domain: String, url: String)? { guard let websiteInfo = getCurrentWebsiteWithBrowser() else { return nil } @@ -94,8 +105,7 @@ class WebTrackingService { let domain = extractDomain(from: websiteInfo.url) guard !domain.isEmpty else { return nil } - let iconData = await getFaviconData(for: domain, sourceURL: websiteInfo.url) - return (domain: domain, iconData: iconData) + return (domain: domain, url: websiteInfo.url) } /// Gets the current website URL along with the browser bundle identifier. @@ -110,9 +120,12 @@ class WebTrackingService { return nil } - // Check if private browsing is active and tracking is disabled - if !trackPrivateBrowsing && browser.isInPrivateBrowsingMode() { - return nil + // Check if private browsing is active and tracking is disabled. + // Detection can fail transiently, especially for Safari via System Events; only block on confirmed private mode. + if !trackPrivateBrowsing { + if browser.isInPrivateBrowsingMode() == true { + return nil + } } guard let url = browser.getCurrentURL() else { return nil } @@ -152,7 +165,7 @@ class WebTrackingService { // MARK: - Website Icon Fetching - private func getFaviconData(for domain: String, sourceURL: String) async -> Data? { + func getFaviconData(for domain: String, sourceURL: String) async -> Data? { // Thread-safe cache read if let cachedData = await faviconCacheActor.get(domain) { return cachedData diff --git a/SimplyTrackMCP/IPCClient.swift b/SimplyTrackMCP/IPCClient.swift index 4aa8dd6..ca0c439 100644 --- a/SimplyTrackMCP/IPCClient.swift +++ b/SimplyTrackMCP/IPCClient.swift @@ -92,6 +92,7 @@ private final class IPCProtocolCodec: ByteToMessageDecoder, MessageToByteEncoder /// Swift-NIO client for communicating with SimplyTrack main app actor IPCClient { private let socketPath: String + private static let requestTimeout: TimeAmount = .seconds(10) private let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) init(socketPath: String) { @@ -147,13 +148,20 @@ actor IPCClient { let clientSocketPath = self.socketPath // Capture socket path before closure return try await withCheckedThrowingContinuation { continuation in + let responseState = IPCResponseState(continuation: continuation) + let timeoutTask = eventLoopGroup.next().scheduleTask(in: Self.requestTimeout) { + let timeoutError = NSError(domain: "IPCClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "IPC request timed out after 10 seconds"]) + responseState.fail(timeoutError, closeChannel: true) + } + responseState.setTimeoutTask(timeoutTask) + let bootstrap = ClientBootstrap(group: eventLoopGroup) .channelOption(.socketOption(.so_reuseaddr), value: 1) .channelInitializer { channel in channel.pipeline.addHandlers([ ByteToMessageHandler(IPCProtocolCodec()) as ChannelHandler, MessageToByteHandler(IPCProtocolCodec()) as ChannelHandler, - IPCClientHandler(continuation: continuation), + IPCClientHandler(responseState: responseState), ]) } @@ -161,6 +169,8 @@ actor IPCClient { bootstrap.connect(unixDomainSocketPath: clientSocketPath).whenComplete { result in switch result { case .success(let channel): + guard responseState.setChannel(channel) else { return } + // Create request message let requestMessage = IPCMessage(type: type, body: body) @@ -171,8 +181,7 @@ actor IPCClient { // Message sent successfully, response will be handled by IPCClientHandler break case .failure(let error): - continuation.resume(throwing: error) - _ = channel.close() + responseState.fail(error, closeChannel: true) } } case .failure(let error): @@ -182,9 +191,7 @@ actor IPCClient { } else { errorMessage = "Connection failed: \(error.localizedDescription). Is SimplyTrack app running?" } - continuation.resume( - throwing: NSError(domain: "IPCClient", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMessage]) - ) + responseState.fail(NSError(domain: "IPCClient", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMessage]), closeChannel: false) } } @@ -192,31 +199,96 @@ actor IPCClient { } } +// MARK: - Client Response State + +private final class IPCResponseState: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var channel: Channel? + private var timeoutTask: Scheduled? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func setChannel(_ channel: Channel) -> Bool { + lock.lock() + guard continuation != nil else { + lock.unlock() + channel.close(promise: nil) + return false + } + self.channel = channel + lock.unlock() + return true + } + + func setTimeoutTask(_ task: Scheduled) { + lock.lock() + guard continuation != nil else { + lock.unlock() + task.cancel() + return + } + timeoutTask = task + lock.unlock() + } + + func succeed(_ value: String?) { + complete(.success(value), closeChannel: true) + } + + func fail(_ error: Error, closeChannel: Bool) { + complete(.failure(error), closeChannel: closeChannel) + } + + private func complete(_ result: Result, closeChannel: Bool) { + lock.lock() + guard let continuation else { + lock.unlock() + return + } + + self.continuation = nil + let channelToClose = closeChannel ? channel : nil + channel = nil + let timeoutTaskToCancel = timeoutTask + timeoutTask = nil + lock.unlock() + + timeoutTaskToCancel?.cancel() + channelToClose?.close(promise: nil) + + switch result { + case .success(let value): + continuation.resume(returning: value) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} + // MARK: - Client Channel Handler /// Channel handler for handling IPC responses on the client side private final class IPCClientHandler: ChannelInboundHandler { typealias InboundIn = IPCMessage - private let continuation: CheckedContinuation - private var hasResponded = false + private let responseState: IPCResponseState - init(continuation: CheckedContinuation) { - self.continuation = continuation + init(responseState: IPCResponseState) { + self.responseState = responseState } func channelRead(context: ChannelHandlerContext, data: NIOAny) { - guard !hasResponded else { return } - hasResponded = true - let message = unwrapInboundIn(data) // Check version compatibility guard message.version == IPCMessage.currentVersion else { - continuation.resume( - throwing: NSError(domain: "IPCClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Version mismatch: server=\(message.version), client=\(IPCMessage.currentVersion)"]) + responseState.fail( + NSError(domain: "IPCClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Version mismatch: server=\(message.version), client=\(IPCMessage.currentVersion)"]), + closeChannel: true ) - context.close(promise: nil) return } @@ -224,21 +296,15 @@ private final class IPCClientHandler: ChannelInboundHandler { switch message.type { case .error: let errorMessage = String(data: message.body, encoding: .utf8) ?? "Unknown error" - continuation.resume(throwing: NSError(domain: "IPCClient", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMessage])) + responseState.fail(NSError(domain: "IPCClient", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMessage]), closeChannel: true) case .response: - continuation.resume(returning: String(data: message.body, encoding: .utf8)) + responseState.succeed(String(data: message.body, encoding: .utf8)) default: - continuation.resume(throwing: NSError(domain: "IPCClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unexpected response type"])) + responseState.fail(NSError(domain: "IPCClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unexpected response type"]), closeChannel: true) } - - context.close(promise: nil) } func errorCaught(context: ChannelHandlerContext, error: Error) { - if !hasResponded { - hasResponded = true - continuation.resume(throwing: error) - } - context.close(promise: nil) + responseState.fail(error, closeChannel: true) } }