diff --git a/Reconnect.xcodeproj/project.pbxproj b/Reconnect.xcodeproj/project.pbxproj index f5190db..fc8dca6 100644 --- a/Reconnect.xcodeproj/project.pbxproj +++ b/Reconnect.xcodeproj/project.pbxproj @@ -10,8 +10,8 @@ D806342B2C27F87100DEA6DA /* plpftp in Frameworks */ = {isa = PBXBuildFile; productRef = D806342A2C27F87100DEA6DA /* plpftp */; }; D813FF152C26E869009CF0DC /* plptools-license in Resources */ = {isa = PBXBuildFile; fileRef = D813FF142C26E869009CF0DC /* plptools-license */; }; D8184C472C253C59008FA79B /* ApplicationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8184C462C253C59008FA79B /* ApplicationModel.swift */; }; - D8184C4B2C253D73008FA79B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8184C4A2C253D73008FA79B /* SettingsView.swift */; }; D81A0C162C27A90000EC2929 /* ncp in Frameworks */ = {isa = PBXBuildFile; productRef = D81A0C152C27A90000EC2929 /* ncp */; }; + D822EA132C2BA92E008A4BAA /* SerialDeviceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D822EA122C2BA92E008A4BAA /* SerialDeviceMonitor.swift */; }; D83658B72C29596F00B45693 /* NavigationStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83658B62C29596F00B45693 /* NavigationStackTests.swift */; }; D83658B92C298C4F00B45693 /* NavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83658B82C298C4F00B45693 /* NavigationStack.swift */; }; D83658BB2C29A09300B45693 /* HistoryItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83658BA2C29A09300B45693 /* HistoryItemView.swift */; }; @@ -58,7 +58,7 @@ /* Begin PBXFileReference section */ D813FF142C26E869009CF0DC /* plptools-license */ = {isa = PBXFileReference; lastKnownFileType = text; path = "plptools-license"; sourceTree = ""; }; D8184C462C253C59008FA79B /* ApplicationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationModel.swift; sourceTree = ""; }; - D8184C4A2C253D73008FA79B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + D822EA122C2BA92E008A4BAA /* SerialDeviceMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialDeviceMonitor.swift; sourceTree = ""; }; D83658B62C29596F00B45693 /* NavigationStackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackTests.swift; sourceTree = ""; }; D83658B82C298C4F00B45693 /* NavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStack.swift; sourceTree = ""; }; D83658BA2C29A09300B45693 /* HistoryItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItemView.swift; sourceTree = ""; }; @@ -124,7 +124,6 @@ children = ( D8184C462C253C59008FA79B /* ApplicationModel.swift */, D886DA752C29343900E84BDA /* BrowserModel.swift */, - D83658B82C298C4F00B45693 /* NavigationStack.swift */, D8C080792C1D7D8D003128AB /* ReconnectError.swift */, ); path = Model; @@ -136,12 +135,20 @@ D886DA732C28CDAD00E84BDA /* BrowserView.swift */, D83658BA2C29A09300B45693 /* HistoryItemView.swift */, D8D3E7A02C25410E003E696D /* MainMenu.swift */, - D8184C4A2C253D73008FA79B /* SettingsView.swift */, D89B5E8D2C2AA8680014A5B6 /* Sidebar.swift */, ); path = Views; sourceTree = ""; }; + D822EA0F2C2BA305008A4BAA /* Utilities */ = { + isa = PBXGroup; + children = ( + D83658B82C298C4F00B45693 /* NavigationStack.swift */, + D822EA122C2BA92E008A4BAA /* SerialDeviceMonitor.swift */, + ); + path = Utilities; + sourceTree = ""; + }; D84964CD2C1BFCB600405656 = { isa = PBXGroup; children = ( @@ -175,6 +182,7 @@ D8184C482C253C6F008FA79B /* Model */, D886DA772C29345E00E84BDA /* PLP */, D84964DF2C1BFCB700405656 /* Preview Content */, + D822EA0F2C2BA305008A4BAA /* Utilities */, D8184C492C253D62008FA79B /* Views */, ); path = Reconnect; @@ -398,8 +406,8 @@ D8E31EB52C26E10900350082 /* Licensable.swift in Sources */, D886DA742C28CDAD00E84BDA /* BrowserView.swift in Sources */, D83658B92C298C4F00B45693 /* NavigationStack.swift in Sources */, + D822EA132C2BA92E008A4BAA /* SerialDeviceMonitor.swift in Sources */, D87AAC8A2C27EB070091F442 /* FileServer.swift in Sources */, - D8184C4B2C253D73008FA79B /* SettingsView.swift in Sources */, D8184C472C253C59008FA79B /* ApplicationModel.swift in Sources */, D89B5E8E2C2AA8680014A5B6 /* Sidebar.swift in Sources */, D886DA762C29343900E84BDA /* BrowserModel.swift in Sources */, diff --git a/Reconnect/Extensions/URL.swift b/Reconnect/Extensions/URL.swift index 8c210cd..5f8de2c 100644 --- a/Reconnect/Extensions/URL.swift +++ b/Reconnect/Extensions/URL.swift @@ -22,6 +22,5 @@ extension URL { static let about = URL(string: "x-reconnect://about")! static let browser = URL(string: "x-reconnect://browser")! - static let settings = URL(string: "x-reconnect://settings")! } diff --git a/Reconnect/Model/ApplicationModel.swift b/Reconnect/Model/ApplicationModel.swift index 4788ae8..284fa20 100644 --- a/Reconnect/Model/ApplicationModel.swift +++ b/Reconnect/Model/ApplicationModel.swift @@ -20,27 +20,100 @@ import SwiftUI import Interact -@Observable -class ApplicationModel { +@MainActor @Observable +class ApplicationModel: NSObject { + + struct SerialDevice: Identifiable { + + var id: String { + return path + } + + var path: String + var available: Bool + var enabled: Binding + } enum SettingsKey: String { - case device + case selectedDevices } - @MainActor var device: String { + var isConnected: Bool = false + + var devices: [SerialDevice] { + return connectedDevices.union(selectedDevices) + .map { device in + let binding: Binding = Binding { + return self.selectedDevices.contains(device) + } set: { newValue in + if newValue { + self.selectedDevices.insert(device) + } else { + self.selectedDevices.remove(device) + } + } + return SerialDevice(path: device, + available: connectedDevices.contains(device), + enabled: binding) + } + .sorted { device1, device2 in + return device1.path.localizedStandardCompare(device2.path) == .orderedAscending + } + } + + private var selectedDevices: Set { + didSet { + keyedDefaults.set(Array(selectedDevices), forKey: .selectedDevices) + update() + } + } + + private var connectedDevices: Set = [] { didSet { - keyedDefaults.set(device, forKey: .device) + update() } } private let keyedDefaults = KeyedDefaults() + private let server: Server = Server() + private let serialDeviceMonitor = SerialDeviceMonitor() - @MainActor init() { - device = keyedDefaults.string(forKey: .device, default: "") + override init() { + selectedDevices = Set(keyedDefaults.object(forKey: .selectedDevices) as? Array ?? []) + super.init() + server.delegate = self + serialDeviceMonitor.delegate = self + server.start() + serialDeviceMonitor.start() } @MainActor func quit() { NSApplication.shared.terminate(nil) } + func update() { + server.setDevices(selectedDevices.intersection(connectedDevices).sorted()) + } + +} + +extension ApplicationModel: ServerDelegate { + + func server(server: Server, didChangeConnectionState isConnected: Bool) { + self.isConnected = isConnected + } + +} + +extension ApplicationModel: SerialDeviceMonitorDelegate { + + func serialDeviceMonitor(serialDeviceMonitor: SerialDeviceMonitor, didAddDevice device: String) { + connectedDevices.insert(device) + + } + + func serialDeviceMonitor(serialDeviceMonitor: SerialDeviceMonitor, didRemoveDevice device: String) { + connectedDevices.remove(device) + } + } diff --git a/Reconnect/Model/BrowserModel.swift b/Reconnect/Model/BrowserModel.swift index 6abdc5c..ba5f541 100644 --- a/Reconnect/Model/BrowserModel.swift +++ b/Reconnect/Model/BrowserModel.swift @@ -28,7 +28,7 @@ class BrowserModel { return name(for: path) } - let fileServer = FileServer(host: "127.0.0.1", port: 7501) + let fileServer: FileServer var drives: [FileServer.DriveInfo] = [] var files: [FileServer.DirectoryEntry] = [] @@ -48,11 +48,8 @@ class BrowserModel { private var navigationStack = NavigationStack() - init() { - guard fileServer.connect() else { - lastError = ReconnectError.unknown - return - } + init(fileServer: FileServer) { + self.fileServer = fileServer } func start() async { diff --git a/Reconnect/PLP/FileServer.swift b/Reconnect/PLP/FileServer.swift index 1408870..e36169f 100644 --- a/Reconnect/PLP/FileServer.swift +++ b/Reconnect/PLP/FileServer.swift @@ -104,14 +104,15 @@ class FileServer { self.port = port } - func connect() -> Bool { - return workQueue.sync { - return self.client.connect(self.host, self.port) + private func syncQueue_connect() throws { + guard self.client.connect(self.host, self.port) else { + throw ReconnectError.unknown } } private func syncQueue_dir(path: String) throws -> [DirectoryEntry] { dispatchPrecondition(condition: .onQueue(workQueue)) + try syncQueue_connect() var details = PlpDir() client.dir(path, &details) @@ -158,6 +159,7 @@ class FileServer { func syncQueue_copyFile(fromRemotePath remoteSourcePath: String, toLocalPath localDestinationPath: String) throws { dispatchPrecondition(condition: .onQueue(workQueue)) + try syncQueue_connect() let result = client.copyFromPsion(remoteSourcePath, localDestinationPath, nil) { context, status in print("progress = \(status)") return 1 // 0 is cancel @@ -182,6 +184,7 @@ class FileServer { func syncQueue_mkdir(path: String) throws { dispatchPrecondition(condition: .onQueue(workQueue)) + try syncQueue_connect() let result = client.mkdir(path) guard result.rawValue == 0 else { throw ReconnectError.rfsvError(result) @@ -203,6 +206,7 @@ class FileServer { func syncQueue_rmdir(path: String) throws { dispatchPrecondition(condition: .onQueue(workQueue)) + try syncQueue_connect() let result = client.rmdir(path) guard result.rawValue == 0 else { throw ReconnectError.rfsvError(result) @@ -224,6 +228,7 @@ class FileServer { func syncQueue_remove(path: String) throws { dispatchPrecondition(condition: .onQueue(workQueue)) + try syncQueue_connect() let result = client.remove(path) guard result.rawValue == 0 else { throw ReconnectError.rfsvError(result) @@ -245,6 +250,7 @@ class FileServer { func syncQueue_devlist() throws -> [String] { dispatchPrecondition(condition: .onQueue(workQueue)) + try syncQueue_connect() var devbits: UInt32 = 0 let result = client.devlist(&devbits) guard result.rawValue == 0 else { @@ -262,6 +268,7 @@ class FileServer { func syncQueue_devinfo(drive: String) throws -> DriveInfo { dispatchPrecondition(condition: .onQueue(workQueue)) + try syncQueue_connect() let d = drive.cString(using: .ascii)!.first! var driveInfo = PlpDrive() let result = client.devinfo(d, &driveInfo) diff --git a/Reconnect/PLP/Server.swift b/Reconnect/PLP/Server.swift index 325ad63..b939555 100644 --- a/Reconnect/PLP/Server.swift +++ b/Reconnect/PLP/Server.swift @@ -20,13 +20,43 @@ import Foundation import ncp -@Observable +protocol ServerDelegate: NSObject { + + func server(server: Server, didChangeConnectionState isConnected: Bool) + +} + class Server { - var isConnected: Bool = false + weak var delegate: ServerDelegate? = nil + + var lock = NSLock() + var threadID: pthread_t? = nil // Synchronized with lock. + var devices: [String] = [] // Synchronized with lock. + + func device() -> String { + print("Getting device...") + while true { + let devices = lock.withLock { + return self.devices + } + if let device = devices.first { + return device + } + print("Waiting for devices...") + sleep(1) + } + } func threadEntryPoint() { + setup_signal_handlers() + + // TODO: Maybe this shouldn't be a member? + lock.withLock { + threadID = pthread_self() + } + let context = Unmanaged.passRetained(self).toOpaque() let callback: statusCallback_t = { context, status in guard let context else { @@ -35,20 +65,51 @@ class Server { print("status = \(status)") let server = Unmanaged.fromOpaque(context).takeUnretainedValue() DispatchQueue.main.sync { - server.isConnected = status == 1 ? true : false + let isConnected = status == 1 ? true : false + server.delegate?.server(server: server, didChangeConnectionState: isConnected) } } -// let device = "/dev/tty.usbserial-AL00AYCG" - let device = "/dev/tty.usbserial-A91MGK6M" - - ncpd(7501, 115200, "127.0.0.1", device, 0, callback, context) + while true { + let device = self.device() + print("Using device \(device)...") + ncpd(7501, 115200, "127.0.0.1", device, 0x0000, callback, context) + DispatchQueue.main.async { + self.delegate?.server(server: self, didChangeConnectionState: false) + } + print("ncpd ended") + } } init() { // Create a new thread and start it + } + + func start() { + // TODO: ONLY DO THIS ONCE! let thread = Thread(block: threadEntryPoint) thread.start() + + // TODO: This should probably block until we're ready?? + } + + func setDevices(_ devices: [String]) { + // SIGHUP? + guard let threadID = lock.withLock({ + return self.threadID + }) else { + return + } + + print("Setting devices \(devices)") + + lock.withLock { + self.devices = devices + } + + print("Signalling thread...") + pthread_kill(threadID, SIGINT) + print("DONE?") } } diff --git a/Reconnect/ReconnectApp.swift b/Reconnect/ReconnectApp.swift index 411f9f3..c6fdcef 100644 --- a/Reconnect/ReconnectApp.swift +++ b/Reconnect/ReconnectApp.swift @@ -21,6 +21,22 @@ import SwiftUI import Diligence import Interact +struct ContentView: View { + + var applicationModel: ApplicationModel + let fileServer = FileServer(host: "127.0.0.1", port: 7501) + + var body: some View { + if applicationModel.isConnected { + BrowserView(fileServer: fileServer) + } else { + ContentUnavailableView("Not Connected", systemImage: "star") + } + } + +} + + @main struct ReconnectApp: App { @@ -39,21 +55,15 @@ struct ReconnectApp: App { MainMenu() .environment(applicationModel) } label: { - if server.isConnected { + if applicationModel.isConnected { Image("StatusConnected") } else { Image("StatusDisconnected") } } - Window("Settings", id: "settings") { - SettingsView() - } - .environment(applicationModel) - .handlesExternalEvents(matching: [.settings]) - - Window("My Psion", id: "browser") { - BrowserView() + WindowGroup { + ContentView(applicationModel: applicationModel) } .environment(applicationModel) .handlesExternalEvents(matching: [.browser]) diff --git a/Reconnect/Model/NavigationStack.swift b/Reconnect/Utilities/NavigationStack.swift similarity index 100% rename from Reconnect/Model/NavigationStack.swift rename to Reconnect/Utilities/NavigationStack.swift diff --git a/Reconnect/Utilities/SerialDeviceMonitor.swift b/Reconnect/Utilities/SerialDeviceMonitor.swift new file mode 100644 index 0000000..b2e96de --- /dev/null +++ b/Reconnect/Utilities/SerialDeviceMonitor.swift @@ -0,0 +1,126 @@ +// Reconnect -- Psion connectivity for macOS +// +// Copyright (C) 2024 Jason Morley +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +import Foundation +import IOKit +import IOKit.serial + +protocol SerialDeviceMonitorDelegate: NSObject { + + func serialDeviceMonitor(serialDeviceMonitor: SerialDeviceMonitor, didAddDevice device: String) + func serialDeviceMonitor(serialDeviceMonitor: SerialDeviceMonitor, didRemoveDevice device: String) + +} + +class SerialDeviceMonitor { + + weak var delegate: SerialDeviceMonitorDelegate? + + func start() { + let matchingDict = IOServiceMatching(kIOSerialBSDServiceValue) + var notifyPort: IONotificationPortRef? + var addedIterator: io_iterator_t = 0 + var removedIterator: io_iterator_t = 0 + + notifyPort = IONotificationPortCreate(kIOMainPortDefault) + let runLoopSource = IONotificationPortGetRunLoopSource(notifyPort).takeUnretainedValue() + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .defaultMode) + + let context = Unmanaged.passRetained(self).toOpaque() + + // TODO: Store this notification and remove it in the future. + IOServiceAddMatchingNotification( + notifyPort!, + kIOMatchedNotification, + matchingDict, + { (context, iterator) in + guard let context else { + return + } + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + while case let service = IOIteratorNext(iterator), service != 0 { + monitor.deviceAdded(service: service) + } + }, + context, + &addedIterator + ) + + // TODO: Store this notification and remove it in the future. + IOServiceAddMatchingNotification( + notifyPort!, + kIOTerminatedNotification, + matchingDict, + { (context, iterator) in + guard let context else { + return + } + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + while case let service = IOIteratorNext(iterator), service != 0 { + monitor.deviceRemoved(service: service) + } + }, + context, + &removedIterator + ) + + // Check the notification iterators for their initial state. We do this for both iterators as it ensures we have + // the correct initial state and is required to arm the notifications. + // https://developer.apple.com/documentation/iokit/1514362-ioserviceaddmatchingnotification + + // Handle existing removals. + while case let service = IOIteratorNext(removedIterator), service != 0 { + deviceRemoved(service: service) + } + + // Handle existing additions. + while case let service = IOIteratorNext(addedIterator), service != 0 { + deviceAdded(service: service) + } + + } + + func stop() { + // TODO: Figure out where this notification is owned and how we call ack to an existing object. + } + + func deviceAdded(service: io_object_t) { + dispatchPrecondition(condition: .onQueue(.main)) + defer { + IOObjectRelease(service) + } + if let deviceName = IORegistryEntryCreateCFProperty(service, + kIOCalloutDeviceKey as CFString, + kCFAllocatorDefault, 0)?.takeUnretainedValue() as? String { + delegate?.serialDeviceMonitor(serialDeviceMonitor: self, didAddDevice: deviceName) + } + } + + func deviceRemoved(service: io_object_t) { + dispatchPrecondition(condition: .onQueue(.main)) + defer { + IOObjectRelease(service) + } + if let deviceName = IORegistryEntryCreateCFProperty(service, + kIOCalloutDeviceKey as CFString, + kCFAllocatorDefault, 0)?.takeUnretainedValue() as? String { + delegate?.serialDeviceMonitor(serialDeviceMonitor: self, didRemoveDevice: deviceName) + } + } + +} diff --git a/Reconnect/Views/BrowserView.swift b/Reconnect/Views/BrowserView.swift index ae053a5..0521fe7 100644 --- a/Reconnect/Views/BrowserView.swift +++ b/Reconnect/Views/BrowserView.swift @@ -21,7 +21,11 @@ import SwiftUI @MainActor struct BrowserView: View { - @State var model = BrowserModel() + @State var model: BrowserModel + + init(fileServer: FileServer) { + _model = State(initialValue: BrowserModel(fileServer: fileServer)) + } var body: some View { NavigationSplitView { diff --git a/Reconnect/Views/MainMenu.swift b/Reconnect/Views/MainMenu.swift index 318f72d..4f4ee8e 100644 --- a/Reconnect/Views/MainMenu.swift +++ b/Reconnect/Views/MainMenu.swift @@ -41,12 +41,17 @@ struct MainMenu: View { } label: { Text("About...") } - Button("Settings...") { - openURL(.settings) + Menu("Settings") { + ForEach(applicationModel.devices) { device in + Toggle(isOn: device.enabled) { + Text(device.path) + .foregroundStyle(device.available ? .primary : .secondary) + } + } + Divider() + Toggle("Open at Login", isOn: $application.openAtLogin) } Divider() - Toggle("Open at Login", isOn: $application.openAtLogin) - Divider() Button("Quit") { applicationModel.quit() } diff --git a/Reconnect/Views/SettingsView.swift b/Reconnect/Views/SettingsView.swift deleted file mode 100644 index 63b8e4d..0000000 --- a/Reconnect/Views/SettingsView.swift +++ /dev/null @@ -1,33 +0,0 @@ -// Reconnect -- Psion connectivity for macOS -// -// Copyright (C) 2024 Jason Morley -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. - -import SwiftUI - -struct SettingsView: View { - - @Environment(ApplicationModel.self) var applicationModel - - var body: some View { - @Bindable var applicationModel = applicationModel - Form { - TextField("Serial Device", text: $applicationModel.device) - } - .formStyle(.grouped) - } - -} diff --git a/dependencies/plptools b/dependencies/plptools index 418e9ea..cff0b69 160000 --- a/dependencies/plptools +++ b/dependencies/plptools @@ -1 +1 @@ -Subproject commit 418e9ea77552b7d7347f23ad5e49a76e7d597069 +Subproject commit cff0b690a561fe874db6a1b36e2144ee428a7705