diff --git a/Soyuz.xcodeproj/project.pbxproj b/Soyuz.xcodeproj/project.pbxproj index 26ccf92..83c819a 100644 --- a/Soyuz.xcodeproj/project.pbxproj +++ b/Soyuz.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ E180B61D2992D53700425DB0 /* PrinterObjectsQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180B61C2992D53700425DB0 /* PrinterObjectsQuery.swift */; }; E180B61F2992DBB000425DB0 /* SoyuzMenuBarExtraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180B61E2992DBB000425DB0 /* SoyuzMenuBarExtraView.swift */; }; E180B6222993256E00425DB0 /* PrinterRequestManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180B6212993256E00425DB0 /* PrinterRequestManager.swift */; }; + E1A93C6729C932E200BAE750 /* BonjourBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A93C6629C932E200BAE750 /* BonjourBrowser.swift */; }; + E1A93C6929CD627100BAE750 /* BonjourBrowserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A93C6829CD627100BAE750 /* BonjourBrowserTests.swift */; }; E1E8B07729949E2700BABE4B /* Starscream in Frameworks */ = {isa = PBXBuildFile; productRef = E1E8B07629949E2700BABE4B /* Starscream */; }; /* End PBXBuildFile section */ @@ -61,6 +63,8 @@ E180B61C2992D53700425DB0 /* PrinterObjectsQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterObjectsQuery.swift; sourceTree = ""; }; E180B61E2992DBB000425DB0 /* SoyuzMenuBarExtraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoyuzMenuBarExtraView.swift; sourceTree = ""; }; E180B6212993256E00425DB0 /* PrinterRequestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterRequestManager.swift; sourceTree = ""; }; + E1A93C6629C932E200BAE750 /* BonjourBrowser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourBrowser.swift; sourceTree = ""; }; + E1A93C6829CD627100BAE750 /* BonjourBrowserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BonjourBrowserTests.swift; path = SoyuzTests/BonjourBrowserTests.swift; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -138,6 +142,7 @@ children = ( E180B5FF2992CD9300425DB0 /* SoyuzTests.swift */, E16378B129A43CE1002F05E9 /* SoyuzScratchTests.swift */, + E1A93C6829CD627100BAE750 /* BonjourBrowserTests.swift */, E16378B329A491E6002F05E9 /* PrinterRequestManagerTests.swift */, ); path = SoyuzTests; @@ -166,6 +171,7 @@ children = ( E180B61C2992D53700425DB0 /* PrinterObjectsQuery.swift */, E180B6212993256E00425DB0 /* PrinterRequestManager.swift */, + E1A93C6629C932E200BAE750 /* BonjourBrowser.swift */, ); path = ViewModels; sourceTree = ""; @@ -310,6 +316,7 @@ E180B5F52992CD9200425DB0 /* KlipperMon.xcdatamodeld in Sources */, E124B9D929941A4D00C0D2D2 /* PrinterConfigView.swift in Sources */, E180B5F22992CD9200425DB0 /* Persistence.swift in Sources */, + E1A93C6729C932E200BAE750 /* BonjourBrowser.swift in Sources */, E180B5E92992CD9100425DB0 /* SoyuzApp.swift in Sources */, E180B6222993256E00425DB0 /* PrinterRequestManager.swift in Sources */, E180B61F2992DBB000425DB0 /* SoyuzMenuBarExtraView.swift in Sources */, @@ -322,6 +329,7 @@ files = ( E180B6002992CD9300425DB0 /* SoyuzTests.swift in Sources */, E16378B429A491E6002F05E9 /* PrinterRequestManagerTests.swift in Sources */, + E1A93C6929CD627100BAE750 /* BonjourBrowserTests.swift in Sources */, E16378B229A43CE1002F05E9 /* SoyuzScratchTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Soyuz.xcodeproj/xcuserdata/madelinecr.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Soyuz.xcodeproj/xcuserdata/madelinecr.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index c1840cd..0dc4b3a 100644 --- a/Soyuz.xcodeproj/xcuserdata/madelinecr.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Soyuz.xcodeproj/xcuserdata/madelinecr.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,4 +3,22 @@ uuid = "9DCD6317-B85A-47F1-8DA6-BE708C290036" type = "1" version = "2.0"> + + + + + + diff --git a/Soyuz/Info.plist b/Soyuz/Info.plist index 65adf3a..ddfddf3 100644 --- a/Soyuz/Info.plist +++ b/Soyuz/Info.plist @@ -17,6 +17,7 @@ NSBonjourServices + _xctest._tcp. _moonraker._tcp. _http._tcp. diff --git a/Soyuz/Soyuz.entitlements b/Soyuz/Soyuz.entitlements index 625af03..40b639e 100644 --- a/Soyuz/Soyuz.entitlements +++ b/Soyuz/Soyuz.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.client + com.apple.security.network.server + diff --git a/Soyuz/ViewModels/BonjourBrowser.swift b/Soyuz/ViewModels/BonjourBrowser.swift new file mode 100644 index 0000000..6227237 --- /dev/null +++ b/Soyuz/ViewModels/BonjourBrowser.swift @@ -0,0 +1,75 @@ +// +// BonjourBrowser.swift +// Soyuz +// +// Created by maddiefuzz on 3/20/23. +// + +import Foundation +import Network + +// Protocol defining minimal API for network discovery +// MARK: Net Discovery Protocol +protocol NetworkDiscoveryEngine { + func startScan(queue: DispatchQueue) + + func setBrowseResultsChangedHandler(_ handler: @escaping ((Set, Set) -> Void)) + func setStateUpdateHandler(_ handler: @escaping ((NWBrowser.State) -> Void)) +} + +extension NWBrowser: NetworkDiscoveryEngine { + + func startScan(queue: DispatchQueue) { + start(queue: queue) + } + + func setBrowseResultsChangedHandler(_ handler: @escaping ((Set, Set) -> Void)) { + self.browseResultsChangedHandler = handler + } + + func setStateUpdateHandler(_ handler: @escaping ((State) -> Void)) { + self.stateUpdateHandler = handler + } +} + +// MARK: BonjourBrowser + +class BonjourBrowser: ObservableObject { + @Published var NDEngineResults: [NWBrowser.Result] = [] + + private let nwBrowser: NetworkDiscoveryEngine + var connection: NWConnection! + + // TEMPORARY +// var bonjourListener: NWListener? + + init(browser: NetworkDiscoveryEngine = NWBrowser(for: .bonjourWithTXTRecord(type: "_moonraker._tcp", domain: "local."), using: .tcp)) { + nwBrowser = browser + // Bonjour browser results changed handler + nwBrowser.setBrowseResultsChangedHandler({ (newResults, changes) in + print("[update] Results changed.") + self.NDEngineResults.removeAll() + newResults.forEach { result in + print(result) + self.NDEngineResults.append(result) + } + }) + + // Bonjour browser state update handler + nwBrowser.setStateUpdateHandler({ newState in + switch newState { + case .failed(let error): + print("[error] nwbrowser: \(error)") + case .ready: + print("[ready] nwbrowser") + case .setup: + print("[setup] nwbrowser") + default: + break + } + }) + + nwBrowser.startScan(queue: DispatchQueue.main) + } + +} diff --git a/Soyuz/ViewModels/PrinterRequestManager.swift b/Soyuz/ViewModels/PrinterRequestManager.swift index a76444f..99234eb 100644 --- a/Soyuz/ViewModels/PrinterRequestManager.swift +++ b/Soyuz/ViewModels/PrinterRequestManager.swift @@ -11,78 +11,11 @@ import AppKit import Starscream -// Protocol defining minimal API for network discovery -// MARK: Net Discovery Protocol -protocol NetworkDiscoveryEngine { - func startScan(queue: DispatchQueue) - func setBrowseResultsChangedHandler(_ handler: @escaping ((Set, Set) -> Void)) - func setStateUpdateHandler(_ handler: @escaping ((NWBrowser.State) -> Void)) -} - -extension NWBrowser: NetworkDiscoveryEngine { - func startScan(queue: DispatchQueue) { - start(queue: queue) - } - - func setBrowseResultsChangedHandler(_ handler: @escaping ((Set, Set) -> Void)) { - self.browseResultsChangedHandler = handler - } - - func setStateUpdateHandler(_ handler: @escaping ((State) -> Void)) { - self.stateUpdateHandler = handler - } -} - - -// Properly formatted JSON-RPC Request for use with Starscream -// MARK: JSON-RPC Request Codable -struct JsonRpcRequest: Codable { - var jsonrpc = "2.0" - let method: String - let params: [String: [String: String?]] - var id = 1 - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(jsonrpc, forKey: .jsonrpc) - try container.encode(method, forKey: .method) - try container.encode(params, forKey: .params) - try container.encode(id, forKey: .id) - } -} - // MARK: PrinterRequestManager //@MainActor class PrinterRequestManager: ObservableObject, WebSocketDelegate { let WEBSOCKET_TIMEOUT_INTERVAL: TimeInterval = 60.0 - // Debug timestamp stuff - let startDate = Date() - let startDateString: String - let filename: URL - - // Debug file writing stuff - func writeToDebugLog(_ output: String) { - do { - let fileHandle = try FileHandle(forWritingTo: filename) - defer { - fileHandle.closeFile() - } - fileHandle.seekToEndOfFile() - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SS" - - let debugOutput = String("\(dateFormatter.string(from: Date())) - \(output)\n") - fileHandle.write(debugOutput.data(using: .utf8)!) - } catch { - print("[error] writeToDebugLog - \(error)") - } - } - - // Websocket JSON-RPC endpoints discovered via bonjour - @Published var nwBrowserDiscoveredItems: [NWBrowser.Result] = [] - - // Websocket JSON-RPC published data @Published var state: String @Published var progress: Double @@ -94,53 +27,21 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { @Published var socketHost: String @Published var socketPort: String - let nwBrowser: NetworkDiscoveryEngine - var connection: NWConnection! + // Published NWConnection for listing connection information + @Published var connection: NWConnection? + + private var socket: WebSocket? + private var lastPingDate = Date() - var socket: WebSocket? - var lastPingDate = Date() // MARK: PRM init() - init(browser: NetworkDiscoveryEngine = NWBrowser(for: .bonjourWithTXTRecord(type: "_moonraker._tcp", domain: "local."), using: .tcp)) { + init() { state = "" progress = 0.0 extruderTemperature = 0.0 bedTemperature = 0.0 socketHost = "" socketPort = "" - nwBrowser = browser - - // Debug output-to-file functionality - startDateString = "\(startDate)\n\n" - filename = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("klippermon-debug-\(startDateString).txt") - - do { - try startDateString.write(to: filename, atomically: true, encoding: .utf8) - } catch { - print("[error] Couldn't write to \(filename) - \(error)") - } - - // Bonjour browser results changed handler - nwBrowser.setBrowseResultsChangedHandler({ (newResults, changes) in - print("[update] Results changed.") - newResults.forEach { result in - print(result) - self.nwBrowserDiscoveredItems.append(result) - } - }) - // Bonjour browser state update handler - nwBrowser.setStateUpdateHandler({ newState in - switch newState { - case .failed(let error): - print("[error] nwbrowser: \(error)") - case .ready: - print("[ready] nwbrowser") - case .setup: - print("[setup] nwbrowser") - default: - break - } - }) // Set up sleep/wake notification observers let center = NSWorkspace.shared.notificationCenter; @@ -153,9 +54,6 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { center.addObserver(forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: mainQueue) { notification in self.screenChangedSleepState(notification) } - - // Start up the bonjour browser, get results and process them in the update handler - nwBrowser.startScan(queue: DispatchQueue.main) } // Called from the UI with an endpoint. @@ -167,15 +65,17 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { print("\(key): \(value)") }) - connection = NWConnection(to: endpoint, using: .tcp) - connection.stateUpdateHandler = { [self] state in + if connection == nil { + connection = NWConnection(to: endpoint, using: .tcp) + } + + connection?.stateUpdateHandler = { [self] state in switch state { case .ready: - if let innerEndpoint = connection.currentPath?.remoteEndpoint, case .hostPort(let host, let port) = innerEndpoint { + if let innerEndpoint = connection?.currentPath?.remoteEndpoint, case .hostPort(let host, let port) = innerEndpoint { let hostPortDebugOutput = "Connected to \(host):\(port)" print(hostPortDebugOutput) - writeToDebugLog(hostPortDebugOutput) let hostString = "\(host)" let regex = try! Regex("%(.+)") @@ -184,7 +84,7 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { print("[sanitized] Resolved \(sanitizedHost):\(port)") - connection.cancel() + connection?.cancel() DispatchQueue.main.async { self.socketHost = sanitizedHost @@ -196,7 +96,11 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { break } } - connection.start(queue: .global()) + connection?.start(queue: .global()) + } + + func disconnect() { + socket?.disconnect() } @@ -238,7 +142,6 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { case .connected(let headers): isConnected = true print("websocket is connected: \(headers)") - writeToDebugLog("Connected to WebSocket") let jsonRpcRequest = JsonRpcRequest(method: "printer.objects.subscribe", params: ["objects": ["extruder": nil, @@ -254,9 +157,7 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { case .disconnected(let reason, let code): isConnected = false print("websocket is disconnected: \(reason) with code: \(code)") - writeToDebugLog("Websocket is disconnected: \(reason) with code: \(code)") case .text(let string): - self.writeToDebugLog(string) // Check for initial RPC response let statusResponse = try? JSONDecoder().decode(jsonRpcResponse.self, from: Data(string.utf8)) if let statusResponseSafe = statusResponse { @@ -267,7 +168,6 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { self.parse_update(updateResponse) } case .binary(let data): - self.writeToDebugLog(String(data: data, encoding: .utf8)!) print("Received data: \(data.count)") case .ping(_): print("PING! \(Date())") @@ -320,3 +220,20 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { } } } + +// Properly formatted JSON-RPC Request for use with Starscream +// MARK: JSON-RPC Request Codable +struct JsonRpcRequest: Codable { + var jsonrpc = "2.0" + let method: String + let params: [String: [String: String?]] + var id = 1 + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(jsonrpc, forKey: .jsonrpc) + try container.encode(method, forKey: .method) + try container.encode(params, forKey: .params) + try container.encode(id, forKey: .id) + } +} diff --git a/Soyuz/Views/PrinterConfigView.swift b/Soyuz/Views/PrinterConfigView.swift index 89b9a37..5d198ca 100644 --- a/Soyuz/Views/PrinterConfigView.swift +++ b/Soyuz/Views/PrinterConfigView.swift @@ -8,18 +8,20 @@ import SwiftUI import Network +// MARK: PrinterConfigView struct PrinterConfigView: View { @ObservedObject var printerManager: PrinterRequestManager + @ObservedObject var bonjourBrowser = BonjourBrowser() var body: some View { VStack { if(printerManager.isConnected) { HStack { Image(systemName: "network") - Text(printerManager.connection.endpoint.toFriendlyString()) + Text(printerManager.connection?.endpoint.toFriendlyString() ?? "Unknown Host") Text("\(printerManager.socketHost):\(printerManager.socketPort)") Button { - printerManager.socket?.disconnect() + printerManager.disconnect() } label: { Text("Disconnect") } @@ -29,7 +31,7 @@ struct PrinterConfigView: View { VStack { Text("Auto-detected Printers") .font(.title) - ForEach(printerManager.nwBrowserDiscoveredItems, id: \.hashValue) { result in + ForEach(bonjourBrowser.NDEngineResults , id: \.hashValue) { result in HStack { Text(result.endpoint.toFriendlyString()) Button { diff --git a/SoyuzTests/BonjourBrowserTests.swift b/SoyuzTests/BonjourBrowserTests.swift new file mode 100644 index 0000000..e923739 --- /dev/null +++ b/SoyuzTests/BonjourBrowserTests.swift @@ -0,0 +1,54 @@ +// +// BonjourBrowserTests.swift +// SoyuzTests +// +// Created by maddiefuzz on 3/24/23. +// + +import XCTest +import Network +import Combine +@testable import Soyuz + +class SoyuzBonjourBrowserTests: XCTestCase { + var bonjourBrowser: BonjourBrowser? + var bonjourListener: NWListener? + var cancellable: AnyCancellable? + + override func setUp() { + do { + bonjourListener = try NWListener(using: .tcp, on: .http) + bonjourListener!.service = NWListener.Service(name: "Test", type: "_xctest._tcp") + + bonjourListener!.newConnectionHandler = { newConnection in + return + } + } catch { + print("Error: \(error)") + } + bonjourBrowser = BonjourBrowser(browser: NWBrowser(for: .bonjour(type: "_xctest._tcp", domain: "local."), using: .tcp)) + } + + func testBonjourDiscoveredItemsNotNil() { + guard let browser = bonjourBrowser else { + XCTAssert(false) + return + } + + let expectation = XCTestExpectation(description: "BonjourBrowser publishes network services") + + cancellable = browser.$NDEngineResults + .dropFirst() + .sink(receiveValue: { newValue in + if newValue.count > 0 { + expectation.fulfill() + } + }) + + bonjourListener!.start(queue: DispatchQueue.main) + wait(for: [expectation], timeout: 2) + XCTAssert(!browser.NDEngineResults.isEmpty) + XCTAssertEqual(browser.NDEngineResults.count, 1) + } + +} diff --git a/SoyuzTests/PrinterRequestManagerTests.swift b/SoyuzTests/PrinterRequestManagerTests.swift index e394e73..cc336f9 100644 --- a/SoyuzTests/PrinterRequestManagerTests.swift +++ b/SoyuzTests/PrinterRequestManagerTests.swift @@ -14,20 +14,20 @@ class PrinterRequestManagerTests: XCTestCase { var testBonjourListener: NWListener? - override func setUp() { - printerRequestManager = PrinterRequestManager(browser: NWBrowser(for: .bonjour(type: "_http._tcp", domain: "local."), using: .tcp)) - - // Set up test bonjour server - //let parameters = NWParameters(tls: .none, tcp: NWListener.) - do { - testBonjourListener = try NWListener(using: .tcp, on: .http) - testBonjourListener!.start(queue: DispatchQueue.main) - } catch { - print("Error: \(error)") - } - } - - func testBonjourDiscoveredItemsNotNil() { - XCTAssertNotNil(printerRequestManager?.nwBrowserDiscoveredItems) - } +// override func setUp() { +// printerRequestManager = PrinterRequestManager(browser: NWBrowser(for: .bonjour(type: "_http._tcp", domain: "local."), using: .tcp)) +// +// // Set up test bonjour server +// //let parameters = NWParameters(tls: .none, tcp: NWListener.) +// do { +// testBonjourListener = try NWListener(using: .tcp, on: .http) +// testBonjourListener!.start(queue: DispatchQueue.main) +// } catch { +// print("Error: \(error)") +// } +// } +// +// func testBonjourDiscoveredItemsNotNil() { +// XCTAssertNotNil(printerRequestManager?.nwBrowserDiscoveredItems) +// } }