diff --git a/Sources/Pulse/LoggerStore/LoggerStore.swift b/Sources/Pulse/LoggerStore/LoggerStore.swift index d61fe4659..ca7cfbbb7 100644 --- a/Sources/Pulse/LoggerStore/LoggerStore.swift +++ b/Sources/Pulse/LoggerStore/LoggerStore.swift @@ -75,6 +75,13 @@ public final class LoggerStore: @unchecked Sendable, Identifiable { guard Thread.isMainThread else { return DispatchQueue.main.async { register(store: store) } } + MainActor.assumeIsolated { + _register(store: store) + } + } + + @MainActor + private static func _register(store: LoggerStore) { if RemoteLogger.shared.store == nil { RemoteLogger.shared.initialize(store: store) } diff --git a/Sources/Pulse/RemoteLogger/RemoteLogger-Connection.swift b/Sources/Pulse/RemoteLogger/RemoteLogger-Connection.swift index 18563aa0f..602857916 100644 --- a/Sources/Pulse/RemoteLogger/RemoteLogger-Connection.swift +++ b/Sources/Pulse/RemoteLogger/RemoteLogger-Connection.swift @@ -212,7 +212,7 @@ extension RemoteLogger { // MARK: Helpers extension RemoteLogger { - static func encode(code: UInt8, body: Data) throws -> Data { + nonisolated static func encode(code: UInt8, body: Data) throws -> Data { guard body.count < UInt32.max else { throw PacketParsingError.unsupportedContentSize } @@ -225,7 +225,7 @@ extension RemoteLogger { return data } - static func decode(buffer: Data) throws -> (Connection.Packet, Int) { + nonisolated static func decode(buffer: Data) throws -> (Connection.Packet, Int) { let header = try PacketHeader(data: buffer) guard buffer.count >= header.compressedPacketLength else { throw PacketParsingError.notEnoughData diff --git a/Sources/Pulse/RemoteLogger/RemoteLogger.swift b/Sources/Pulse/RemoteLogger/RemoteLogger.swift index 2fec61d06..51487dfb5 100644 --- a/Sources/Pulse/RemoteLogger/RemoteLogger.swift +++ b/Sources/Pulse/RemoteLogger/RemoteLogger.swift @@ -10,8 +10,7 @@ import OSLog /// Connects to the remote server and sends logs remotely. In the current version, /// a server is a Pulse Pro app for macOS). -/// -/// - warning: Has to be used from the main thread. +@MainActor public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegate { /// The store that the logger was initialized with. public private(set) var store: LoggerStore? @@ -46,10 +45,14 @@ public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegat @Published public private(set) var connectionState: ConnectionState = .disconnected { - didSet { os_log("Set public connection state %{public}@", log: log, "\(oldValue) → \(connectionState)") } - + didSet { + os_log("Set public connection state %{public}@", log: log, "\(oldValue) → \(connectionState)") + RemoteLogger.latestConnectionState.value = connectionState + } } + nonisolated static let latestConnectionState = Mutex(.disconnected) + // Browsing private var browser: NWBrowser? private var selectedServerPasscode: String? @@ -101,8 +104,7 @@ public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegat } } - public static var shared: RemoteLogger { _shared.value } - private static let _shared = Mutex(RemoteLogger()) + public static let shared = RemoteLogger() /// - parameter store: The store to be synced with the server. By default, /// ``LoggerStore/shared``. Only one store can be synced at at time. @@ -128,9 +130,8 @@ public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegat // The buffer is used to cover the time between the app launch and the // initial (automatic) connection to the server. - let box = SendableBox(value: self) - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { - box.value?.clearBuffer() + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { [weak self] in + self?.clearBuffer() } } @@ -201,12 +202,15 @@ public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegat parameters.includePeerToPeer = true let browser = NWBrowser(for: .bonjourWithTXTRecord(type: RemoteLogger.serviceType, domain: nil), using: parameters) - let box = SendableBox(value: self) - browser.stateUpdateHandler = { - box.value?.browserDidUpdateState($0) + browser.stateUpdateHandler = { [weak self] state in + MainActor.assumeIsolated { + self?.browserDidUpdateState(state) + } } - browser.browseResultsChangedHandler = { results, _ in - box.value?.browserDidUpdateResults(results) + browser.browseResultsChangedHandler = { [weak self] results, _ in + MainActor.assumeIsolated { + self?.browserDidUpdateResults(results) + } } browser.start(queue: .main) @@ -249,9 +253,8 @@ public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegat os_log("Did scheduled browser retry", log: log) // Automatically retry until the user cancels - let box = SendableBox(value: self) - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { - guard let self = box.value, self.isEnabled else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { [weak self] in + guard let self, self.isEnabled else { return } self.stopBrowser() self.startBrowser() } @@ -440,9 +443,8 @@ public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegat connection?.send(code: .clientHello, entity: body) // Set timeout and retry in case there was no response from the server - let box = SendableBox(value: self) - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) { - box.value?.handshakeDidTimeout() + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) { [weak self] in + self?.handshakeDidTimeout() } } @@ -692,10 +694,6 @@ private func getFallbackDeviceId() -> UUID { return id } -private struct SendableBox: @unchecked Sendable { - weak var value: T? -} - private extension NWBrowser.Result { var name: String? { switch endpoint { diff --git a/Sources/Pulse/RemoteLogger/RemoteLoggerURLProtocol.swift b/Sources/Pulse/RemoteLogger/RemoteLoggerURLProtocol.swift index 23427c07a..3dfec8ccc 100644 --- a/Sources/Pulse/RemoteLogger/RemoteLoggerURLProtocol.swift +++ b/Sources/Pulse/RemoteLogger/RemoteLoggerURLProtocol.swift @@ -48,7 +48,7 @@ public final class RemoteLoggerURLProtocol: URLProtocol { } public override class func canInit(with request: URLRequest) -> Bool { - guard RemoteLogger.shared.connectionState == .connected else { + guard RemoteLogger.latestConnectionState.value == .connected else { return false } return RemoteDebugger.shared.shouldMock(request) diff --git a/Sources/PulseUI/Features/Remote/RemoteLoggerSettingsViewModel.swift b/Sources/PulseUI/Features/Remote/RemoteLoggerSettingsViewModel.swift index b58037cf9..324adc96c 100644 --- a/Sources/PulseUI/Features/Remote/RemoteLoggerSettingsViewModel.swift +++ b/Sources/PulseUI/Features/Remote/RemoteLoggerSettingsViewModel.swift @@ -8,6 +8,7 @@ import Combine import Pulse import Network +@MainActor final class RemoteLoggerSettingsViewModel: ObservableObject { @Published var isEnabled = false @Published var pendingPasscodeProtectedServer: RemoteLoggerServerViewModel? @@ -19,10 +20,10 @@ final class RemoteLoggerSettingsViewModel: ObservableObject { static var shared = RemoteLoggerSettingsViewModel() - init(logger: RemoteLogger = .shared) { - self.logger = logger + init(logger: RemoteLogger? = nil) { + self.logger = logger ?? .shared - isEnabled = logger.isEnabled + isEnabled = self.logger.isEnabled $isEnabled.dropFirst().removeDuplicates().receive(on: DispatchQueue.main) .throttle(for: .milliseconds(500), scheduler: DispatchQueue.main, latest: true) diff --git a/Sources/PulseUI/Views/ContextMenus.swift b/Sources/PulseUI/Views/ContextMenus.swift index d74a90f45..e1cd8b631 100644 --- a/Sources/PulseUI/Views/ContextMenus.swift +++ b/Sources/PulseUI/Views/ContextMenus.swift @@ -291,6 +291,7 @@ struct ButtonOpenOnMac: View { } } +@MainActor private func openOnMac(_ entity: NSManagedObject) { switch LoggerEntity(entity) { case .message(let message):