diff --git a/DriveWire Specification.md b/DriveWire Specification.md index 4a28054..9743674 100644 --- a/DriveWire Specification.md +++ b/DriveWire Specification.md @@ -579,6 +579,360 @@ Byte | Value 1 | Length of name 2-258 | Name +## Remote File Manager (RFM) + +**Note: RFM is an unofficial extension, currently implemented only by the Swift host. It is documented here so that other hosts and guest drivers can implement it consistently. It is not part of the DW3/DW4 baseline and guests should not assume a given server supports it.** + +RFM gives the CoCo direct access to files and directories on the Server's file system, rather than to fixed-size sector images. It was designed to back an OS-9 device driver, so its semantics (path descriptors, GetStat/SetStat codes, synthesized file descriptors) mirror the OS-9 RBF/SCF conventions rather than the disk-image sector model used elsewhere in this specification. + +All RFM transactions share the same envelope: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | RFM sub-opcode (see table below) + +The remaining bytes of the transaction, and the response, depend on the sub-opcode. Multiple files/directories may be open concurrently; each open is identified by a **path number** (0-255) chosen by the CoCo and passed as the first byte of every subsequent sub-opcode's payload. The Server maintains a per-path descriptor (host file handle, current position, open mode, resolved pathname) until the path is closed. + +The Server confines all path resolution to a configured root directory (analogous to a device's root). Any pathname that would resolve outside of that root is rejected with error code 214 (E$FNA) rather than followed. + +RFM error codes returned in the single-byte error responses are OS-9 style: + +Code (dec) | Code (hex) | Meaning +-----------|------------|-------- +0 | $00 | Success +211 | $D3 | E$EOF -- end of file +214 | $D6 | E$FNA -- file/path not accessible (includes root-escape attempts, permission denied, or path is a directory when a file was expected) +215 | $D7 | E$DNE -- directory not empty (delete of a non-empty directory) +216 | $D8 | E$PNNF -- path not found + +### RFM sub-opcodes + +Sub-opcode | Value | Name | Direction +-----------|-------|------|---------- +1 | $01 | OP_RFM_CREATE | bi-directional +2 | $02 | OP_RFM_OPEN | bi-directional +3 | $03 | OP_RFM_MAKDIR | bi-directional +4 | $04 | OP_RFM_CHGDIR | bi-directional +5 | $05 | OP_RFM_DELETE | bi-directional +6 | $06 | OP_RFM_SEEK | bi-directional +7 | $07 | OP_RFM_READ | bi-directional +8 | $08 | OP_RFM_WRITE | uni-directional +9 | $09 | OP_RFM_READLN | bi-directional +10 | $0A | OP_RFM_WRITLN | uni-directional +11 | $0B | OP_RFM_GETSTT | bi-directional +12 | $0C | OP_RFM_SETSTT | uni-directional +13 | $0D | OP_RFM_CLOSE | bi-directional + +### Two directory contexts: data vs. execution + +RFM tracks **two separate current-directory values per CoCo process**: a data directory (used to resolve relative pathnames for ordinary file access) and an execution directory (used to resolve relative pathnames when loading/executing code). This mirrors OS-9's distinction between a process's data and execution search paths. Every CREATE/OPEN/MAKDIR/CHGDIR/DELETE request carries a mode byte whose bit 2 ($04) selects which of the two directory contexts applies to that request; CHGDIR updates only the selected context. Each process's current directory falls back to its parent process's current directory if it has not set its own. + +### OP_RFM_CREATE / OP_RFM_OPEN + +CREATE opens a file for writing, creating it if it does not exist. OPEN opens an existing file or directory for reading (or read/write, per the mode byte). Both share the same wire format. + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_CREATE ($01) or OP_RFM_OPEN ($02) +2 | Path number (0-255) +3 | Process ID +4 | Parent process ID +5 | Mode byte (bit 7 $80 = directory open; bit 2 $04 = use execution directory instead of data directory; bits 1 and 3 relate to write access) +6-n | Pathname, terminated by a carriage return ($0D). High bit of each pathname byte is ignored. + +Server response: + +Byte | Value +-----------|---------- +0 | Error code (0 = success) + +If the mode byte indicates a directory open and the open succeeds, the Server synthesizes OS-9 style directory contents (see GETSTT/SS.FD below) for subsequent reads on that path. + +### OP_RFM_MAKDIR + +Creates a directory (including intermediate directories). + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_MAKDIR ($03) +2 | Path number +3 | Process ID +4 | Parent process ID +5 | Mode byte (bit 2 $04 = execution directory context) +6-n | Pathname, terminated by $0D + +Server response: + +Byte | Value +-----------|---------- +0 | Error code (0 = success) + +### OP_RFM_CHGDIR + +Changes the CoCo process's current directory (data or execution, per the mode byte) to the given path. The pathname may use OS-9 multi-dot notation (".." = parent, "..." = grandparent, and so on -- N dots means N-1 levels up). + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_CHGDIR ($04) +2 | Path number +3 | Process ID +4 | Parent process ID +5 | Mode byte (bit 2 $04 = execution directory context) +6-n | Pathname, terminated by $0D + +Server response: + +Byte | Value +-----------|---------- +0 | Error code (0 = success, 216 = path not found) + +A pathname that would resolve above the Server's device root is clamped to that root rather than rejected. + +### OP_RFM_DELETE + +Deletes a file, or an empty directory. + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_DELETE ($05) +2 | Path number +3 | Process ID +4 | Parent process ID +5 | Mode byte (bit 2 $04 = execution directory context) +6-n | Pathname, terminated by $0D + +Server response: + +Byte | Value +-----------|---------- +0 | Error code (0 = success, 214 = access denied/escapes root, 215 = directory not empty, 216 = not found) + +### OP_RFM_SEEK + +Sets the current file position for an already-open path. + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_SEEK ($06) +2 | Path number +3 | Bits 31-24 of the new 32-bit file position +4 | Bits 23-16 of the new file position +5 | Bits 15-8 of the new file position +6 | Bits 7-0 of the new file position + +Server response: + +Byte | Value +-----------|---------- +0 | Error code (always 0) + +### OP_RFM_READ + +Reads up to the requested number of bytes from the current file position and advances the position by the number of bytes actually returned. + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_READ ($07) +2 | Path number +3 | Bits 15-8 of the maximum number of bytes requested +4 | Bits 7-0 of the maximum number of bytes requested + +Server response: + +Byte | Value +-----------|---------- +0 | Bits 15-8 of the number of bytes returned (0 if at end of file or the path is unknown) +1 | Bits 7-0 of the number of bytes returned +2-n | That many bytes of file data (omitted if the count is 0) + +A returned count of 0 indicates end of file; the CoCo should treat this as E$EOF. The Server waits briefly (on the order of 2 milliseconds) after sending the byte count and before sending the data payload, to give a software (bit-banger) serial driver on the CoCo time to prepare its receive buffer. + +### OP_RFM_WRITE + +Writes bytes to the current file position and advances the position. This is a uni-directional transaction; the Server does not acknowledge it. + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_WRITE ($08) +2 | Path number +3 | Bits 15-8 of the byte count +4 | Bits 7-0 of the byte count +5-n | Data bytes to write + +### OP_RFM_READLN + +Identical to OP_RFM_READ, except that the Server stops at the first line feed byte in the file (which it translates to a carriage return, $0D, in the returned data) or at the requested maximum, whichever comes first. + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_READLN ($09) +2 | Path number +3 | Bits 15-8 of the maximum number of bytes requested +4 | Bits 7-0 of the maximum number of bytes requested + +Server response: + +Byte | Value +-----------|---------- +0 | Bits 15-8 of the number of bytes returned (0 if at end of file or the path is unknown) +1 | Bits 7-0 of the number of bytes returned +2-n | Line data, including the trailing $0D if a line end was found (omitted if the count is 0) + +### OP_RFM_WRITLN + +Writes a single line to the current file position. This is a uni-directional transaction; the Server does not acknowledge it. The Server writes bytes up to (but not including) the first carriage return ($0D) or $FF byte in the supplied data, translating a terminating $0D to a line feed ($0A) on disk; any bytes after the terminator are considered stale buffer contents and discarded. + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_WRITLN ($0A) +2 | Path number +3 | Bits 15-8 of the byte count +4 | Bits 7-0 of the byte count +5-n | Data bytes, normally terminated by $0D + +### OP_RFM_GETSTT + +Retrieves status information about an open path. The request carries a GetStat code using the same SS.* code space as OP_GETSTAT/OP_SETSTAT elsewhere in this specification. + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_GETSTT ($0B) +2 | Path number +3 | GetStat code + +Currently supported GetStat codes: + +**SS.Size ($02)** -- returns the file's size as a 4-byte big-endian value. No further bytes are sent by the CoCo. + +Byte | Value +-----------|---------- +0-3 | File size, 32-bit big-endian + +**SS.Pos ($05)** -- returns the file's current position as a 4-byte big-endian value. No further bytes are sent by the CoCo. + +Byte | Value +-----------|---------- +0-3 | Current file position, 32-bit big-endian + +**SS.EOF ($06)** -- returns whether the path is at end of file. No further bytes are sent by the CoCo. + +Byte | Value +-----------|---------- +0 | 0 (not at EOF) or 211/E$EOF (at EOF) + +**SS.FD ($0F)** -- returns a synthesized OS-9 file descriptor for the path (see "Synthesized file descriptors" below). After the initial 4-byte GETSTT request, the CoCo sends 2 more bytes and the Server replies with the requested number of descriptor bytes (capped at 256): + +Byte | Value +-----------|---------- +0 | Bits 15-8 of the number of bytes requested +1 | Bits 7-0 of the number of bytes requested + +Server response: + +Byte | Value +-----------|---------- +0-n | Up to 256 bytes of synthesized file descriptor data + +**SS.FDInf ($20)** -- returns a synthesized OS-9 file descriptor for a directory entry previously returned by SS.FD, identified by its LSN rather than by an open path. After the initial 4-byte GETSTT request, the CoCo sends 4 more bytes: + +Byte | Value +-----------|---------- +0 | Bits 23-16 of the LSN +1 | Unused +2 | Bits 15-8 of the LSN +3 | Bits 7-0 of the LSN + +Server response: + +Byte | Value +-----------|---------- +0-255 | 256 bytes of synthesized file descriptor data + +Any other GetStat code is accepted but produces no response payload. + +### OP_RFM_SETSTT + +Currently a no-op: the Server consumes only the 2-byte OP_RFM/sub-opcode header and sends no response and expects no further bytes from the CoCo. + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_SETSTT ($0C) + +### OP_RFM_CLOSE + +Closes a path and releases its descriptor. + +CoCo sends: + +Byte | Value +-----------|---------- +0 | OP_RFM ($D6) +1 | OP_RFM_CLOSE ($0D) +2 | Path number + +Server response: + +Byte | Value +-----------|---------- +0 | Error code (always 0) + +### Synthesized directory listings + +When a path is opened as a directory (mode bit $80 set on OPEN), the Server synthesizes its contents as a sequence of 32-byte OS-9 style directory entries readable via OP_RFM_READ: + +Byte | Value +-----------|---------- +0-28 | Entry name, up to 29 characters, with the high bit of the last character set to mark the end of the name +29-31 | A 24-bit LSN identifying this entry, for use with GETSTT/SS.FDInf + +The first two entries are always "." (the directory itself) and ".." (its parent); at the Server's device root, ".." refers back to the root itself. The LSNs used here are Server-assigned identifiers for host paths and bear no relation to physical sector numbers; they exist only so that SS.FDInf can be used to retrieve a file descriptor for a directory entry without opening it as a path. + +### Synthesized file descriptors + +GETSTT/SS.FD and GETSTT/SS.FDInf both return a file descriptor sector synthesized from the underlying host file's attributes, in OS-9 FD format: + +Byte | Value +-----------|---------- +0 | FD.ATT: attribute byte. Bit 7 ($80) set if a directory. Bits 0-5 are owner read/write/execute and public read/write/execute, derived from the host file's POSIX permissions. +1-2 | FD.OWN: owner ID (always 0) +3-7 | FD.DAT: last modification date/time (year-1900, month, day, hour, minute) +8 | FD.LNK: link count (always 1) +9-12 | FD.SIZ: file size, 32-bit big-endian +13-15 | FD.Creat: creation date (year-1900, month, day) +16-n | FD.SEG: segment list; always zero, since RFM files have no physical sector allocation + ## WireBug Mode **Note that WireBug is not yet implemented. Looking for a cool project??** @@ -1373,7 +1727,7 @@ OP_SERWRITE | 195 (0xC3) | C+128 | DW 4.0.0 | OP_SERSETSTAT | 196 (0xC4) | D+128 | DW 4.0.0 | OP_SERTERM | 197 (0xC5) | E+128 | DW 4.0.5 | OP_READEX | 210 (0xD2) | R+128 | DW 3 | -OP_RFM | 214 (0xD6) | V+128 | N/A | +OP_RFM | 214 (0xD6) | V+128 | Unofficial | See "Remote File Manager (RFM)" section | OP_230K230K | 230 (0xE6) | | DW 4.0.0 | For 230k mode OP_REREADEX | 242 (0xF2) | r+128 | DW 3 | OP_RESET3 | 248 (0xF8) | | DW 4.0.0 | diff --git a/swift/DriveWire.xcodeproj/project.pbxproj b/swift/DriveWire.xcodeproj/project.pbxproj index 12fda32..932b4fb 100644 --- a/swift/DriveWire.xcodeproj/project.pbxproj +++ b/swift/DriveWire.xcodeproj/project.pbxproj @@ -517,7 +517,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 26; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.boisypitre.DriveWire; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -545,7 +545,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 26; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.boisypitre.DriveWire; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -560,7 +560,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 26; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.boisypitre.DriveWire.Tests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -576,7 +576,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 26; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.boisypitre.DriveWire.Tests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -591,7 +591,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; GCC_C_LANGUAGE_STANDARD = gnu11; - MACOSX_DEPLOYMENT_TARGET = 14.6; + MACOSX_DEPLOYMENT_TARGET = 26; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_VERSION = 5.0; @@ -603,7 +603,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; GCC_C_LANGUAGE_STANDARD = gnu11; - MACOSX_DEPLOYMENT_TARGET = 14.6; + MACOSX_DEPLOYMENT_TARGET = 26; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; diff --git a/swift/DriveWire.xcodeproj/xcshareddata/xcschemes/drivewire-cli.xcscheme b/swift/DriveWire.xcodeproj/xcshareddata/xcschemes/drivewire-cli.xcscheme new file mode 100644 index 0000000..06a4707 --- /dev/null +++ b/swift/DriveWire.xcodeproj/xcshareddata/xcschemes/drivewire-cli.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/Contents.json b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..693a8c3 100644 --- a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -3,52 +3,62 @@ { "idiom" : "mac", "scale" : "1x", - "size" : "16x16" + "size" : "16x16", + "filename" : "icon_16.png" }, { "idiom" : "mac", "scale" : "2x", - "size" : "16x16" + "size" : "16x16", + "filename" : "icon_16@2x.png" }, { "idiom" : "mac", "scale" : "1x", - "size" : "32x32" + "size" : "32x32", + "filename" : "icon_32.png" }, { "idiom" : "mac", "scale" : "2x", - "size" : "32x32" + "size" : "32x32", + "filename" : "icon_32@2x.png" }, { "idiom" : "mac", "scale" : "1x", - "size" : "128x128" + "size" : "128x128", + "filename" : "icon_128.png" }, { "idiom" : "mac", "scale" : "2x", - "size" : "128x128" + "size" : "128x128", + "filename" : "icon_128@2x.png" }, { "idiom" : "mac", "scale" : "1x", - "size" : "256x256" + "size" : "256x256", + "filename" : "icon_256.png" }, { "idiom" : "mac", "scale" : "2x", - "size" : "256x256" + "size" : "256x256", + "filename" : "icon_256@2x.png" }, { "idiom" : "mac", "scale" : "1x", - "size" : "512x512" + "size" : "512x512", + "filename" : "icon_512.png" }, { "idiom" : "mac", "scale" : "2x", - "size" : "512x512" + "size" : "512x512", + "filename" : "icon_512@2x.png" } ], "info" : { diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_128.png b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..f7c1095 Binary files /dev/null and b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png new file mode 100644 index 0000000..0ee943e Binary files /dev/null and b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png differ diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_16.png b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..1c0937c Binary files /dev/null and b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png new file mode 100644 index 0000000..f6b0169 Binary files /dev/null and b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png differ diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_256.png b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..0ee943e Binary files /dev/null and b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png new file mode 100644 index 0000000..7bfcdf6 Binary files /dev/null and b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png differ diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_32.png b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..f6b0169 Binary files /dev/null and b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png new file mode 100644 index 0000000..a3fe53a Binary files /dev/null and b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png differ diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_512.png b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..7bfcdf6 Binary files /dev/null and b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png new file mode 100644 index 0000000..07866a6 Binary files /dev/null and b/swift/DriveWire/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png differ diff --git a/swift/DriveWire/ContentView.swift b/swift/DriveWire/ContentView.swift index f531753..3824e14 100644 --- a/swift/DriveWire/ContentView.swift +++ b/swift/DriveWire/ContentView.swift @@ -7,289 +7,1080 @@ import SwiftUI import ORSSerial +import AppKit +import UniformTypeIdentifiers -struct VirtualChannelView : View { - var channelNumber : Int - var body : some View { - HStack { - Label("Channel \(channelNumber)", systemImage: "") - LEDView() +private enum DriveWirePalette { + static let canvasTop = Color(red: 0.08, green: 0.11, blue: 0.12) + static let canvasBottom = Color(red: 0.04, green: 0.05, blue: 0.06) + static let panel = Color(red: 0.15, green: 0.18, blue: 0.18) + static let panelElevated = Color(red: 0.18, green: 0.21, blue: 0.21) + static let panelMuted = Color(red: 0.20, green: 0.23, blue: 0.23) + static let accent = Color(red: 0.28, green: 0.77, blue: 0.61) + static let accentMuted = Color(red: 0.16, green: 0.39, blue: 0.33) + static let border = Color.white.opacity(0.08) + static let softText = Color.white.opacity(0.64) +} + +private func serialPortDisplayName(_ path: String) -> String { + guard !path.isEmpty, path != "NONE" else { + return "No serial device selected" + } + + let lastComponent = URL(fileURLWithPath: path).lastPathComponent + if let trimmed = lastComponent.split(separator: ".", maxSplits: 1).last { + return String(trimmed) + } + return lastComponent +} + +private struct WindowFramePersistenceAccessor: NSViewControllerRepresentable { + let fileURL: URL? + let onWindowWillClose: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSViewController(context: Context) -> TrackingViewController { + let controller = TrackingViewController() + controller.onWindowChange = { window in + context.coordinator.attach(to: window, fileURL: fileURL, onWindowWillClose: onWindowWillClose) + } + return controller + } + + func updateNSViewController(_ controller: TrackingViewController, context: Context) { + controller.onWindowChange = { window in + context.coordinator.attach(to: window, fileURL: fileURL, onWindowWillClose: onWindowWillClose) + } + controller.reportCurrentWindow() + } + + final class Coordinator { + private weak var window: NSWindow? + private var observers: [NSObjectProtocol] = [] + private var frameKey: String? + private var hasRestoredFrame = false + + deinit { + removeObservers() + } + + func attach(to window: NSWindow?, fileURL: URL?, onWindowWillClose: @escaping () -> Void) { + guard let window else { + return + } + + if fileURL == nil { + let needsReattach = self.window !== window || self.frameKey != nil + if needsReattach { + removeObservers() + self.window = window + self.frameKey = nil + hasRestoredFrame = false + } + forceWindowFrame(window) + return + } + + let frameKey = Self.frameKey(for: fileURL) + let needsReattach = self.window !== window || self.frameKey != frameKey + guard needsReattach else { + if !hasRestoredFrame { + restoreFrameIfNeeded(for: window, key: frameKey) + } + return + } + + removeObservers() + self.window = window + self.frameKey = frameKey + hasRestoredFrame = false + + restoreFrameIfNeeded(for: window, key: frameKey) + + let center = NotificationCenter.default + observers = [ + center.addObserver(forName: NSWindow.didMoveNotification, object: window, queue: .main) { [weak self] _ in + self?.saveFrame() + }, + center.addObserver(forName: NSWindow.didEndLiveResizeNotification, object: window, queue: .main) { [weak self] _ in + self?.saveFrame() + }, + center.addObserver(forName: NSWindow.didDeminiaturizeNotification, object: window, queue: .main) { [weak self] _ in + self?.saveFrame() + }, + center.addObserver(forName: NSWindow.willCloseNotification, object: window, queue: .main) { [weak self] _ in + self?.saveFrame() + onWindowWillClose() + } + ] + } + + private func restoreFrameIfNeeded(for window: NSWindow, key: String?) { + guard !hasRestoredFrame else { + return + } + hasRestoredFrame = true + + let minimumAcceptedSize = NSSize(width: 1360, height: 1180) + + if let key, let frameString = UserDefaults.standard.string(forKey: key) { + let frame = NSRectFromString(frameString) + let isLargeEnough = frame.width >= minimumAcceptedSize.width && frame.height >= minimumAcceptedSize.height + if isLargeEnough { + DispatchQueue.main.async { + window.setFrame(frame, display: true) + } + return + } + UserDefaults.standard.removeObject(forKey: key) + } + + forceWindowFrame(window) + } + + private func forceWindowFrame(_ window: NSWindow) { + let minimumSize = NSSize(width: 1240, height: 1180) + let targetSize = NSSize(width: 1480, height: 1260) + + DispatchQueue.main.async { + window.minSize = minimumSize + window.contentMinSize = minimumSize + window.setContentSize(targetSize) + + let visibleFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero + let origin: NSPoint + if visibleFrame == .zero { + origin = window.frame.origin + } else { + origin = NSPoint( + x: visibleFrame.midX - (targetSize.width / 2), + y: visibleFrame.midY - (targetSize.height / 2) + ) + } + + let frame = NSRect(origin: origin, size: targetSize) + window.setFrame(frame, display: true, animate: false) + } + } + + private func saveFrame() { + guard let window, let frameKey else { + return + } + + let frameString = NSStringFromRect(window.frame) + UserDefaults.standard.set(frameString, forKey: frameKey) + } + + private func removeObservers() { + let center = NotificationCenter.default + for observer in observers { + center.removeObserver(observer) + } + observers.removeAll() + } + + private static func frameKey(for fileURL: URL?) -> String? { + guard let fileURL else { + return nil + } + + let encodedPath = Data(fileURL.standardizedFileURL.path.utf8).base64EncodedString() + let sanitized = encodedPath + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + return "DriveWireDocumentFrame-\(sanitized)" + } + } + + final class TrackingViewController: NSViewController { + var onWindowChange: ((NSWindow?) -> Void)? + + override func loadView() { + view = NSView(frame: .zero) + } + + override func viewWillAppear() { + super.viewWillAppear() + reportCurrentWindow() + } + + override func viewDidAppear() { + super.viewDidAppear() + reportCurrentWindow() + } + + func reportCurrentWindow() { + onWindowChange?(view.window) } } } -struct VirtualChannelsView : View { - var body : some View { - GroupBox(label: - Label("Virtual Serial Channels", systemImage: - "bolt.horizontal.fill") - ) { - ScrollView { - VirtualChannelView(channelNumber: 0) - VirtualChannelView(channelNumber: 1) - VirtualChannelView(channelNumber: 2) - VirtualChannelView(channelNumber: 3) - VirtualChannelView(channelNumber: 4) - VirtualChannelView(channelNumber: 5) - VirtualChannelView(channelNumber: 6) - VirtualChannelView(channelNumber: 7) - } - }.padding(10) +struct DriveSlot: Identifiable { + let driveNumber: Int + + var id: Int { driveNumber } +} + +struct StatisticItem: Identifiable { + let title: String + let value: String + + var id: String { title } +} + +struct StatTile: Identifiable { + let title: String + let value: String + let tint: Color + + var id: String { title } +} + +struct DashboardSection: View { + let eyebrow: String + let title: String + let detail: String? + @ViewBuilder var content: Content + + init(eyebrow: String, title: String, detail: String? = nil, @ViewBuilder content: () -> Content) { + self.eyebrow = eyebrow + self.title = title + self.detail = detail + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(eyebrow.uppercased()) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(DriveWirePalette.accent) + .tracking(1.2) + Text(title) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + if let detail, !detail.isEmpty { + Text(detail) + .font(.callout) + .foregroundStyle(DriveWirePalette.softText) + } + } + + content + } + .padding(15) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill( + LinearGradient( + colors: [DriveWirePalette.panelElevated, DriveWirePalette.panel], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(DriveWirePalette.border, lineWidth: 1) + ) + } +} + +struct StatusPill: View { + let text: String + let color: Color + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(text) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Capsule(style: .continuous) + .fill(color.opacity(0.18)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(color.opacity(0.3), lineWidth: 1) + ) + } +} + +struct DashboardMetricBadge: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label.uppercased()) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(DriveWirePalette.softText) + .tracking(0.8) + Text(value) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(DriveWirePalette.panelMuted.opacity(0.95)) + ) + } +} + +struct MetricTile: View { + let title: String + let value: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + RoundedRectangle(cornerRadius: 999, style: .continuous) + .fill(tint.opacity(0.22)) + .frame(width: 28, height: 6) + .overlay( + RoundedRectangle(cornerRadius: 999, style: .continuous) + .stroke(tint.opacity(0.35), lineWidth: 1) + ) + + Text(value) + .font(.system(size: 19, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text(title) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(DriveWirePalette.softText) + } + .padding(10) + .frame(maxWidth: .infinity, minHeight: 76, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(DriveWirePalette.panelMuted) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(DriveWirePalette.border, lineWidth: 1) + ) + } +} + +struct LabelValueRow: View { + let title: String + let value: String + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .foregroundStyle(DriveWirePalette.softText) + Spacer(minLength: 16) + Text(value) + .foregroundStyle(.white) + .multilineTextAlignment(.trailing) + } + .font(.system(size: 12, weight: .medium, design: .rounded)) + } +} + +struct VirtualChannelView: View { + let channelNumber: Int + + var body: some View { + HStack(spacing: 10) { + LEDView(isOn: false, activeColor: DriveWirePalette.accent) + .frame(width: 9, height: 9) + Text("Channel \(channelNumber)") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + Spacer(minLength: 8) + Text("Idle") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(DriveWirePalette.softText) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(DriveWirePalette.panelMuted) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(DriveWirePalette.border, lineWidth: 1) + ) + } +} + +struct VirtualChannelsView: View { + private let channels = Array(0...7) + private let columns = [ + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10) + ] + + var body: some View { + DashboardSection(eyebrow: "Monitoring", title: "Virtual Channels", detail: "Compact status for the eight guest channels.") { + LazyVGrid(columns: columns, spacing: 10) { + ForEach(channels, id: \.self) { channel in + VirtualChannelView(channelNumber: channel) + } + } + } + } +} + +struct DriveRowView: View { + let driveNumber: Int + let imagePath: String? + let onChoose: () -> Void + let onEject: () -> Void + + private var displayName: String { + guard let imagePath, !imagePath.isEmpty else { + return "Empty Slot" + } + + return URL(fileURLWithPath: imagePath).lastPathComponent + } + + private var detailText: String { + guard let imagePath, !imagePath.isEmpty else { + return "No image mounted" + } + + let fileURL = URL(fileURLWithPath: imagePath) + let sizeText: String + if let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), + let fileSize = values.fileSize { + sizeText = ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file) + } else { + sizeText = "Unknown size" + } + + return "\(sizeText) • \(imagePath)" + } + + private var statusText: String { + imagePath == nil ? "Ready" : "Mounted" + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(imagePath == nil ? DriveWirePalette.accentMuted.opacity(0.5) : DriveWirePalette.accent.opacity(0.2)) + .frame(width: 44, height: 44) + .overlay( + Image(systemName: imagePath == nil ? "externaldrive.badge.plus" : "externaldrive.fill") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(imagePath == nil ? DriveWirePalette.softText : DriveWirePalette.accent) + ) + + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .center) { + Text("Drive \(driveNumber)") + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Spacer(minLength: 12) + StatusPill(text: statusText, color: imagePath == nil ? .gray : DriveWirePalette.accent) + } + + Text(displayName) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + Text(detailText) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(DriveWirePalette.softText) + .textSelection(.enabled) + .lineLimit(2) + } + + Spacer(minLength: 12) + + VStack(spacing: 8) { + Button(imagePath == nil ? "Mount" : "Replace", action: onChoose) + .buttonStyle(.borderedProminent) + .tint(DriveWirePalette.accentMuted) + Button("Eject", role: .destructive, action: onEject) + .buttonStyle(.bordered) + .tint(imagePath == nil ? DriveWirePalette.softText : Color(red: 0.95, green: 0.42, blue: 0.42)) + .disabled(imagePath == nil) + } + .controlSize(.small) + } + .padding(13) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(DriveWirePalette.panelMuted) + ) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(DriveWirePalette.border, lineWidth: 1) + ) } } -struct DriveSelector: View { - let buttonSize = 50.0 - @Binding var selectedDisk : String +struct DrivesPanelView: View { + @Binding var document: DriveWireDocument + + private let slots = (0..<4).map(DriveSlot.init) + + private var activeHost: DriveWireHost { + document.connectionType == .serial ? document.serialDriver.host : document.tcpDriver.host + } + + private var mountedCount: Int { + activeHost.virtualDrives.count + } + var body: some View { - HStack { - Button(action: { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - if panel.runModal() == .OK { - self.selectedDisk = panel.url?.relativePath ?? "" + DashboardSection( + eyebrow: "Storage", + title: "Virtual Disks", + detail: "\(mountedCount) of \(slots.count) slots mounted." + ) { + VStack(spacing: 10) { + ForEach(slots) { slot in + DriveRowView( + driveNumber: slot.driveNumber, + imagePath: imagePath(for: slot.driveNumber), + onChoose: { chooseDisk(for: slot.driveNumber) }, + onEject: { ejectDisk(slot.driveNumber) } + ) } - }) { - Image(systemName: "externaldrive") - }.frame(width: buttonSize, height: buttonSize) - .font(.system(size: buttonSize * 0.7)) - .foregroundColor(Color.white) - .background(Color.blue) - .cornerRadius(buttonSize / 6.0) - .buttonStyle(PlainButtonStyle()) - VStack { - TextField("Disk Image", text: $selectedDisk).padding(10) + + Spacer(minLength: 0) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func imagePath(for driveNumber: Int) -> String? { + activeHost.virtualDrives.first(where: { $0.driveNumber == driveNumber })?.imagePath + } + + private func chooseDisk(for driveNumber: Int) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.allowedContentTypes = [ + UTType(filenameExtension: "dsk"), + UTType(filenameExtension: "img") + ].compactMap { $0 } + + guard panel.runModal() == .OK, let path = panel.url?.path(percentEncoded: false) else { + return + } + + do { + try activeHost.insertVirtualDisk(driveNumber: driveNumber, imagePath: path) + } catch { + activeHost.log += "Failed to mount drive \(driveNumber): \(error.localizedDescription)\n" + } + } + + private func ejectDisk(_ driveNumber: Int) { + activeHost.ejectVirtualDisk(driveNumber: driveNumber) + } +} + +struct InspectorField: View { + let label: String + @ViewBuilder var content: Content + + init(_ label: String, @ViewBuilder content: () -> Content) { + self.label = label + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(label.uppercased()) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(DriveWirePalette.softText) + .tracking(0.8) + content } } } struct SerialPortSelector: View { - @Binding var selectedPortName : String - @Binding var selectedBaudRate : String - - var body: some View { - VStack { - Picker("Serial port:", selection: $selectedPortName) { - Text("No device").tag("NONE") - ForEach(ObservableSerialPortManager().availablePorts, id: \.self) { port in - Text(port.name).tag(port.name) - } - } - Picker("Baud rate:", selection: $selectedBaudRate) { - ForEach(["57600", "115200", "230400"], id: \.self) { baud in - Text(baud).tag(baud) - } - } + @Binding var selectedPortName: String + @Binding var selectedBaudRate: String + @StateObject private var portManager = ObservableSerialPortManager() + + var body: some View { + HStack(alignment: .top, spacing: 14) { + InspectorField("Serial Port") { + Picker("Serial Port", selection: $selectedPortName) { + Text("No device") + .tag("NONE") + ForEach(portManager.availablePorts, id: \.self) { port in + Text(port.name) + .tag(port.path) + } + } + .labelsHidden() + .pickerStyle(.menu) + .tint(.white) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + + InspectorField("Baud Rate") { + Picker("Baud Rate", selection: $selectedBaudRate) { + ForEach(["57600", "115200", "230400"], id: \.self) { baud in + Text(baud) + .tag(baud) + } + } + .labelsHidden() + .pickerStyle(.menu) + .tint(.white) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(width: 160, alignment: .topLeading) } } } struct IPAddressSelector: View { - @Binding var selectedIPAddress : String - @Binding var selectedIPPort : String - let labelWidth = 80.0 - - var body: some View { - VStack { - HStack { - Text("IP Address:").frame(width: labelWidth, alignment: .trailing) - TextField("", text: $selectedIPAddress) - } - HStack { - Text("IP Port:").frame(width: labelWidth, alignment: .trailing) - TextField("", text: $selectedIPPort) - } + @Binding var selectedIPAddress: String + @Binding var selectedIPPort: String + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + InspectorField("IP Address") { + TextField("127.0.0.1", text: $selectedIPAddress) + .textFieldStyle(.roundedBorder) + } + InspectorField("Port") { + TextField("6809", text: $selectedIPPort) + .textFieldStyle(.roundedBorder) + } } } } -struct StatisticsView: View { - @Binding var document: DriveWireDocument +struct StatisticsGridView: View { + let statistics: DriveWireStatistics + + private var tiles: [StatTile] { + [ + StatTile(title: "Last Opcode", value: hex(statistics.lastOpCode), tint: DriveWirePalette.accent), + StatTile(title: "Last LSN", value: String(statistics.lastLSN), tint: .blue), + StatTile(title: "Sectors Read", value: String(statistics.readCount), tint: .mint), + StatTile(title: "Sectors Written", value: String(statistics.writeCount), tint: .orange), + StatTile(title: "Reads OK", value: "\(statistics.percentReadsOK)%", tint: .green), + StatTile(title: "Writes OK", value: "\(statistics.percentWritesOK)%", tint: .yellow) + ] + } + + private var secondaryStatistics: [StatisticItem] { + [ + StatisticItem(title: "Last Drive", value: String(statistics.lastDriveNumber)), + StatisticItem(title: "Last GetStat", value: hex(statistics.lastGetStat)), + StatisticItem(title: "Last SetStat", value: hex(statistics.lastSetStat)), + StatisticItem(title: "Read Retries", value: String(statistics.reReadCount)), + StatisticItem(title: "Write Retries", value: String(statistics.reWriteCount)), + StatisticItem(title: "Last Error", value: hex(statistics.lastError)), + StatisticItem(title: "Checksum", value: hex(statistics.lastCheckSum)) + ] + } + + private let primaryColumns = [ + GridItem(.adaptive(minimum: 150, maximum: 220), spacing: 10, alignment: .top) + ] + + private let secondaryColumns = [ + GridItem(.adaptive(minimum: 140, maximum: 220), spacing: 10, alignment: .top) + ] + + var body: some View { + DashboardSection(eyebrow: "Telemetry", title: "Runtime Snapshot", detail: "Live protocol and disk activity from the active host.") { + LazyVGrid(columns: primaryColumns, spacing: 10) { + ForEach(tiles) { tile in + MetricTile(title: tile.title, value: tile.value, tint: tile.tint) + } + } - var body: some View { - VStack { - let labelWidth = 100.0 - HStack { - HStack { - Text("Last Opcode:").frame(width: labelWidth, alignment: .trailing) - TextField("", value: $document.serialDriver.host.statistics.lastOpCode, formatter: NumberFormatter()) - } - HStack { - Text("Last LSN:").frame(width: labelWidth, alignment: .trailing) - TextField("", value: $document.serialDriver.host.statistics.lastLSN, formatter: NumberFormatter()) - } - } - HStack { - HStack { - Text("Sectors Read:").frame(width: labelWidth, alignment: .trailing) - TextField("", value: $document.serialDriver.host.statistics.readCount, formatter: NumberFormatter()) - } - HStack { - Text("Sectors Written:").frame(width: labelWidth, alignment: .trailing) - TextField("", value: $document.serialDriver.host.statistics.writeCount, formatter: NumberFormatter()) - } - } - HStack { - HStack { - Text("Last GetStat:").frame(width: labelWidth, alignment: .trailing) - TextField("", value: $document.serialDriver.host.statistics.lastGetStat, formatter: NumberFormatter()) - } - HStack { - Text("Last SetStat:").frame(width: labelWidth, alignment: .trailing) - TextField("", value: $document.serialDriver.host.statistics.lastSetStat, formatter: NumberFormatter()) - } - } - HStack { - HStack { - Text("Read Retries:").frame(width: labelWidth, alignment: .trailing) - TextField("", value: $document.serialDriver.host.statistics.reReadCount, formatter: NumberFormatter()) - } - HStack { - Text("Write Retries:").frame(width: labelWidth, alignment: .trailing) - TextField("", value: $document.serialDriver.host.statistics.reWriteCount, formatter: NumberFormatter()) - } - } - HStack { - HStack { - Text("% Reads OK:").frame(width: labelWidth, alignment: .trailing) - TextField("", value: $document.serialDriver.host.statistics.percentReadsOK, formatter: NumberFormatter()) - } - HStack { - Text("% Writes OK:").frame(width: labelWidth, alignment: .trailing) - TextField("", value: $document.serialDriver.host.statistics.percentWritesOK, formatter: NumberFormatter()) - } - } - } + LazyVGrid(columns: secondaryColumns, spacing: 10) { + ForEach(secondaryStatistics) { item in + DashboardMetricBadge(label: item.title, value: item.value) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(DriveWirePalette.panelMuted) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(DriveWirePalette.border, lineWidth: 1) + ) + } + } + + private func hex(_ value: T) -> String { + "0x" + String(value, radix: 16, uppercase: true) } } -struct SerialCommsView : View { +struct SerialCommsView: View { @Binding var document: DriveWireDocument + @ObservedObject var serialDriver: DriveWireSerialDriver @Binding var portName: String @Binding var baudRate: String + @State private var hasInitializedSelection = false + @State private var isRestoringSelection = false + + private func applySerialSelection(portSelection: String? = nil, baudSelection: String? = nil) { + let nextBaud = Int(baudSelection ?? baudRate) ?? serialDriver.baudRate + let nextPort = (portSelection ?? portName) == "NONE" ? "" : (portSelection ?? portName) + + serialDriver.baudRate = nextBaud + serialDriver.portName = nextPort + + // Re-assign the driver so the document registers the configuration change for saving. + document.serialDriver = serialDriver + } + + private var statusText: String { + let selectedPort = portName == "NONE" ? "" : portName + if selectedPort.isEmpty { + return "Serial Idle" + } + if serialDriver.isConnected { + return "Connected to \(serialPortDisplayName(serialDriver.portName))" + } + return "Select a device to connect, or choose No device to disconnect" + } var body: some View { - SerialPortSelector(selectedPortName: $portName, selectedBaudRate: $baudRate).onChange(of: portName) { oldValue, newValue in - document.serialDriver.portName = newValue - }.onChange(of: baudRate) { oldValue, newValue in - document.serialDriver.baudRate = Int(newValue)! - }.onDisappear(perform: { - document.serialDriver.stop() - }).onAppear(perform: { - portName = document.serialDriver.portName + VStack(alignment: .leading, spacing: 16) { + SerialPortSelector(selectedPortName: $portName, selectedBaudRate: $baudRate) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(DriveWirePalette.panelMuted) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(DriveWirePalette.border, lineWidth: 1) + ) + .onAppear { + guard !hasInitializedSelection else { + return + } + + hasInitializedSelection = true + isRestoringSelection = true + portName = document.serialDriver.portName.isEmpty ? "NONE" : document.serialDriver.portName baudRate = String(document.serialDriver.baudRate) - }).padding(10) + + DispatchQueue.main.async { + isRestoringSelection = false + } + } + .onChange(of: portName) { _, newValue in + guard !isRestoringSelection else { + return + } + applySerialSelection(portSelection: newValue) + } + .onChange(of: baudRate) { _, newValue in + guard !isRestoringSelection else { + return + } + applySerialSelection(baudSelection: newValue) + } } } -struct TCPCommsView : View { +struct TCPCommsView: View { @Binding var document: DriveWireDocument @Binding var ipAddress: String @Binding var ipPort: String + private var statusText: String { + document.tcpDriver.connected ? "Connected to \(document.tcpDriver.ipAddress):\(document.tcpDriver.ipPort)" : "Disconnected" + } + var body: some View { - IPAddressSelector(selectedIPAddress: $ipAddress, selectedIPPort: $ipPort) + VStack(alignment: .leading, spacing: 16) { + LabelValueRow(title: "Status", value: statusText) + IPAddressSelector(selectedIPAddress: $ipAddress, selectedIPPort: $ipPort) + + HStack(spacing: 10) { + Button("Connect") { + document.tcpDriver.ipAddress = ipAddress + document.tcpDriver.ipPort = UInt32(ipPort) ?? document.tcpDriver.ipPort + } + .buttonStyle(.borderedProminent) + .tint(DriveWirePalette.accentMuted) + + Button("Disconnect") { + document.tcpDriver.stop() + } + .buttonStyle(.bordered) + .tint(.white) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(DriveWirePalette.panelMuted) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(DriveWirePalette.border, lineWidth: 1) + ) + .onAppear { + ipAddress = document.tcpDriver.ipAddress + ipPort = String(document.tcpDriver.ipPort) + } } } -struct ContentView: View { +struct ConnectionPanelView: View { @Binding var document: DriveWireDocument - @State var selectedName = "NONE" - @State var selectedBaud = "57600" - @State var selectedIPAddress = "192.168.0.10" - @State var selectedIPPort = "6809" - @State var selectedDisk0 = "" - @State var selectedDisk1 = "" - @State var selectedDisk2 = "" - @State var selectedDisk3 = "" + @ObservedObject var serialDriver: DriveWireSerialDriver + @Binding var selectedPortName: String + @Binding var selectedBaudRate: String + @Binding var selectedIPAddress: String + @Binding var selectedIPPort: String + + private var isConnected: Bool { + switch document.connectionType { + case .serial: + return serialDriver.isConnected + case .network: + return document.tcpDriver.connected + } + } + + private var statusLabel: String { + switch document.connectionType { + case .serial: + let selectedPort = selectedPortName == "NONE" ? "" : selectedPortName + if serialDriver.isConnected { + return "Serial on \(serialPortDisplayName(serialDriver.portName))" + } + if selectedPort.isEmpty { + return "Serial Idle" + } + return "Serial selected: \(serialPortDisplayName(selectedPort))" + case .network: + return document.tcpDriver.connected ? "Network Connected" : "Network Idle" + } + } + + private var statusDetail: String { + switch document.connectionType { + case .serial: + return "Configure a physical serial link to serve the active guest." + case .network: + return "Connect to a DriveWire guest over TCP with the active document settings." + } + } var body: some View { - HStack { - GroupBox(label: - Label("Disks", systemImage: "externaldrive") - ) { - HStack { - VStack { - let drives: [DriveSelector] = [ - DriveSelector(selectedDisk: $selectedDisk0), - DriveSelector(selectedDisk: $selectedDisk1), - DriveSelector(selectedDisk: $selectedDisk2), - DriveSelector(selectedDisk: $selectedDisk3) - ] - - drives[0] - .onChange(of: selectedDisk0) { oldValue, newValue in - do { - try document.serialDriver.host.insertVirtualDisk(driveNumber: 0, imagePath: newValue) - } catch { - - } - }.onAppear(perform: { - if document.serialDriver.host.virtualDrives.count > 0 { - let diskName = document.serialDriver.host.virtualDrives[0].imagePath - selectedDisk0 = diskName - } - }) - - drives[1] - .onChange(of: selectedDisk1) { oldValue, newValue in - do { - try document.serialDriver.host.insertVirtualDisk(driveNumber: 1, imagePath: newValue) - } catch { - } - } - } + DashboardSection(eyebrow: "Console", title: "DriveWire Host", detail: statusDetail) { + HStack(alignment: .top, spacing: 16) { + HStack(spacing: 12) { + LEDView(isOn: isConnected, activeColor: DriveWirePalette.accent) + .frame(width: 14, height: 14) + Text(statusLabel) + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(2) } - }.padding(10) - GroupBox(label: - Label("Statistics", systemImage: "building.columns") - ) { - StatisticsView(document: $document) - }.padding(10) - } - - HStack{ - GroupBox(label: - Label("Communication", systemImage: "list.bullet") - ) { - Picker("Connection Type", selection: $document.connectionType) { - ForEach(DriveWireDocument.ConnectionType.allCases) { type in - Text(type.rawValue.capitalized).tag(type) - } + + Spacer(minLength: 0) + } + + if document.connectionType == .serial { + SerialCommsView(document: $document, serialDriver: document.serialDriver, portName: $selectedPortName, baudRate: $selectedBaudRate) + } else { + TCPCommsView(document: $document, ipAddress: $selectedIPAddress, ipPort: $selectedIPPort) + } + } + } +} + +struct LoggingPanelView: View { + @Binding var logText: String + @Binding var detailedOpcodeLogging: Bool + + private let logBottomID = "log-bottom" + + var body: some View { + DashboardSection(eyebrow: "Diagnostics", title: "Activity Log", detail: "Protocol traffic and host events for the active connection.") { + HStack(spacing: 10) { + Button("Clear") { + logText = "" } - .pickerStyle(.radioGroup) + .buttonStyle(.borderedProminent) + .tint(DriveWirePalette.accentMuted) - if document.connectionType == .serial { - // Show serial UI - SerialCommsView(document: $document, portName: $selectedName, baudRate: $selectedBaud) - } else { - // Show TCP/IP UI - TCPCommsView(document: $document, ipAddress: $selectedIPAddress, ipPort: $selectedIPPort) + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(logText, forType: .string) + } + .buttonStyle(.bordered) + .tint(.white) + + Toggle("Detailed opcodes", isOn: $detailedOpcodeLogging) + .toggleStyle(.switch) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .help("Includes frequent READ, WRITE, GETSTAT, and SETSTAT traffic in the activity log. Leave this off for normal performance.") + + Text("\(logText.split(separator: "\n").count) lines") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(DriveWirePalette.softText) + } + + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(logText.isEmpty ? " " : logText) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .topLeading) + .textSelection(.enabled) + Color.clear + .frame(height: 1) + .id(logBottomID) + } + .padding(12) + } + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.black.opacity(0.28)) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(DriveWirePalette.border, lineWidth: 1) + ) + .frame(maxWidth: .infinity, minHeight: 160, maxHeight: 220) + .onAppear { + proxy.scrollTo(logBottomID, anchor: .bottom) + } + .onChange(of: logText) { _, _ in + proxy.scrollTo(logBottomID, anchor: .bottom) } } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } +} - VirtualChannelsView().padding(10) +struct ContentView: View { + @Binding var document: DriveWireDocument + let fileURL: URL? + @State private var selectedPortName = "NONE" + @State private var selectedBaudRate = "57600" + @State private var selectedIPAddress = "127.0.0.1" + @State private var selectedIPPort = "6809" - }.padding(10) - - GroupBox(label: - Label("Logging", systemImage: "list.bullet") - ) { - TextEditor(text: $document.serialDriver.host.log) - }.padding(10) - + private var activeHost: DriveWireHost { + document.connectionType == .serial ? document.serialDriver.host : document.tcpDriver.host + } + + private var activeLogBinding: Binding { + Binding( + get: { activeHost.log }, + set: { activeHost.log = $0 } + ) + } + + var body: some View { + ZStack { + LinearGradient( + colors: [DriveWirePalette.canvasTop, DriveWirePalette.canvasBottom], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + HSplitView { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + ConnectionPanelView( + document: $document, + serialDriver: document.serialDriver, + selectedPortName: $selectedPortName, + selectedBaudRate: $selectedBaudRate, + selectedIPAddress: $selectedIPAddress, + selectedIPPort: $selectedIPPort + ) + + DrivesPanelView(document: $document) + } + .padding(12) + .frame(maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) + } + .scrollIndicators(.hidden) + .frame(minWidth: 390, idealWidth: 440, maxHeight: .infinity, alignment: .topLeading) + .background(Color.white.opacity(0.02)) + + ScrollView { + VStack(alignment: .leading, spacing: 14) { + StatisticsGridView(statistics: activeHost.statistics) + LoggingPanelView(logText: activeLogBinding, detailedOpcodeLogging: $document.detailedOpcodeLogging) + VirtualChannelsView() + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .scrollIndicators(.hidden) + .frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.black.opacity(0.1)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + .background { + WindowFramePersistenceAccessor(fileURL: fileURL) { + document.persistCurrentState(to: fileURL) + document.serialDriver.stop() + document.tcpDriver.stop() + } + .frame(width: 0, height: 0) + } + .padding(18) + } + .navigationTitle("DriveWire Host") } } -class ObservableSerialPortManager: ObservableObject { +final class ObservableSerialPortManager: NSObject, ObservableObject { @Published var availablePorts: [ORSSerialPort] = [] - private var portManager: ORSSerialPortManager + private let portManager: ORSSerialPortManager - init() { + override init() { portManager = ORSSerialPortManager.shared() - + super.init() + NotificationCenter.default.addObserver(self, selector: #selector(portsWereConnected(_:)), name: Notification.Name.ORSSerialPortsWereConnected, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(portsWereDisconnected(_:)), name: Notification.Name.ORSSerialPortsWereDisconnected, object: nil) @@ -313,8 +1104,6 @@ class ObservableSerialPortManager: ObservableObject { } } - - #Preview { - ContentView(document: .constant(DriveWireDocument())) +#Preview { + ContentView(document: .constant(DriveWireDocument()), fileURL: nil) } - diff --git a/swift/DriveWire/DriveWireApp.swift b/swift/DriveWire/DriveWireApp.swift index ca78b68..f02d166 100644 --- a/swift/DriveWire/DriveWireApp.swift +++ b/swift/DriveWire/DriveWireApp.swift @@ -8,12 +8,115 @@ import SwiftUI import AppIntents +private enum DriveWireWindowID { + static let newDocumentChooser = "new-document-chooser" +} + +private struct NewDocumentChooserView: View { + @Environment(\.newDocument) private var newDocument + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { + Text("New DriveWire Document") + .font(.system(size: 24, weight: .bold, design: .rounded)) + Text("Choose the transport before opening the main document window.") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } + + HStack(spacing: 12) { + transportButton( + title: "Serial", + detail: "Create a document bound to a physical serial link.", + connectionType: .serial + ) + + transportButton( + title: "Network", + detail: "Create a document bound to a TCP endpoint.", + connectionType: .network + ) + } + + HStack { + Spacer() + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + } + .padding(20) + .frame(width: 520) + } + + private func transportButton(title: String, detail: String, connectionType: DriveWireDocument.ConnectionType) -> some View { + Button { + newDocument(DriveWireDocument(connectionType: connectionType)) + dismiss() + } label: { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + Text(detail) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, minHeight: 110, alignment: .leading) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.secondary.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +private struct DriveWireCommands: Commands { + @Environment(\.openWindow) private var openWindow + + var body: some Commands { + CommandGroup(replacing: .newItem) { + Button("New DriveWire Document…") { + openWindow(id: DriveWireWindowID.newDocumentChooser) + } + .keyboardShortcut("n") + } + } +} + @main struct DriveWireApp: App { var body: some Scene { DocumentGroup(newDocument: DriveWireDocument()) { configuration in - ContentView(document: configuration.$document).frame(minWidth: 800, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity) + ContentView(document: configuration.$document, fileURL: configuration.fileURL) + .frame( + minWidth: 1240, + idealWidth: 1520, + maxWidth: .infinity, + minHeight: 1180, + idealHeight: 1260, + maxHeight: .infinity + ) + } + .windowResizability(.contentSize) + .defaultSize(width: 1520, height: 1260) + .commands { + DriveWireCommands() } + + Window("New DriveWire Document", id: DriveWireWindowID.newDocumentChooser) { + NewDocumentChooserView() + } + .windowResizability(.contentSize) } } - diff --git a/swift/DriveWire/DriveWireDocument.swift b/swift/DriveWire/DriveWireDocument.swift index f64e800..55c6f88 100644 --- a/swift/DriveWire/DriveWireDocument.swift +++ b/swift/DriveWire/DriveWireDocument.swift @@ -21,10 +21,44 @@ final class DriveWireDocument: FileDocument { var serialDriver: DriveWireSerialDriver var tcpDriver: DriveWireTCPDriver var connectionType: ConnectionType + var detailedOpcodeLogging: Bool + + init( + serialDriver: DriveWireSerialDriver, + tcpDriver: DriveWireTCPDriver, + connectionType: ConnectionType, + detailedOpcodeLogging: Bool = false + ) { + self.serialDriver = serialDriver + self.tcpDriver = tcpDriver + self.connectionType = connectionType + self.detailedOpcodeLogging = detailedOpcodeLogging + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + serialDriver = try container.decode(DriveWireSerialDriver.self, forKey: .serialDriver) + tcpDriver = try container.decode(DriveWireTCPDriver.self, forKey: .tcpDriver) + connectionType = try container.decode(ConnectionType.self, forKey: .connectionType) + detailedOpcodeLogging = try container.decodeIfPresent(Bool.self, forKey: .detailedOpcodeLogging) ?? false + } } - @Published var serialDriver = DriveWireSerialDriver() - @Published var tcpDriver = DriveWireTCPDriver() + @Published var serialDriver = DriveWireSerialDriver() { + didSet { + applyLoggingPreferences() + } + } + @Published var tcpDriver = DriveWireTCPDriver() { + didSet { + applyLoggingPreferences() + } + } + @Published var detailedOpcodeLogging = false { + didSet { + applyLoggingPreferences() + } + } enum ConnectionType: String, CaseIterable, Identifiable, Codable { case serial, network @@ -35,7 +69,14 @@ final class DriveWireDocument: FileDocument { static var readableContentTypes: [UTType] { [.exampleText] } - init() { + init(connectionType: ConnectionType = .serial) { + self.connectionType = connectionType + applyLoggingPreferences() + } + + private func applyLoggingPreferences() { + serialDriver.host.detailedOpcodeLogging = detailedOpcodeLogging + tcpDriver.host.detailedOpcodeLogging = detailedOpcodeLogging } struct ReloadVirtualDriveIntent: AppIntent { @@ -76,16 +117,42 @@ final class DriveWireDocument: FileDocument { self.serialDriver = model.serialDriver self.tcpDriver = model.tcpDriver self.connectionType = model.connectionType + self.detailedOpcodeLogging = model.detailedOpcodeLogging + applyLoggingPreferences() + if connectionType == .serial { + serialDriver.restoreConnectionIfNeeded() + } } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { let model = DriveWireDocumentModel( serialDriver: self.serialDriver, tcpDriver: self.tcpDriver, - connectionType: self.connectionType + connectionType: self.connectionType, + detailedOpcodeLogging: self.detailedOpcodeLogging ) let data = try JSONEncoder().encode(model) return .init(regularFileWithContents: data) } + + func persistCurrentState(to fileURL: URL?) { + guard let fileURL else { + return + } + + let model = DriveWireDocumentModel( + serialDriver: self.serialDriver, + tcpDriver: self.tcpDriver, + connectionType: self.connectionType, + detailedOpcodeLogging: self.detailedOpcodeLogging + ) + + do { + let data = try JSONEncoder().encode(model) + try data.write(to: fileURL, options: .atomic) + } catch { + serialDriver.log += "Failed to save document state: \(error.localizedDescription)\n" + } + } } diff --git a/swift/DriveWire/Drivers/DriveWireSerialDriver.swift b/swift/DriveWire/Drivers/DriveWireSerialDriver.swift index 93513cb..2f6d1be 100644 --- a/swift/DriveWire/Drivers/DriveWireSerialDriver.swift +++ b/swift/DriveWire/Drivers/DriveWireSerialDriver.swift @@ -6,6 +6,7 @@ // import ORSSerial +import Combine /// Provides a serial interface to a DriveWire host. /// @@ -28,25 +29,26 @@ class DriveWireSerialDriver : NSObject, DriveWireDelegate, ORSSerialPortDelegate /// A flag that when set to `true`, causes the driver to stop running. public var quit = false - /// A flag that when set to `true`, causes serial traffic to log. - public var logging = true - + /// A flag that when set to `true`, causes raw serial traffic hex dumps to log. + public var logging = false + + @Published public private(set) var isConnected = false + + private var isRestoringState = false private var serialPort : ORSSerialPort? /// The serial port associated with this driver. public var portName : String = "" { didSet { - if let sp = serialPort { - self.stop() - sp.close() + guard !isRestoringState else { + return } - - if let serialPort = ORSSerialPort(path: "/dev/cu." + self.portName) { - self.serialPort = serialPort - self.serialPort?.baudRate = NSNumber(value: self.baudRate) - serialPort.delegate = self - serialPort.open() + + guard oldValue != portName else { + return } + + connectIfPossible() } } @@ -62,14 +64,18 @@ class DriveWireSerialDriver : NSObject, DriveWireDelegate, ORSSerialPortDelegate @_documentation(visibility: private) internal func serialPortWasRemovedFromSystem(_ serialPort: ORSSerialPort) { + isConnected = false } @_documentation(visibility: private) internal func serialPortWasOpened(_ serialPort: ORSSerialPort) { + isConnected = true } @_documentation(visibility: private) internal func serialPort(_ serialPort: ORSSerialPort, didEncounterError error: Error) { + isConnected = false + log += "Serial error: \(error.localizedDescription)\n" print(error) } @@ -98,9 +104,39 @@ class DriveWireSerialDriver : NSObject, DriveWireDelegate, ORSSerialPortDelegate super.init() host = DriveWireHost(delegate: self) } + + func restoreConnectionIfNeeded() { + connectIfPossible() + } + + private static func serialPortPath(for selection: String) -> String? { + guard !selection.isEmpty else { + return nil + } + if selection.hasPrefix("/dev/") { + return selection + } + return "/dev/cu." + selection + } + + private func connectIfPossible() { + stop() + + guard let normalizedPath = Self.serialPortPath(for: portName), + let serialPort = ORSSerialPort(path: normalizedPath) else { + return + } + + self.serialPort = serialPort + serialPort.baudRate = NSNumber(value: baudRate) + serialPort.delegate = self + serialPort.open() + } required init(from decoder: Decoder) throws { super.init() + isRestoringState = true + defer { isRestoringState = false } do { let values = try decoder.container(keyedBy: CodingKeys.self) self.portName = try values.decode(String.self, forKey: .portName) @@ -123,9 +159,9 @@ class DriveWireSerialDriver : NSObject, DriveWireDelegate, ORSSerialPortDelegate } public func stop() { + isConnected = false self.serialPort?.delegate = nil self.serialPort?.close() self.serialPort = nil } } - diff --git a/swift/DriveWire/Drivers/DriveWireTCPDriver.swift b/swift/DriveWire/Drivers/DriveWireTCPDriver.swift index 69a9c31..3a7264d 100644 --- a/swift/DriveWire/Drivers/DriveWireTCPDriver.swift +++ b/swift/DriveWire/Drivers/DriveWireTCPDriver.swift @@ -6,6 +6,7 @@ // import Foundation +import Network /// Provides a TCP/IP interface to a DriveWire host. /// @@ -17,6 +18,7 @@ class DriveWireTCPDriver : NSObject, DriveWireDelegate, ObservableObject, Codabl private var outputStream: OutputStream? private var readBuffer = [UInt8](repeating: 0, count: 1024) private var streamQueue = DispatchQueue(label: "DriveWireTCP.StreamQueue") + private var isRestoringState = false @Published public var connected: Bool = false enum CodingKeys: String, CodingKey { @@ -33,12 +35,15 @@ class DriveWireTCPDriver : NSObject, DriveWireDelegate, ObservableObject, Codabl /// A flag that when set to `true`, causes the driver to stop running. public var quit = false - /// A flag that when set to `true`, causes serial traffic to log. - public var logging = true + /// A flag that when set to `true`, causes raw network traffic hex dumps to log. + public var logging = false /// The TCP/IP address associated with this driver. public var ipAddress: String = "" { didSet { + guard !isRestoringState else { + return + } if oldValue != ipAddress { reconnect() } @@ -48,6 +53,9 @@ class DriveWireTCPDriver : NSObject, DriveWireDelegate, ObservableObject, Codabl /// The TCP/IP port. public var ipPort: UInt32 = 6809 { didSet { + guard !isRestoringState else { + return + } if oldValue != ipPort { reconnect() } @@ -143,6 +151,8 @@ class DriveWireTCPDriver : NSObject, DriveWireDelegate, ObservableObject, Codabl required init(from decoder: Decoder) throws { super.init() + isRestoringState = true + defer { isRestoringState = false } do { let values = try decoder.container(keyedBy: CodingKeys.self) self.ipAddress = try values.decode(String.self, forKey: .ipAddress) @@ -193,3 +203,80 @@ extension DriveWireTCPDriver: StreamDelegate { } } } + +/// Provides a TCP server interface to a DriveWire host. +/// +/// Listens for an incoming TCP connection (e.g. from MAME's `-bitb socket.127.0.0.1:`) +/// and serves a DriveWire guest over that connection. +class DriveWireTCPServerDriver: NSObject, DriveWireDelegate, ObservableObject { + private var listener: NWListener? + private var connection: NWConnection? + + public var logging = false + public var host: DriveWireHost = DriveWireHost() + + func transactionCompleted(opCode: UInt8) {} + + func dataAvailable(host: DriveWireHost, data: Data) { + if logging { data.dump(prefix: "<-") } + connection?.send(content: data, completion: .idempotent) + } + + func start(port: UInt16) throws { + host.delegate = self + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw DriveWireHostError.nameNotFound + } + listener = try NWListener(using: .tcp, on: nwPort) + listener?.newConnectionHandler = { [weak self] conn in self?.accept(conn) } + listener?.stateUpdateHandler = { state in + if case .ready = state { + print("DriveWire TCP server listening on port \(port)") + } + } + listener?.start(queue: .main) + } + + private func accept(_ conn: NWConnection) { + connection?.cancel() + connection = conn + conn.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + print("DriveWire guest connected") + self?.receive(from: conn) + case .failed, .cancelled: + print("DriveWire guest disconnected") + self?.host.resetRFMState() + self?.connection = nil + default: + break + } + } + conn.start(queue: .main) + } + + private func receive(from conn: NWConnection) { + conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in + if let data = data, !data.isEmpty { + if self?.logging == true { data.dump(prefix: "->") } + var d = data + self?.host.send(data: &d) + } + if error == nil && !isComplete { + self?.receive(from: conn) + } else { + print("DriveWire guest disconnected") + self?.host.resetRFMState() + self?.connection = nil + } + } + } + + func stop() { + connection?.cancel() + listener?.cancel() + connection = nil + listener = nil + } +} diff --git a/swift/DriveWire/LEDView.swift b/swift/DriveWire/LEDView.swift index ef93e78..c30e5b9 100644 --- a/swift/DriveWire/LEDView.swift +++ b/swift/DriveWire/LEDView.swift @@ -7,10 +7,18 @@ import SwiftUI -struct LEDView : View { - var body : some View { +struct LEDView: View { + var isOn: Bool = true + var activeColor: Color = .red + + var body: some View { Circle() - .strokeBorder(Color.blue,lineWidth: 4) - .background(Circle().foregroundColor(Color.red)) + .fill(isOn ? activeColor : Color.secondary.opacity(0.25)) + .overlay( + Circle() + .strokeBorder(isOn ? activeColor.opacity(0.35) : Color.secondary.opacity(0.2), lineWidth: 4) + ) + .shadow(color: isOn ? activeColor.opacity(0.35) : .clear, radius: 4) + .accessibilityHidden(true) } } diff --git a/swift/DriveWire/Model/DriveWireHost.swift b/swift/DriveWire/Model/DriveWireHost.swift index 081f6b0..020db6c 100644 --- a/swift/DriveWire/Model/DriveWireHost.swift +++ b/swift/DriveWire/Model/DriveWireHost.swift @@ -64,9 +64,21 @@ public enum DriveWireProtocolError : Int { /// @Observable public class DriveWireHost : Codable { - var log : String = "" + private static let maximumLogCharacters = 24_000 + private static let trimmedLogPrefix = "... older log entries trimmed ...\n" + private static let emitConsoleOutput = false + + public var detailedOpcodeLogging = false + + private var logStorage = "" + + var log: String { + get { logStorage } + set { logStorage = Self.trimmedLog(from: newValue) } + } init() { + setupTransactions() } func reloadVirtualDrives() { @@ -74,6 +86,30 @@ public class DriveWireHost : Codable { vd.reload() } } + + private static func trimmedLog(from value: String) -> String { + guard value.count > maximumLogCharacters else { + return value + } + + let retainedCount = maximumLogCharacters - trimmedLogPrefix.count + guard retainedCount > 0 else { + return String(value.suffix(maximumLogCharacters)) + } + + return trimmedLogPrefix + value.suffix(retainedCount) + } + + private func reportActivity(_ message: String, isFrequent: Bool = false, isError: Bool = false) { + let shouldRecord = isError || !isFrequent || detailedOpcodeLogging + if shouldRecord { + logStorage = Self.trimmedLog(from: logStorage + message + "\n") + } + + if isError || (Self.emitConsoleOutput && shouldRecord) { + print(message) + } + } enum CodingKeys: String, CodingKey { case virtualDrives @@ -103,101 +139,220 @@ public class DriveWireHost : Codable { private var validateWithCRC = false private var fastwriteChannel : UInt8 = 0 private var processor : ((Data) -> Int)? + private var openVirtualSerialChannels = Set() + private var virtualSerialInput = [UInt8: Data]() + private var virtualSerialCommandBuffers = [UInt8: String]() + private var pendingClosedVirtualSerialChannels: [UInt8] = [] struct RFMPathDescriptor { var processID = 0 + var parentProcessID = 0 var pathNumber = 0 var pathDescriptorAddress = 0 var mode = 0 var filePosition = 0 - var pathnameLength = 0 var pathname = "" - var errorCode : UInt8 = 0 var localFile : FileHandle? = nil var fileContents : Data = Data() var attributes = [FileAttributeKey : Any]() - - mutating func openLocalFile() -> UInt8 { - var errorCode : UInt8 = 0 - - var localPathname = pathname - if (localPathname as NSString).isAbsolutePath == false { - // TODO: resolve relative paths + + mutating func openLocalFile(rootPath: String, shouldCreate: Bool = false) -> UInt8 { + guard !pathname.isEmpty && !pathname.contains("\0") else { return 216 } + + let expanded = RFMPathDescriptor.expandMultiDots(pathname) + let localPathname: String + if (expanded as NSString).isAbsolutePath { + localPathname = rootPath + expanded + } else { + localPathname = rootPath + "/" + expanded + } + let resolvedPath = URL(filePath: localPathname).standardized.path + let normalizedRoot = URL(filePath: rootPath).standardized.path + guard resolvedPath == normalizedRoot || resolvedPath.hasPrefix(normalizedRoot + "/") else { + return 214 // E$FNA — escaped above rfmRootPath (or above device root) } + do { - localPathname = "/Users/boisy" + localPathname - attributes = try FileManager().attributesOfItem(atPath: localPathname) - - // Determine if we're opening a directory + let fileExists = FileManager.default.fileExists(atPath: localPathname) if mode & 0x80 != 0 { - // We're expecting this to be a directory - if let fileType = attributes[FileAttributeKey.type] as? String, fileType != "NSFileTypeDirectory" { - errorCode = 214 + // Directory open — clamp '..' at the device root. + let effectiveLocalPath: String + if resolvedPath == normalizedRoot { + // Resolved to rfmRootPath itself — not a valid CoCo device directory. + // This happens when "." is opened with no CWD set (e.g. before any chd). + return 216 + } else { + effectiveLocalPath = resolvedPath } + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: effectiveLocalPath, isDirectory: &isDir), isDir.boolValue else { + return 216 + } + attributes = (try? FileManager.default.attributesOfItem(atPath: effectiveLocalPath)) ?? [:] + // Store the effective path so DriveWireHost can synthesize directory entries with proper LSNs. + self.pathname = effectiveLocalPath.hasPrefix(rootPath) + ? String(effectiveLocalPath.dropFirst(rootPath.count)) + : effectiveLocalPath } else { - localFile = try FileHandle(forReadingFrom: URL(filePath: localPathname)) - if let localFile = localFile { - fileContents = localFile.availableData + if !fileExists { + guard shouldCreate else { return 216 } + // Check parent directory is writable before creating + let parent = (localPathname as NSString).deletingLastPathComponent + guard FileManager.default.isWritableFile(atPath: parent) else { return 214 } + guard FileManager.default.createFile(atPath: localPathname, contents: nil) else { + return 216 + } + } else { + attributes = try FileManager.default.attributesOfItem(atPath: localPathname) + if attributes[.type] as? FileAttributeType == .typeDirectory { + return 214 // E$FNA — path is a directory, not a file + } + let needsWrite = (mode & 0x0A) != 0 || shouldCreate + if needsWrite && !FileManager.default.isWritableFile(atPath: localPathname) { + return 214 // E$FNA — write access denied + } + if !FileManager.default.isReadableFile(atPath: localPathname) { + return 214 // E$FNA — read access denied + } + } + let url = URL(filePath: localPathname) + let needsWrite = (mode & 0x0A) != 0 || shouldCreate + localFile = needsWrite + ? try FileHandle(forUpdating: url) + : try FileHandle(forReadingFrom: url) + if let f = localFile { + fileContents = f.availableData } } } catch { - errorCode = 216 + return 216 } - - return errorCode + return 0 } - mutating func readLineFromFile(offset : Int, maximumCount : Int) -> (UInt8, Data) { - var errorCode : UInt8 = 0 - var data = Data() - - var byte : UInt8 = 0 - repeat { - do { - if filePosition >= fileContents.count { - errorCode = 211 - break - } - byte = fileContents[filePosition] - filePosition = filePosition + 1 - if byte == 0x0A { - byte = 0x0D - } - data = data + Data([byte]) - } catch { - errorCode = 211 - } - } while filePosition < fileContents.count && data.count <= maximumCount && byte != 0x0D && errorCode == 0 + // Build one 32-byte OS-9 directory entry: name[0..28] with last char | 0x80, then 3-byte LSN. + static func makeOS9DirEntry(_ name: String, lsn: Int) -> Data { + var entry = Data(repeating: 0, count: 32) + let bytes = Array(name.utf8.prefix(29)) + for (i, b) in bytes.enumerated() { + entry[i] = i == bytes.count - 1 ? (b | 0x80) : b + } + entry[29] = UInt8((lsn >> 16) & 0xFF) + entry[30] = UInt8((lsn >> 8) & 0xFF) + entry[31] = UInt8(lsn & 0xFF) + return entry + } - return (errorCode, data) + // Expand OS-9 multi-dot path components: N dots = N-1 parent-dir references. + static func expandMultiDots(_ path: String) -> String { + return path.components(separatedBy: "/").map { c in + guard !c.isEmpty, c.allSatisfy({ $0 == "." }), c.count >= 2 else { return c } + return Array(repeating: "..", count: c.count - 1).joined(separator: "/") + }.joined(separator: "/") } - mutating func readFromFile(offset : Int, maximumCount : Int) -> (UInt8, Data) { - var errorCode : UInt8 = 0 + // Synthesize an OS-9 file descriptor sector from host file attributes. + // Layout: FD.ATT(1) FD.OWN(2) FD.DAT(5) FD.LNK(1) FD.SIZ(4) FD.Creat(3) FD.SEG(zeros...) + func synthesizeFD(count: Int) -> Data { + var fd = Data(repeating: 0, count: max(count, 16)) + let isDir = attributes[.type] as? FileAttributeType == .typeDirectory + + // FD.ATT: DIR=0x80, owner R/W/E = 0x07, public R = 0x08 + var att: UInt8 = isDir ? 0x8F : 0x0F + if let posix = attributes[.posixPermissions] as? Int { + att = isDir ? 0x80 : 0x00 + if posix & 0o400 != 0 { att |= 0x01 } // owner read + if posix & 0o200 != 0 { att |= 0x02 } // owner write + if posix & 0o100 != 0 { att |= 0x04 } // owner exec + if posix & 0o004 != 0 { att |= 0x08 } // public read + if posix & 0o002 != 0 { att |= 0x10 } // public write + if posix & 0o001 != 0 { att |= 0x20 } // public exec + } + fd[0] = att + + // FD.OWN (bytes 1-2): owner — 0 + // FD.DAT (bytes 3-7): modification date YYMMDDHHMM + if let mod = attributes[.modificationDate] as? Date { + let c = Calendar.current + fd[3] = UInt8(max(0, min(255, c.component(.year, from: mod) - 1900))) + fd[4] = UInt8(c.component(.month, from: mod)) + fd[5] = UInt8(c.component(.day, from: mod)) + fd[6] = UInt8(c.component(.hour, from: mod)) + fd[7] = UInt8(c.component(.minute, from: mod)) + } + // FD.LNK (byte 8): link count + fd[8] = 1 + // FD.SIZ (bytes 9-12): file size, big-endian + let sz = attributes[.size] as? Int ?? 0 + fd[9] = UInt8((sz >> 24) & 0xFF) + fd[10] = UInt8((sz >> 16) & 0xFF) + fd[11] = UInt8((sz >> 8) & 0xFF) + fd[12] = UInt8(sz & 0xFF) + // FD.Creat (bytes 13-15): creation date YYMMDD + if let cr = attributes[.creationDate] as? Date { + let c = Calendar.current + fd[13] = UInt8(max(0, min(255, c.component(.year, from: cr) - 1900))) + fd[14] = UInt8(c.component(.month, from: cr)) + fd[15] = UInt8(c.component(.day, from: cr)) + } + // FD.SEG (bytes 16+): segment list — all zeros for RFM (no physical sectors) + return Data(fd.prefix(count)) + } + + mutating func readLineFromFile(maximumCount: Int) -> (UInt8, Data) { + guard filePosition < fileContents.count else { return (211, Data()) } var data = Data() - - var byte : UInt8 = 0 - repeat { - do { - if filePosition >= fileContents.count { - errorCode = 211 - break - } - byte = fileContents[filePosition] - filePosition = filePosition + 1 - data = data + Data([byte]) - } catch { - errorCode = 211 - } - } while filePosition < fileContents.count && data.count < maximumCount && errorCode == 0 + while filePosition < fileContents.count && data.count < maximumCount { + var byte = fileContents[filePosition] + filePosition += 1 + if byte == 0x0A { byte = 0x0D } + data.append(byte) + if byte == 0x0D { break } + } + return (0, data) + } - return (errorCode, data) + mutating func readFromFile(maximumCount: Int) -> (UInt8, Data) { + guard filePosition < fileContents.count else { return (211, Data()) } + let end = min(filePosition + maximumCount, fileContents.count) + let data = Data(fileContents[filePosition.. UInt8 { + guard let file = localFile else { return 216 } + let bytesToWrite: Data + if translateCR { + // Content ends at first $0D (everything after is stale buffer data). + // Convert $0D → $0A, discard remainder. Also stop at $FF (OS-9 padding). + var out = Data() + for byte in data { + if byte == 0xFF { break } + if byte == 0x0D { out.append(0x0A); break } + out.append(byte) + } + bytesToWrite = out + } else { + bytesToWrite = data + } + file.seek(toFileOffset: UInt64(filePosition)) + file.write(bytesToWrite) + file.synchronizeFile() + filePosition += bytesToWrite.count + return 0 } } - var rfmWorkingDataDirectoryPathDescriptor = RFMPathDescriptor() - var rfmWorkingExecutionDirectoryPathDescriptor = RFMPathDescriptor() - var rfmPathDescriptor = RFMPathDescriptor() + var rfmRootPath: String = NSHomeDirectory() + private var rfmPaths: [Int: RFMPathDescriptor] = [:] + private var rfmCurrentDir: [Int: String] = [:] // data directory per process + private var rfmCurrentExecDir: [Int: String] = [:] // execution directory per process + // Maps host path ↔ unique LSN for synthesized RFM directory entries. + // LSNs start above a typical NitrOS-9 DW image (~524k sectors for DW format). + private var rfmLSNByPath: [String: Int] = [:] + private var rfmLSNToPath: [Int: String] = [:] + private var rfmLSNCounter: Int = 0x600000 /// The no-operation transaction code. /// @@ -406,8 +561,8 @@ public class DriveWireHost : Codable { dwTransaction.append(DWOp(opcode:OPNAMEOBJCREATE, processor: self.OP_NAMEOBJ_CREATE)) dwTransaction.append(DWOp(opcode:OPNOP, processor: self.OP_NOP)) dwTransaction.append(DWOp(opcode:OPTIME, processor: self.OP_TIME)) - dwTransaction.append(DWOp(opcode:OPINIT, processor: self.OP_INIT)) - dwTransaction.append(DWOp(opcode:OPTERM, processor: self.OP_TERM)) + dwTransaction.append(DWOp(opcode:0x49, processor: self.OP_INIT)) // OPINIT (historical) + dwTransaction.append(DWOp(opcode:0x54, processor: self.OP_TERM)) // OPTERM (historical) dwTransaction.append(DWOp(opcode:OPREAD, processor: self.OP_READ)) dwTransaction.append(DWOp(opcode:OPREADEX, processor: self.OP_READEX)) dwTransaction.append(DWOp(opcode:OPREREAD, processor: self.OP_REREAD)) @@ -416,8 +571,8 @@ public class DriveWireHost : Codable { dwTransaction.append(DWOp(opcode:OPREWRITE, processor: self.OP_REWRITE)) dwTransaction.append(DWOp(opcode:OPGETSTAT, processor: self.OP_GETSTAT)) dwTransaction.append(DWOp(opcode:OPSETSTAT, processor: self.OP_SETSTAT)) - dwTransaction.append(DWOp(opcode:OPRESET3, processor: self.OP_RESET)) - dwTransaction.append(DWOp(opcode:OPRESET2, processor: self.OP_RESET)) + dwTransaction.append(DWOp(opcode:0xF8, processor: self.OP_RESET)) // OPRESET3 (historical) + dwTransaction.append(DWOp(opcode:0xFE, processor: self.OP_RESET)) // OPRESET2 (historical) dwTransaction.append(DWOp(opcode:OPRESET, processor: self.OP_RESET)) dwTransaction.append(DWOp(opcode:OPWIREBUG, processor: self.OP_WIREBUG)) dwTransaction.append(DWOp(opcode:OPPRINTFLUSH, processor: self.OP_PRINTFLUSH)) @@ -521,9 +676,10 @@ public class DriveWireHost : Codable { repeat { bytesConsumed = self.processor!(serialBuffer) - + if bytesConsumed > 0 && serialBuffer.count >= bytesConsumed { - // chop off consumed bytes + // Bytes were consumed — cancel the idle watchdog while mid-transaction. + invalidateWatchdog() serialBuffer.replaceSubrange(0.. 0 && serialBuffer.count > 0 @@ -561,6 +717,19 @@ public class DriveWireHost : Codable { processor = OP_OPCODE invalidateWatchdog() } + + func resetRFMState() { + for descriptor in rfmPaths.values { + descriptor.localFile?.closeFile() + } + rfmPaths.removeAll() + rfmCurrentDir.removeAll() + rfmCurrentExecDir.removeAll() + rfmLSNByPath.removeAll() + rfmLSNToPath.removeAll() + rfmLSNCounter = 0x600000 + print("RFM state reset") + } private func OP_DWINIT(data : Data) -> Int { var result = 0 @@ -571,8 +740,10 @@ public class DriveWireHost : Codable { // Save capabilities byte. guestCapabilityByte = data[1] - // Send the host capabilities byte. - delegate?.dataAvailable(host: self, data: Data([0x00])) + // Send the host capabilities byte. This must be non-zero: the + // NitrOS-9 driver treats zero as a DW3 server and disables DW4 + // extensions, including the virtual serial poller. + delegate?.dataAvailable(host: self, data: Data([0xFF])) result = expectedCount // Reset the state machine. @@ -583,6 +754,20 @@ public class DriveWireHost : Codable { } private var nameLength = 0 + + private static func decodedNameObjectPath(from data: Data, length: Int) -> String? { + guard length > 0, data.count >= length else { + return nil + } + + let nameBytes = Array(data.prefix(length)).map { $0 & 0x7F } + guard nameBytes.allSatisfy({ $0 >= 0x20 && $0 != 0x7F }) else { + return nil + } + + let name = String(bytes: nameBytes, encoding: .ascii) ?? "" + return name.isEmpty ? nil : name + } private func OP_NAMEOBJ_MOUNT(data : Data) -> Int { var result = 0 @@ -604,12 +789,12 @@ public class DriveWireHost : Codable { if data.count >= nameLength { resetState() result = nameLength; - let name = String(bytes: data, encoding: .ascii)! - + // determine if a named object with this name already exists - if let vd = findVirtualDisk(name: name) { + if let name = Self.decodedNameObjectPath(from: data, length: nameLength), + let vd = findVirtualDisk(name: name) { response = UInt8(vd.driveNumber) - } else { + } else if let name = Self.decodedNameObjectPath(from: data, length: nameLength) { do { let nextFreeDrive = findAvailableVirtualDrive() try insertVirtualDisk(driveNumber: nextFreeDrive, imagePath: name) @@ -621,17 +806,17 @@ public class DriveWireHost : Codable { delegate?.dataAvailable(host: self, data: Data([response])) delegate?.transactionCompleted(opCode: currentTransaction) } - + return result } } - + private func OP_NAMEOBJ_CREATE(data : Data) -> Int { var nameLength = 0 var result = 0 let expectedCount = 2 var response : UInt8 = 0 - currentTransaction = OPNAMEOBJMOUNT + currentTransaction = OPNAMEOBJCREATE if data.count >= expectedCount { nameLength = Int(data[1]) @@ -647,24 +832,24 @@ public class DriveWireHost : Codable { if data.count >= nameLength { resetState() result = nameLength; - let name = String(bytes: data, encoding: .ascii)! - + // determine if a named object with this name already exists - if let _ = findVirtualDisk(name: name) { + if let name = Self.decodedNameObjectPath(from: data, length: nameLength), + let _ = findVirtualDisk(name: name) { response = 0 - } else { + } else if let name = Self.decodedNameObjectPath(from: data, length: nameLength) { let nextFreeDrive = findAvailableVirtualDrive() do { try insertVirtualDisk(driveNumber: nextFreeDrive, imagePath: name) response = UInt8(nextFreeDrive); } catch { - + } } delegate?.dataAvailable(host: self, data: Data([response])) delegate?.transactionCompleted(opCode: currentTransaction) } - + return result } } @@ -689,23 +874,26 @@ public class DriveWireHost : Codable { resetState() delegate?.dataAvailable(host: self, data: Data([year, month, day, hour, minute, second])) delegate?.transactionCompleted(opCode: currentTransaction) + let msg = "OP_TIME -> \(1900 + Int(year))/\(month)/\(day) \(hour):\(String(format:"%02d",minute)):\(String(format:"%02d",second))" + log += msg + "\n"; print(msg) return 1 } - + private func OP_INIT(data : Data) -> Int { - currentTransaction = OPINIT + currentTransaction = 0x49 // OPINIT (historical) resetState() - statistics = DriveWireStatistics() // reset statistics + statistics = DriveWireStatistics() + resetRFMState() delegate?.transactionCompleted(opCode: currentTransaction) - log = log + "OP_INIT" + "\n" + log += "OP_INIT\n"; print("OP_INIT") return 1 } - + private func OP_TERM(data : Data) -> Int { - currentTransaction = OPTERM + currentTransaction = 0x54 // OPTERM (historical) resetState() delegate?.transactionCompleted(opCode: currentTransaction) - log = log + "OP_TERM" + "\n" + log += "OP_TERM\n"; print("OP_TERM") return 1 } @@ -749,6 +937,8 @@ public class DriveWireHost : Codable { statistics.lastDriveNumber = driveNumber delegate?.dataAvailable(host: self, data: Data([UInt8(error)])) delegate?.transactionCompleted(opCode: currentTransaction) + let msg = "OP_WRITE(drive=\(driveNumber), lsn=0x\(String(vLSN, radix: 16))) -> \(error)" + reportActivity(msg, isFrequent: true, isError: error != DriveWireProtocolError.E_NONE.rawValue) } return result @@ -777,7 +967,7 @@ public class DriveWireHost : Codable { delegate?.transactionCompleted(opCode: currentTransaction) } - log = log + "OP_GETSTAT(" + String(statistics.lastDriveNumber) + "," + String(statistics.lastGetStat) + ")" + "\n" + reportActivity("OP_GETSTAT(drive=\(statistics.lastDriveNumber), \(DriveWireHost.ssCodeName(Int(statistics.lastGetStat))))", isFrequent: true) return result } @@ -795,15 +985,16 @@ public class DriveWireHost : Codable { delegate?.transactionCompleted(opCode: currentTransaction) } - log = log + "OP_SETSTAT(" + String(statistics.lastDriveNumber) + "," + String(statistics.lastGetStat) + ")" + "\n" + reportActivity("OP_SETSTAT(drive=\(statistics.lastDriveNumber), \(DriveWireHost.ssCodeName(Int(statistics.lastSetStat))))", isFrequent: true) return result } private func OP_RESET(data : Data) -> Int { currentTransaction = OPRESET resetState() + resetRFMState() delegate?.transactionCompleted(opCode: currentTransaction) - log = log + "OP_RESET" + "\n" + log += "OP_RESET\n"; print("OP_RESET") return 1 } @@ -853,70 +1044,560 @@ public class DriveWireHost : Codable { private func OP_SERINIT(data : Data) -> Int { currentTransaction = OPSERINIT + guard data.count >= 2 else { return 0 } + let ch = data[1] + openVirtualSerialChannels.insert(ch) resetState() delegate?.transactionCompleted(opCode: currentTransaction) - return 1 + let msg = "OP_SERINIT(ch=\(ch))"; log += msg + "\n"; print(msg) + return 2 } - + private func OP_SERTERM(data : Data) -> Int { currentTransaction = OPSERTERM + guard data.count >= 2 else { return 0 } + let ch = data[1] + openVirtualSerialChannels.remove(ch) resetState() delegate?.transactionCompleted(opCode: currentTransaction) - return 1 + let msg = "OP_SERTERM(ch=\(ch))"; log += msg + "\n"; print(msg) + return 2 } - + private func OP_SERREAD(data : Data) -> Int { currentTransaction = OPSERREAD + guard data.count >= 1 else { return 0 } resetState() - delegate?.dataAvailable(host: self, data: Data([0x00, 0x00])) + let response = pollVirtualSerial() + delegate?.dataAvailable(host: self, data: response) + delegate?.transactionCompleted(opCode: currentTransaction) + reportActivity("OP_SERREAD -> \(response[0]),\(response[1])", isFrequent: true) return 1 } - + private func OP_SERREADM(data : Data) -> Int { currentTransaction = OPSERREADM + guard data.count >= 3 else { return 0 } + let ch = data[1] + let count = Int(data[2]) + let response = readVirtualSerial(channel: ch, count: count) resetState() + delegate?.dataAvailable(host: self, data: response) delegate?.transactionCompleted(opCode: currentTransaction) - return 1 + reportActivity("OP_SERREADM(ch=\(ch), bytes=\(count))", isFrequent: true) + return 3 } - + private func OP_SERWRITE(data : Data) -> Int { currentTransaction = OPSERWRITE + guard data.count >= 3 else { return 0 } + let ch = data[1]; let byte = data[2] + writeVirtualSerial(channel: ch, data: Data([byte])) resetState() delegate?.transactionCompleted(opCode: currentTransaction) - return 1 + let msg = "OP_SERWRITE(ch=\(ch), byte=0x\(String(byte, radix: 16)))"; log += msg + "\n"; print(msg) + return 3 } - + private func OP_SERWRITEM(data : Data) -> Int { currentTransaction = OPSERWRITEM + guard data.count >= 2 else { return 0 } + let ch = data[1] + if !openVirtualSerialChannels.contains(ch) { + resetState() + delegate?.transactionCompleted(opCode: currentTransaction) + reportActivity("OP_SERWRITEM(ch=\(ch)) ignored for unopened channel", isFrequent: true) + return 2 + } + guard data.count >= 3 else { return 0 } + let count = Int(data[2]) + let total = 3 + count + guard data.count >= total else { return 0 } + writeVirtualSerial(channel: ch, data: data.subdata(in: 3.. Int { currentTransaction = OPSERGETSTAT + guard data.count >= 3 else { return 0 } + let ch = data[1]; let code = data[2] resetState() delegate?.transactionCompleted(opCode: currentTransaction) - return 1 + let msg = "OP_SERGETSTAT(ch=\(ch), \(DriveWireHost.ssCodeName(Int(code))))"; log += msg + "\n"; print(msg) + return 3 } - + private func OP_SERSETSTAT(data : Data) -> Int { currentTransaction = OPSERSETSTAT + guard data.count >= 3 else { return 0 } + let ch = data[1]; let code = data[2] + let expectedCount = code == 0x28 ? 29 : 3 + guard data.count >= expectedCount else { return 0 } + switch code { + case 0x29: + openVirtualSerialChannels.insert(ch) + case 0x2A: + openVirtualSerialChannels.remove(ch) + default: + break + } resetState() delegate?.transactionCompleted(opCode: currentTransaction) - return 1 + let msg = "OP_SERSETSTAT(ch=\(ch), \(DriveWireHost.ssCodeName(Int(code))))"; log += msg + "\n"; print(msg) + return expectedCount } - + + private func pollVirtualSerial() -> Data { + if let channel = pendingClosedVirtualSerialChannels.first, + virtualSerialInput[channel]?.isEmpty ?? true { + pendingClosedVirtualSerialChannels.removeFirst() + openVirtualSerialChannels.remove(channel) + return Data([16, channel]) + } + + if let channel = virtualSerialInput.keys.sorted().first(where: { + isVirtualWindowChannel($0) && !(virtualSerialInput[$0]?.isEmpty ?? true) + }) { + let byte = readVirtualSerial(channel: channel, count: 1).first ?? 0 + return Data([channel &+ 1, byte]) + } + + if let channel = virtualSerialInput.keys.sorted().first(where: { + !isVirtualWindowChannel($0) && !(virtualSerialInput[$0]?.isEmpty ?? true) + }) { + let waiting = virtualSerialInput[channel]?.count ?? 0 + if waiting >= 3 { + return Data([channel &+ 17, UInt8(min(waiting, 255))]) + } + let byte = readVirtualSerial(channel: channel, count: 1).first ?? 0 + return Data([channel &+ 1, byte]) + } + + return Data([0x00, 0x00]) + } + + private func readVirtualSerial(channel: UInt8, count: Int) -> Data { + guard count > 0, var queued = virtualSerialInput[channel], !queued.isEmpty else { + return Data() + } + + let readCount = min(count, queued.count) + let response = queued.prefix(readCount) + queued.removeFirst(readCount) + virtualSerialInput[channel] = queued + return Data(response) + } + + private func writeVirtualSerial(channel: UInt8, data: Data) { + for byte in data { + if byte == 0x0D { + let command = virtualSerialCommandBuffers[channel, default: ""].trimmingCharacters(in: .whitespacesAndNewlines) + virtualSerialCommandBuffers[channel] = "" + processVirtualSerialCommand(command, channel: channel) + } else if byte == 0x08 || byte == 0x7F { + var command = virtualSerialCommandBuffers[channel, default: ""] + if !command.isEmpty { + command.removeLast() + } + virtualSerialCommandBuffers[channel] = command + } else if byte >= 0x20 { + virtualSerialCommandBuffers[channel, default: ""].append(Character(UnicodeScalar(byte))) + } + } + } + + private func processVirtualSerialCommand(_ command: String, channel: UInt8) { + guard !command.isEmpty else { + return + } + + if command.lowercased().hasPrefix("dw ") || command.lowercased() == "dw" { + let response = processDriveWireAPICommand(command) + enqueueVirtualSerialResponse(response, channel: channel) + if response.hasPrefix("OK ") { + pendingClosedVirtualSerialChannels.append(channel) + } + } else { + enqueueVirtualSerialResponse(driveWireAPIFailure(code: 10, text: "Unknown command '\(command)'"), channel: channel) + } + let msg = "VSerial(ch=\(channel)) command: \(command)" + log += msg + "\n"; print(msg) + } + + private func enqueueVirtualSerialResponse(_ response: String, channel: UInt8) { + virtualSerialInput[channel, default: Data()].append(contentsOf: response.data(using: .ascii) ?? Data()) + } + + private func enqueueDriveWireUtilityResponse(_ text: String, channel: UInt8) { + enqueueVirtualSerialResponse(driveWireAPISuccess(text), channel: channel) + pendingClosedVirtualSerialChannels.append(channel) + } + + private func driveWireAPISuccess(_ text: String) -> String { + "OK command successful\n\r" + text + } + + private func driveWireAPIFailure(code: UInt8, text: String) -> String { + String(format: "FAIL %03d %@\r", Int(code), text) + } + + private func processDriveWireAPICommand(_ command: String) -> String { + let arguments = Array(command.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init).dropFirst()) + let result = parseDriveWireAPI(arguments) + switch result { + case .success(let text): + return driveWireAPISuccess(text) + case .failure(let code, let text): + return driveWireAPIFailure(code: code, text: text) + } + } + + private enum DriveWireAPIResult { + case success(String) + case failure(UInt8, String) + } + + private struct DriveWireAPICommand { + let name: String + let help: String + } + + private enum DriveWireAPICommandMatch { + case success(String) + case failure(String) + } + + private func parseDriveWireAPI(_ arguments: [String]) -> DriveWireAPIResult { + guard let command = arguments.first else { + return .success(shortHelp(for: [ + DriveWireAPICommand(name: "config", help: "Commands to manipulate the config"), + DriveWireAPICommand(name: "disk", help: "Manage disks and disksets"), + DriveWireAPICommand(name: "log", help: "View server logs"), + DriveWireAPICommand(name: "midi", help: "Manage MIDI"), + DriveWireAPICommand(name: "net", help: "Show network information"), + DriveWireAPICommand(name: "port", help: "Manage virtual serial ports"), + DriveWireAPICommand(name: "server", help: "Various server based tools") + ])) + } + + switch match(command, in: ["config", "disk", "log", "midi", "net", "port", "server"]) { + case .success("config"): + return parseConfigCommand(Array(arguments.dropFirst())) + case .success("disk"): + return parseDiskCommand(Array(arguments.dropFirst())) + case .success("port"): + return parsePortCommand(Array(arguments.dropFirst())) + case .success("server"): + return parseServerCommand(Array(arguments.dropFirst())) + case .success(let name): + return .failure(204, "Command 'dw \(name)' is not implemented yet.") + case .failure(let message): + return .failure(10, message) + } + } + + private func parseConfigCommand(_ arguments: [String]) -> DriveWireAPIResult { + guard let command = arguments.first else { + return .success(shortHelp(for: [ + DriveWireAPICommand(name: "save", help: "Save current configuration"), + DriveWireAPICommand(name: "set", help: "Set config item"), + DriveWireAPICommand(name: "show", help: "Show current instance config (or item)") + ])) + } + + switch match(command, in: ["save", "set", "show"]) { + case .success("show"): + return configShow(Array(arguments.dropFirst())) + case .success(let name): + return .failure(204, "Command 'dw config \(name)' is not implemented yet.") + case .failure(let message): + return .failure(10, message) + } + } + + private func parseDiskCommand(_ arguments: [String]) -> DriveWireAPIResult { + guard let command = arguments.first else { + return .success(shortHelp(for: [ + DriveWireAPICommand(name: "create", help: "Create disk image"), + DriveWireAPICommand(name: "dos", help: "Manage DOS disk images"), + DriveWireAPICommand(name: "eject", help: "Eject disk from drive #"), + DriveWireAPICommand(name: "insert", help: "Load disk into drive #"), + DriveWireAPICommand(name: "reload", help: "Reload disk in drive #"), + DriveWireAPICommand(name: "set", help: "Set disk parameter"), + DriveWireAPICommand(name: "show", help: "Show current disk details"), + DriveWireAPICommand(name: "write", help: "Write dirty sectors") + ])) + } + + switch match(command, in: ["create", "dos", "eject", "insert", "reload", "set", "show", "write"]) { + case .success("eject"): + return diskEject(Array(arguments.dropFirst())) + case .success("insert"): + return diskInsert(Array(arguments.dropFirst())) + case .success("reload"): + return diskReload(Array(arguments.dropFirst())) + case .success("show"): + return diskShow(Array(arguments.dropFirst())) + case .success(let name): + return .failure(204, "Command 'dw disk \(name)' is not implemented yet.") + case .failure(let message): + return .failure(10, message) + } + } + + private func parsePortCommand(_ arguments: [String]) -> DriveWireAPIResult { + guard let command = arguments.first else { + return .success(shortHelp(for: [ + DriveWireAPICommand(name: "close", help: "Close port #"), + DriveWireAPICommand(name: "open", help: "Open port #"), + DriveWireAPICommand(name: "show", help: "Show port status") + ])) + } + + switch match(command, in: ["close", "open", "show"]) { + case .success("show"): + return portShow() + case .success(let name): + return .failure(204, "Command 'dw port \(name)' is not implemented yet.") + case .failure(let message): + return .failure(10, message) + } + } + + private func parseServerCommand(_ arguments: [String]) -> DriveWireAPIResult { + guard let command = arguments.first else { + return .success(shortHelp(for: [ + DriveWireAPICommand(name: "dir", help: "List files on server"), + DriveWireAPICommand(name: "help", help: "Show help"), + DriveWireAPICommand(name: "list", help: "List contents of file on server"), + DriveWireAPICommand(name: "print", help: "Print file on server"), + DriveWireAPICommand(name: "show", help: "Show various server information"), + DriveWireAPICommand(name: "status", help: "Show server status information"), + DriveWireAPICommand(name: "turbo", help: "Show turbo status") + ])) + } + + switch match(command, in: ["dir", "help", "list", "print", "show", "status", "turbo"]) { + case .success("status"): + return serverStatus() + case .success("show"): + return .success(shortHelp(for: [ + DriveWireAPICommand(name: "handlers", help: "Show handler information"), + DriveWireAPICommand(name: "threads", help: "Show thread information") + ])) + case .success(let name): + return .failure(204, "Command 'dw server \(name)' is not implemented yet.") + case .failure(let message): + return .failure(10, message) + } + } + + private func match(_ input: String, in commands: [String]) -> DriveWireAPICommandMatch { + let matches = commands.filter { $0.hasPrefix(input.lowercased()) } + if matches.count == 1 { + return .success(matches[0]) + } else if matches.isEmpty { + return .failure("Unknown command '\(input)'") + } else { + return .failure("Ambiguous command, '\(input)' matches \(matches.joined(separator: " or "))") + } + } + + private func shortHelp(for commands: [DriveWireAPICommand]) -> String { + let names = commands.map(\.name).sorted() + return "Possible commands:\r\n\r\n" + columnLayout(names) + "\r\n" + } + + private func columnLayout(_ values: [String], columns: Int = 80) -> String { + guard !values.isEmpty else { return "" } + let width = (values.map(\.count).max() ?? 1) + 2 + let perLine = max(1, (columns - 1) / width) + var lines: [String] = [] + var current = "" + for (index, value) in values.enumerated() { + if index > 0 && index % perLine == 0 { + lines.append(current.trimmingCharacters(in: .whitespaces)) + current = "" + } + current += value.padding(toLength: width, withPad: " ", startingAt: 0) + } + if !current.isEmpty { + lines.append(current.trimmingCharacters(in: .whitespaces)) + } + return lines.joined(separator: "\r\n") + } + + private func configShow(_ arguments: [String]) -> DriveWireAPIResult { + let items = [ + "CMDCols": "80", + "DeviceType": "swift", + "DetailedOpcodeLogging": detailedOpcodeLogging ? "true" : "false", + "HostCapabilityByte": "255", + "MountedDrives": "\(virtualDrives.count)", + "ProtectedMode": "false" + ] + + if let key = arguments.first { + if let value = items.first(where: { $0.key.lowercased() == key.lowercased() }) { + return .success("\(value.key) = \(value.value)\r\n") + } + return .failure(142, "Key '\(key)' is not set.") + } + + let text = items.keys.sorted() + .map { "\($0) = \(items[$0] ?? "")" } + .joined(separator: "\r\n") + return .success("Current protocol handler configuration:\r\n\n\(text)\r\n") + } + + private func diskShow(_ arguments: [String]) -> DriveWireAPIResult { + guard let driveArgument = arguments.first else { + var text = "\r\nCurrent DriveWire disks:\r\n\r\n" + for drive in virtualDrives.sorted(by: { $0.driveNumber < $1.driveNumber }) { + text += String(format: "X%-3d ", drive.driveNumber) + text += shortenedPath(drive.imagePath) + "\r\n" + } + return .success(text) + } + + guard let driveNumber = parseDriveNumber(driveArgument) else { + return .failure(101, "Invalid drive number '\(driveArgument)'") + } + guard let drive = virtualDrives.first(where: { $0.driveNumber == driveNumber }) else { + return .failure(102, "Drive \(driveNumber) is not loaded.") + } + + var text = "Details for disk in drive #\(driveNumber):\r\n\r\n" + text += shortenedPath(drive.imagePath) + "\r\n\r\n" + text += "System params:\r\n" + text += "path: \(drive.imagePath)\r\n" + text += "User params:\r\n" + return .success(text) + } + + private func diskInsert(_ arguments: [String]) -> DriveWireAPIResult { + guard arguments.count >= 2 else { + return .failure(10, "Syntax error") + } + guard let driveNumber = parseDriveNumber(arguments[0]) else { + return .failure(101, "Invalid drive number '\(arguments[0])'") + } + let path = arguments.dropFirst().joined(separator: " ") + do { + try insertVirtualDisk(driveNumber: driveNumber, imagePath: path) + return .success("Disk inserted in drive \(driveNumber).\r\n") + } catch { + return .failure(216, error.localizedDescription) + } + } + + private func diskEject(_ arguments: [String]) -> DriveWireAPIResult { + guard let argument = arguments.first, arguments.count == 1 else { + return .failure(10, "Syntax error") + } + if argument.lowercased() == "all" { + virtualDrives.removeAll() + return .success("Ejected all disks.\r\n") + } + guard let driveNumber = parseDriveNumber(argument) else { + return .failure(101, "Invalid drive number '\(argument)'") + } + guard virtualDrives.contains(where: { $0.driveNumber == driveNumber }) else { + return .failure(102, "Drive \(driveNumber) is not loaded.") + } + ejectVirtualDisk(driveNumber: driveNumber) + return .success("Disk ejected from drive \(driveNumber).\r\n") + } + + private func diskReload(_ arguments: [String]) -> DriveWireAPIResult { + guard let argument = arguments.first, arguments.count == 1 else { + return .failure(10, "dw disk reload requires a drive # or 'all' as an argument") + } + if argument.lowercased() == "all" { + reloadVirtualDrives() + return .success("All disks reloaded.\r\n") + } + guard let driveNumber = parseDriveNumber(argument) else { + return .failure(10, "Syntax error: non numeric drive #") + } + guard virtualDrives.contains(where: { $0.driveNumber == driveNumber }) else { + return .failure(102, "Drive \(driveNumber) is not loaded.") + } + virtualDrives.first(where: { $0.driveNumber == driveNumber })?.reload() + return .success("Disk in drive #\(driveNumber) reloaded.\r\n") + } + + private func portShow() -> DriveWireAPIResult { + var text = "\r\nCurrent port status:\r\n\n" + for port in 0..<15 { + let channel = UInt8(port) + text += "/N\(port + 1)".padding(toLength: 6, withPad: " ", startingAt: 0) + if openVirtualSerialChannels.contains(channel) { + let waiting = virtualSerialInput[channel]?.count ?? 0 + text += " " + text += "open".padding(toLength: 8, withPad: " ", startingAt: 0) + text += " " + text += "buf: \(waiting)".padding(toLength: 9, withPad: " ", startingAt: 0) + } else { + text += " closed" + } + text += "\r\n" + } + return .success(text) + } + + private func serverStatus() -> DriveWireAPIResult { + var text = "DriveWire Swift status:\r\n\n" + text += "Device: Swift host\r\n" + text += "DW4 support: enabled\r\n" + text += "Virtual disks: \(virtualDrives.count)\r\n" + text += "Open ports: \(openVirtualSerialChannels.count)\r\n" + text += "Last opcode: 0x\(String(statistics.lastOpCode, radix: 16))\r\n" + text += "Reads: \(statistics.readCount)\r\n" + text += "Writes: \(statistics.writeCount)\r\n" + return .success(text) + } + + private func parseDriveNumber(_ value: String) -> Int? { + let trimmed = value.uppercased().hasPrefix("X") ? String(value.dropFirst()) : value + guard let number = Int(trimmed), number >= 0, number <= 255 else { + return nil + } + return number + } + + private func shortenedPath(_ path: String) -> String { + let home = NSHomeDirectory() + if path == home { + return "~" + } + if path.hasPrefix(home + "/") { + return "~" + path.dropFirst(home.count) + } + return path + } + + private func isVirtualWindowChannel(_ channel: UInt8) -> Bool { + channel >= 0x80 && channel <= 0x8F + } + private func OP_FASTWRITE_Serial(data : Data) -> Int { + guard data.count >= 2 else { return 0 } + writeVirtualSerial(channel: fastwriteChannel, data: Data([data[1]])) resetState() delegate?.transactionCompleted(opCode: currentTransaction) - return 1 + return 2 } private func OP_FASTWRITE_Screen(data : Data) -> Int { + guard data.count >= 2 else { return 0 } + writeVirtualSerial(channel: 0x80 &+ fastwriteChannel, data: Data([data[1]])) resetState() delegate?.transactionCompleted(opCode: currentTransaction) - return 1 + return 2 } private func OP_RFM(data : Data) -> Int { @@ -965,432 +1646,604 @@ public class DriveWireHost : Codable { } private func OPRFMCREATE(data : Data) -> Int { - var result = 0 - let expectedCount = 2 - - if data.count >= expectedCount { - - resetState() - } - - return result + return rfmOpen(data: data, shouldCreate: true) } - - // Format of command from the client at this point - // - 2 byte: Path descriptor address (we use this as part of a unique identifier for this path) - // - 1 byte: Path number (0-15; we use this as part of a unique identifier for this path) - // - 1 byte: Mode Byte from caller's R$A - // - 2 bytes: length of pathname + + // Receives: path#(1) + processID(1) + mode(1), then pathname terminated by CR ($0D). + // Sends back: 1-byte error code. private func OPRFMOPEN(data : Data) -> Int { + return rfmOpen(data: data, shouldCreate: false) + } + + private func rfmOpen(data: Data, shouldCreate: Bool) -> Int { var result = 0 - let expectedCount = 7 + let expectedCount = 4 + var capturedPathNumber = 0 if data.count >= expectedCount { - rfmPathDescriptor = RFMPathDescriptor() - rfmPathDescriptor.processID = Int(data[0]) - rfmPathDescriptor.pathNumber = Int(data[1]) - rfmPathDescriptor.pathDescriptorAddress = Int(data[2]) * 256 + Int(data[3]) - rfmPathDescriptor.mode = Int(data[4]) - rfmPathDescriptor.pathnameLength = Int(data[5]) * 256 + Int(data[6]) - + capturedPathNumber = Int(data[0]) + var descriptor = RFMPathDescriptor() + descriptor.pathNumber = capturedPathNumber + descriptor.processID = Int(data[1]) + descriptor.parentProcessID = Int(data[2]) + descriptor.mode = Int(data[3]) + rfmPaths[capturedPathNumber] = descriptor result = expectedCount processor = OPRFMGETPATH - log = log + "OP_RFM_OPEN(" } - + return result + + func OPRFMGETPATH(data: Data) -> Int { + guard let crOffset = data.firstIndex(of: 0x0D) else { return 0 } + let pathBytes = data[data.startIndex.. \(errorCode)" + log += msg + "\n" + print(msg) + resetState() + return crOffset - data.startIndex + 1 + } } - private func OPRFMGETPATH(data : Data) -> Int { - var result = 0 - let expectedCount = rfmPathDescriptor.pathnameLength + private func resolveRFMPathname(_ pathname: String, processID: Int, parentProcessID: Int, isExec: Bool = false) -> String { + guard !(pathname as NSString).isAbsolutePath else { return pathname } + let dirMap = isExec ? rfmCurrentExecDir : rfmCurrentDir + let base: String + if let cwd = dirMap[processID] { + base = cwd + } else if let cwd = dirMap[parentProcessID] { + base = cwd + } else { + return pathname + } + if pathname == "." { return base } + return (base as NSString).appendingPathComponent(pathname) + } + + // Expand OS-9 multi-dot path components: N dots = N-1 parent-dir references. + private func expandOS9MultiDots(_ path: String) -> String { + RFMPathDescriptor.expandMultiDots(path) + } + + private static func ssCodeName(_ code: Int) -> String { + switch code { + case 0x00: return "SS.Opt" + case 0x01: return "SS.Ready" + case 0x02: return "SS.Size" + case 0x03: return "SS.Reset" + case 0x04: return "SS.WTrk" + case 0x05: return "SS.Pos" + case 0x06: return "SS.EOF" + case 0x07: return "SS.Link" + case 0x08: return "SS.ULink" + case 0x09: return "SS.Feed" + case 0x0A: return "SS.Frz" + case 0x0B: return "SS.SPT" + case 0x0C: return "SS.SQD" + case 0x0D: return "SS.DCmd" + case 0x0E: return "SS.DevNm" + case 0x0F: return "SS.FD" + case 0x10: return "SS.Ticks" + case 0x11: return "SS.Lock" + case 0x12: return "SS.DStat" + case 0x13: return "SS.Joy" + case 0x14: return "SS.BlkRd" + case 0x15: return "SS.BlkWr" + case 0x16: return "SS.Reten" + case 0x17: return "SS.WFM" + case 0x18: return "SS.RFM" + case 0x19: return "SS.ELog" + case 0x1A: return "SS.SSig" + case 0x1B: return "SS.Relea" + case 0x1C: return "SS.AlfaS" + case 0x1D: return "SS.Break" + case 0x1E: return "SS.RsBit" + case 0x1F: return "SS.DirEnt" + case 0x20: return "SS.FDInf" + case 0x21: return "SS.Cursr" + case 0x22: return "SS.ScSiz" + case 0x23: return "SS.KySns" + case 0x24: return "SS.DevNm" + case 0x25: return "SS.FD" + case 0x26: return "SS.Ticks" + case 0x27: return "SS.Lock" + case 0x28: return "SS.ComSt" + case 0x29: return "SS.Open" + case 0x2A: return "SS.Close" + case 0x2B: return "SS.HngUp" + case 0x2C: return "SS.FSig" + default: return "0x\(String(code, radix: 16, uppercase: false))" + } + } - if data.count >= expectedCount { - var lsn0 : UInt8 = 0 - var lsn1 : UInt8 = 0 - var lsn2 : UInt8 = 0 - - if currentSubTransaction == DWRFMTransaction.OP_RFM_CHGDIR.rawValue { - - } else { - rfmPathDescriptor.pathname = String(bytes: data, encoding: .ascii)! + // Returns a stable LSN for the given absolute host path, allocating a new one if needed. + private func lsnForPath(_ path: String) -> Int { + if let existing = rfmLSNByPath[path] { return existing } + let lsn = rfmLSNCounter + rfmLSNByPath[path] = lsn + rfmLSNToPath[lsn] = path + rfmLSNCounter += 1 + return lsn + } + + // Builds OS-9 directory contents for the open directory descriptor, registering each entry in the LSN map. + private func synthesizeDirectoryEntries(for descriptor: RFMPathDescriptor) -> Data { + let dirPath = rfmRootPath + descriptor.pathname + var entries = Data() + + // '..' and '.' entries — point to parent and self + let parentPath: String + let selfPath = (URL(fileURLWithPath: dirPath).standardized.path) + let deviceRoot = rfmRootPath + "/" + (descriptor.pathname.split(separator: "/").first.map(String.init) ?? "") + if selfPath == URL(fileURLWithPath: deviceRoot).standardized.path { + parentPath = selfPath // at device root: '..' loops back to self + } else { + parentPath = URL(fileURLWithPath: dirPath + "/..").standardized.path + } + entries.append(contentsOf: RFMPathDescriptor.makeOS9DirEntry("..", lsn: lsnForPath(parentPath))) + entries.append(contentsOf: RFMPathDescriptor.makeOS9DirEntry(".", lsn: lsnForPath(selfPath))) + + if let contents = try? FileManager.default.contentsOfDirectory(atPath: dirPath) { + for name in contents.sorted() { + let childPath = dirPath + "/" + name + entries.append(contentsOf: RFMPathDescriptor.makeOS9DirEntry(name, lsn: lsnForPath(childPath))) } + } + return entries + } + + private func OPRFMMAKDIR(data: Data) -> Int { + var result = 0 + let expectedCount = 4 + var capturedPathNumber = 0 + var capturedProcessID = 0 + var capturedParentProcessID = 0 + var capturedMode = 0 + if data.count >= expectedCount { + capturedPathNumber = Int(data[0]) + capturedProcessID = Int(data[1]) + capturedParentProcessID = Int(data[2]) + capturedMode = Int(data[3]) result = expectedCount - - // Locate the file on the host. - let errorCode = rfmPathDescriptor.openLocalFile() - - if errorCode == 0 { - // obtain unique identifier - lsn0 = 3 - lsn1 = 2 - lsn2 = 1 - } - - delegate?.dataAvailable(host: self, data: Data([errorCode, lsn0, lsn1, lsn2])) - log = log + "\(rfmPathDescriptor.pathDescriptorAddress), \(rfmPathDescriptor.pathNumber), \(rfmPathDescriptor.pathname)) -> [\(errorCode), \(lsn0), \(lsn1), \(lsn2)]\n" + processor = OPRFMGETMKDIRPATH + } + + return result + func OPRFMGETMKDIRPATH(data: Data) -> Int { + guard let crOffset = data.firstIndex(of: 0x0D) else { return 0 } + let pathBytes = data[data.startIndex.. 0") + } catch { errorCode = 216 } + } else { errorCode = 214 } + } + delegate?.dataAvailable(host: self, data: Data([errorCode])) resetState() + return crOffset - data.startIndex + 1 } - - return result - } - - private func OPRFMMAKDIR(data : Data) -> Int { - var result = 1 - return result } - private func OPRFMCHGDIR(data : Data) -> Int { + private func OPRFMCHGDIR(data: Data) -> Int { var result = 0 - let expectedCount = 7 - var mode = 0 - + let expectedCount = 4 + var capturedPathNumber = 0 + var capturedProcessID = 0 + var capturedParentProcessID = 0 + var capturedMode = 0 + if data.count >= expectedCount { - let processID = Int(data[0]) - let pathNumber = Int(data[1]) - let pathDescriptorAddress = Int(data[2]) * 256 + Int(data[3]) - mode = Int(data[4]) - let pathnameLength = Int(data[5]) * 256 + Int(data[6]) - - if mode & 0x4 != 0 { - // execution directory - rfmWorkingExecutionDirectoryPathDescriptor.processID = processID - rfmWorkingExecutionDirectoryPathDescriptor.pathNumber = pathNumber - rfmWorkingExecutionDirectoryPathDescriptor.pathDescriptorAddress = pathDescriptorAddress - rfmWorkingExecutionDirectoryPathDescriptor.mode = mode - rfmWorkingExecutionDirectoryPathDescriptor.pathnameLength = pathnameLength - } else { - // data directory - rfmWorkingDataDirectoryPathDescriptor.processID = processID - rfmWorkingDataDirectoryPathDescriptor.pathNumber = pathNumber - rfmWorkingDataDirectoryPathDescriptor.pathDescriptorAddress = pathDescriptorAddress - rfmWorkingDataDirectoryPathDescriptor.mode = mode - rfmWorkingDataDirectoryPathDescriptor.pathnameLength = pathnameLength - } - + capturedPathNumber = Int(data[0]) + capturedProcessID = Int(data[1]) + capturedParentProcessID = Int(data[2]) + capturedMode = Int(data[3]) result = expectedCount processor = OPRFMGETCHGDIRPATH } - - return result - - func OPRFMGETCHGDIRPATH(data : Data) -> Int { - var result = 0 - let expectedCount = rfmPathDescriptor.pathnameLength - var errorCode : UInt8 = 0 - - if data.count >= expectedCount { - var lsn0 : UInt8 = 0 - var lsn1 : UInt8 = 0 - var lsn2 : UInt8 = 0 - - if mode & 0x4 != 0 { - // execution directory - rfmWorkingExecutionDirectoryPathDescriptor.pathname = String(bytes: data, encoding: .ascii)! - errorCode = rfmWorkingExecutionDirectoryPathDescriptor.openLocalFile() - } else { - // data directory - rfmWorkingDataDirectoryPathDescriptor.pathname = String(bytes: data, encoding: .ascii)! - errorCode = rfmWorkingDataDirectoryPathDescriptor.openLocalFile() - } - result = expectedCount - - // Locate the file on the host. + return result - if errorCode == 0 { - // obtain unique identifier - lsn0 = 3 - lsn1 = 2 - lsn2 = 1 - } - - if mode & 0x4 != 0 { - // execution directory - log = log + "OP_RFM_CHGDIR(Execution, \(rfmWorkingExecutionDirectoryPathDescriptor.pathDescriptorAddress), \(rfmWorkingExecutionDirectoryPathDescriptor.pathNumber), \(rfmWorkingExecutionDirectoryPathDescriptor.pathname)) -> [\(errorCode), \(lsn0), \(lsn1), \(lsn2)]\n" + func OPRFMGETCHGDIRPATH(data: Data) -> Int { + guard let crOffset = data.firstIndex(of: 0x0D) else { return 0 } + let pathBytes = data[data.startIndex.. [\(errorCode), \(lsn0), \(lsn1), \(lsn2)]\n" + errorCode = 216 // E$PNNF — directory not found + let line = "OP_RFM_CHGDIR(path#\(capturedPathNumber), pid=\(capturedProcessID), ppid=\(capturedParentProcessID)) -> \(errorCode)\n" + log += line + print(line) } - - delegate?.dataAvailable(host: self, data: Data([errorCode, lsn0, lsn1, lsn2])) - - resetState() } - - return result + delegate?.dataAvailable(host: self, data: Data([errorCode])) + resetState() + return crOffset - data.startIndex + 1 } } -private func OPRFMDELETE(data : Data) -> Int { - var result = 1 - return result -} - - // Format of command from the client at this point - // - 2 byte: Path descriptor address (we use this as part of a unique identifier for this path) - // - 1 byte: Path number (0-15; we use this as part of a unique identifier for this path) - // - 4 bytes: 32-bit seek position - private func OPRFMSEEK(data : Data) -> Int { + private func OPRFMDELETE(data: Data) -> Int { var result = 0 - let expectedCount = 7 - var errorCode : UInt8 = 0 + let expectedCount = 4 + var capturedPathNumber = 0 + var capturedProcessID = 0 + var capturedParentProcessID = 0 + var capturedMode = 0 if data.count >= expectedCount { - rfmPathDescriptor.pathDescriptorAddress = Int(data[0]) * 256 + Int(data[1]) - rfmPathDescriptor.pathNumber = Int(data[2]) - rfmPathDescriptor.filePosition = Int(data[3]) * 16777216 + Int(data[4]) * 65536 + Int(data[5]) * 256 + Int(data[6]) - + capturedPathNumber = Int(data[0]) + capturedProcessID = Int(data[1]) + capturedParentProcessID = Int(data[2]) + capturedMode = Int(data[3]) result = expectedCount - resetState() - delegate?.dataAvailable(host: self, data: Data([errorCode])) - log = log + "OP_RFM_SEEK(\(rfmPathDescriptor.filePosition)) -> R$B=\(errorCode)\n" + processor = OPRFMGETDELETEPATH } - - return result - } - // Read data from the client. - // - // Format of command from the client at this point - // - 1 byte: Process ID - // - 1 byte: Path number (0-15; we use this as part of a unique identifier for this path) - // - 2 bytes: Path descriptor address (we use this as part of a unique identifier for this path) - // - 2 bytes: Number of bytes to read - // - // If there is an error, then the following bytes are sent to the client, and the transaction terminates: - // - 1 byte: a non-zero error code - // - 2 bytes: size to read (0) - // - // If there is NOT an error, then the following bytes are sent to the client: - // - 1 byte: 0 (no error) - // - 2 bytes: the number of bytes the client can read - // - // Upon receiving the non-error 3-byte response, the client will then issue a "ready" response of 1 byte. - // When the host receives the "ready" response, it will send the number of bytes to the client. - private func OPRFMREAD(data : Data) -> Int { - var pathDescriptorAddress = 0 - var pathNumber = 0 - var bytesToRead = 0 - var errorCode : UInt8 = 0 - var dataToSend = Data([0x00]) + return result - // Format of command from the client at this point - // - 1 byte: 0 = acknowledged previous response - func OPRFMREADP2(data : Data) -> Int { - var result = 0 - let expectedCount = 1 - - if data.count >= expectedCount { - result = expectedCount - delegate?.dataAvailable(host: self, data: dataToSend) + func OPRFMGETDELETEPATH(data: Data) -> Int { + guard let crOffset = data.firstIndex(of: 0x0D) else { return 0 } + let pathBytes = data[data.startIndex.. 0") + } + } catch { errorCode = 216 } + } else { errorCode = 214 } } - + delegate?.dataAvailable(host: self, data: Data([errorCode])) resetState() - - return result + return crOffset - data.startIndex + 1 } + } + // Receives: path#(1) + X(2) + U(2) where X:U is the 32-bit seek position. + // Sends back: 1-byte error code. + private func OPRFMSEEK(data: Data) -> Int { var result = 0 let expectedCount = 5 - + let errorCode: UInt8 = 0 + if data.count >= expectedCount { - pathDescriptorAddress = Int(data[0]) * 256 + Int(data[1]) - pathNumber = Int(data[2]) - bytesToRead = Int(data[3]) * 256 + Int(data[4]) + let pathNumber = Int(data[0]) + let position = Int(data[1]) * 16777216 + Int(data[2]) * 65536 + Int(data[3]) * 256 + Int(data[4]) + rfmPaths[pathNumber]?.filePosition = position result = expectedCount - - // Get number of bytes to read from file on this path. - - // Send the response code to the guest. - // Format of response: - // - 1 byte: error code - // - 2 bytes: length of valid data - (errorCode, dataToSend) = rfmPathDescriptor.readFromFile(offset: 0, maximumCount : bytesToRead) + resetState() delegate?.dataAvailable(host: self, data: Data([errorCode])) - delegate?.dataAvailable(host: self, data: Data([UInt8((dataToSend.count >> 8) & 0xFF), UInt8(dataToSend.count & 0xFF)])) - if errorCode == 0 { - processor = OPRFMREADP2 - } else { - resetState() - } + log += "OP_RFM_SEEK(path#\(pathNumber), pos=\(position)) -> \(errorCode)\n" + print("OP_RFM_SEEK(path#\(pathNumber), pos=\(position)) -> \(errorCode)") } - - return result - } - private func OPRFMWRITE(data : Data) -> Int { - var result = 1 return result } - // Read data from the client up to a new line. - // - // Format of command from the client at this point - // - 1 byte: Process ID - // - 1 byte: Path number (0-15; we use this as part of a unique identifier for this path) - // - 2 bytes: Path descriptor address (we use this as part of a unique identifier for this path) - // - 2 bytes: Number of bytes to read - // - // If there is an error, then the following bytes are sent to the client, and the transaction terminates: - // - 1 byte: a non-zero error code - // - 2 bytes: (0, 0) - // - // If there is NOT an error, then the following bytes are sent to the client: - // - 1 byte: 0 (no error) - // - 2 bytes: the number of bytes the client can read - // - // Upon receiving the non-error 3-byte response, the client will then issue a "ready" response of 1 byte. - // When the host receives the "ready" response, it sends the number of requested bytes to the client. - private func OPRFMREADLN(data : Data) -> Int { - var pathDescriptorAddress = 0 - var pathNumber = 0 - var bytesToRead = 0 - var errorCode : UInt8 = 0 - var dataToSend = Data([0x00]) + // Receives: path#(1) then maxBytes(2). Sends: count(1) then count bytes. + // count=0 signals EOF; rfm.asm treats it as E$EOF. + private func OPRFMREAD(data: Data) -> Int { + var result = 0 + let expectedCount = 3 - // Format of command from the client at this point - // - 1 byte: 0 = acknowledged previous response - func OPRFMREADP2(data : Data) -> Int { - var result = 0 - let expectedCount = 1 - - if data.count >= expectedCount { - result = expectedCount - delegate?.dataAvailable(host: self, data: dataToSend) - } + if data.count >= expectedCount { + let pathNumber = Int(data[0]) + let maxBytes = Int(data[1]) * 256 + Int(data[2]) + result = expectedCount + if var descriptor = rfmPaths[pathNumber] { + let (errorCode, lineData) = descriptor.readFromFile(maximumCount: maxBytes) + rfmPaths[pathNumber] = descriptor + let ec = errorCode != 0 ? Int(errorCode) : Int(lineData.count) + log += "OP_RFM_READ(path#\(pathNumber), max=\(maxBytes)) -> \(ec)\n" + print("OP_RFM_READ(path#\(pathNumber), max=\(maxBytes)) -> \(ec)") + if errorCode != 0 { + delegate?.dataAvailable(host: self, data: Data([0x00, 0x00])) + } else { + delegate?.dataAvailable(host: self, data: Data([UInt8(lineData.count >> 8), UInt8(lineData.count & 0xFF)])) + if !lineData.isEmpty { + Thread.sleep(forTimeInterval: 0.002) + delegate?.dataAvailable(host: self, data: lineData) + } + } + } else { + delegate?.dataAvailable(host: self, data: Data([0x00, 0x00])) + } resetState() - - return result } + return result + } + + // Receives: path#(1) then count(2) then count bytes. No response. + // Receives: path#(1) then count(2) then count bytes. No response. + private func OPRFMWRITE(data: Data) -> Int { var result = 0 - let expectedCount = 5 - - if data.count >= expectedCount { - pathDescriptorAddress = Int(data[0]) * 256 + Int(data[1]) - pathNumber = Int(data[2]) - bytesToRead = Int(data[3]) * 256 + Int(data[4]) - result = expectedCount - - // Get number of bytes to read from file on this path. - - // Send the response code to the guest. - // Format of response: - // - 1 byte: error code - // - 2 bytes: length of valid data - (errorCode, dataToSend) = rfmPathDescriptor.readLineFromFile(offset: 0, maximumCount: bytesToRead) - delegate?.dataAvailable(host: self, data: Data([errorCode])) - delegate?.dataAvailable(host: self, data: Data([UInt8((dataToSend.count >> 8) & 0xFF), UInt8(dataToSend.count & 0xFF)])) - if errorCode == 0 { - processor = OPRFMREADP2 - } else { + let headerCount = 3 + + if data.count >= headerCount { + let pathNumber = Int(data[0]) + let byteCount = Int(data[1]) * 256 + Int(data[2]) + let totalCount = headerCount + byteCount + if data.count >= totalCount { + let key = rfmPaths[pathNumber] != nil ? pathNumber + : rfmPaths.first { $0.value.localFile != nil }?.key + if let k = key, var descriptor = rfmPaths[k] { + _ = descriptor.writeToFile(data: Data(data[headerCount.. Int { - var result = 1 return result } - // Perform a GetStat on behalf of the client. - // - // Format of command from the client at this point - // - 1 byte: Process ID - // - 1 byte: Path number (0-15; we use this as part of a unique identifier for this path) - // - 2 bytes: Path descriptor address (we use this as part of a unique identifier for this path) - // - 1 byte: GetStat code - // - // Responses from the host depend upon the GetStat code. - private func OPRFMGETSTT(data : Data) -> Int { + // Receives: path#(1) then maxBytes(2). Sends: count(1) then count bytes. + private func OPRFMREADLN(data: Data) -> Int { var result = 0 - let expectedCount = 4 - var errorCode : UInt8 = 0 + let expectedCount = 3 if data.count >= expectedCount { - rfmPathDescriptor.pathDescriptorAddress = Int(data[0]) * 256 + Int(data[1]) - rfmPathDescriptor.pathNumber = Int(data[2]) - let statCode = Int(data[3]) - - switch statCode { - case 2: - // return file size - let count = rfmPathDescriptor.fileContents.count - delegate?.dataAvailable(host: self, data: Data([errorCode, UInt8((count & 0xFF000000) >> 24), UInt8((count & 0x00FF0000) >> 16), UInt8((count & 0x0000FF00) >> 8), UInt8((count & 0x000000FF) >> 0)])) - log = log + "OP_RFM_GETSTAT(SS.Size) -> R$B=\(errorCode), R$X=\((count & 0xFFFF0000) >> 16), R$U=\((count & 0x0000FFFF) >> 0)\n" - - default: - log = log + "OP_RFM_GETSTAT(\(statCode))\n" - } - + let pathNumber = Int(data[0]) + let maxBytes = Int(data[1]) * 256 + Int(data[2]) result = expectedCount + + if var descriptor = rfmPaths[pathNumber] { + let (errorCode, lineData) = descriptor.readLineFromFile(maximumCount: maxBytes) + rfmPaths[pathNumber] = descriptor + let ec = errorCode != 0 ? Int(errorCode) : Int(lineData.count) + log += "OP_RFM_READLN(path#\(pathNumber), max=\(maxBytes)) -> \(ec)\n" + print("OP_RFM_READLN(path#\(pathNumber), max=\(maxBytes)) -> \(ec)") + if errorCode != 0 { + delegate?.dataAvailable(host: self, data: Data([0x00, 0x00])) + } else { + delegate?.dataAvailable(host: self, data: Data([UInt8(lineData.count >> 8), UInt8(lineData.count & 0xFF)])) + if !lineData.isEmpty { + Thread.sleep(forTimeInterval: 0.002) + delegate?.dataAvailable(host: self, data: lineData) + } + } + } else { + delegate?.dataAvailable(host: self, data: Data([0x00, 0x00])) + } resetState() } - + return result } - // Perform a SetStat on behalf of the client. - // - // Format of command from the client at this point - // - 1 byte: Process ID - // - 1 byte: Path number (0-15; we use this as part of a unique identifier for this path) - // - 2 bytes: Path descriptor address (we use this as part of a unique identifier for this path) - // - 1 byte: SetStat code - // - // Responses from the host depend upon the SetStat code. - private func OPRFMSETSTT(data : Data) -> Int { + // Receives: path#(1) then count(2) then count bytes. No response. + private func OPRFMWRITLN(data: Data) -> Int { var result = 0 - let expectedCount = 4 - var errorCode : UInt8 = 0 + let headerCount = 3 + + if data.count >= headerCount { + let pathNumber = Int(data[0]) + let byteCount = Int(data[1]) * 256 + Int(data[2]) + let totalCount = headerCount + byteCount + if data.count >= totalCount { + let key = rfmPaths[pathNumber] != nil ? pathNumber + : rfmPaths.first { $0.value.localFile != nil }?.key + if let k = key, var descriptor = rfmPaths[k] { + _ = descriptor.writeToFile(data: Data(data[headerCount.. Int { + var result = 0 + let expectedCount = 2 + var capturedPathNumber = 0 if data.count >= expectedCount { - rfmPathDescriptor.pathDescriptorAddress = Int(data[0]) * 256 + Int(data[1]) - rfmPathDescriptor.pathNumber = Int(data[2]) - let statCode = Int(data[3]) - + capturedPathNumber = Int(data[0]) + let statCode = Int(data[1]) result = expectedCount - resetState() - delegate?.dataAvailable(host: self, data: Data([errorCode])) - log = log + "OP_RFM_SETSTAT(\(statCode))\n" + log += "OP_RFM_GETSTT(path#\(capturedPathNumber), \(DriveWireHost.ssCodeName(statCode)))\n" + print("OP_RFM_GETSTT(path#\(capturedPathNumber), \(DriveWireHost.ssCodeName(statCode)))") + if statCode == 0x02 { // SS.Size — send 4-byte file size + let size = rfmPaths[capturedPathNumber]?.fileContents.count ?? 0 + let msg = "OP_RFM_GETSTT(path#\(capturedPathNumber), SS.Size) -> \(size)" + log += msg + "\n"; print(msg) + delegate?.dataAvailable(host: self, data: Data([ + UInt8((size >> 24) & 0xFF), UInt8((size >> 16) & 0xFF), + UInt8((size >> 8) & 0xFF), UInt8(size & 0xFF)])) + resetState() + } else if statCode == 0x05 { // SS.Pos — send 4-byte current position + let pos = rfmPaths[capturedPathNumber]?.filePosition ?? 0 + let msg = "OP_RFM_GETSTT(path#\(capturedPathNumber), SS.Pos) -> \(pos)" + log += msg + "\n"; print(msg) + delegate?.dataAvailable(host: self, data: Data([ + UInt8((pos >> 24) & 0xFF), UInt8((pos >> 16) & 0xFF), + UInt8((pos >> 8) & 0xFF), UInt8(pos & 0xFF)])) + resetState() + } else if statCode == 0x06 { // SS.EOF — send 0 (not EOF) or E$EOF (211) + let isEOF = rfmPaths[capturedPathNumber].map { $0.filePosition >= $0.fileContents.count } ?? true + let response: UInt8 = isEOF ? 211 : 0 + let msg = "OP_RFM_GETSTT(path#\(capturedPathNumber), SS.EOF) -> \(isEOF ? "EOF" : "OK")" + log += msg + "\n"; print(msg) + delegate?.dataAvailable(host: self, data: Data([response])) + resetState() + } else if statCode == 0x0F { // SS.FD + processor = OPRFMGETSSFD + } else if statCode == 0x20 { // SS.FDInf + processor = OPRFMGETSSFDINF + } else { + resetState() + } } - + return result + + func OPRFMGETSSFD(data: Data) -> Int { + guard data.count >= 2 else { return 0 } + let count = min(Int(data[0]) * 256 + Int(data[1]), 256) + let fd = rfmPaths[capturedPathNumber]?.synthesizeFD(count: count) + ?? Data(repeating: 0, count: count) + delegate?.dataAvailable(host: self, data: fd) + log += "OP_RFM_GETSTT_FD(path#\(capturedPathNumber), bytes=\(count))\n" + print("OP_RFM_GETSTT_FD(path#\(capturedPathNumber), bytes=\(count))") + resetState() + return 2 + } + + func OPRFMGETSSFDINF(data: Data) -> Int { + guard data.count >= 4 else { return 0 } + // data[0]=Y_hi=LSN[0], data[1]=Y_lo=length, data[2]=U_hi=LSN[1], data[3]=U_lo=LSN[2] + let lsn = Int(data[0]) << 16 | Int(data[2]) << 8 | Int(data[3]) + var tempDesc = RFMPathDescriptor() + if let hostPath = rfmLSNToPath[lsn] { + tempDesc.attributes = (try? FileManager.default.attributesOfItem(atPath: hostPath)) ?? [:] + } + let fd = tempDesc.synthesizeFD(count: 256) + delegate?.dataAvailable(host: self, data: fd) + let path = rfmLSNToPath[lsn] ?? "unknown" + log += "OP_RFM_GETSTT_FDInf(lsn=0x\(String(lsn, radix: 16)), path=\(path))\n" + print("OP_RFM_GETSTT_FDInf(lsn=0x\(String(lsn, radix: 16)), path=\(path))") + resetState() + return 4 + } } - // Perform a close on behalf of the client. - // - // Format of command from the client at this point - // - 1 byte: Process ID - // - 1 byte: Path number (0-15; we use this as part of a unique identifier for this path) - // - 2 bytes: Path descriptor address (we use this as part of a unique identifier for this path) - // - // The host responds with a single error code. - private func OPRFMCLOSE(data : Data) -> Int { + // Receives nothing (rfm.asm sendit only sends the sub-op). No response. + private func OPRFMSETSTT(data: Data) -> Int { + print("OP_RFM_SETSTT") + resetState() + return 0 + } + + // Receives: path#(1). Sends back: 1-byte error code. + // Receives: path#(1). Sends back: 1-byte error code. + // If the path was opened via CREATE, the shell closes its reference after + // forking the child writer — keep the descriptor alive for incoming writes. + private func OPRFMCLOSE(data: Data) -> Int { var result = 0 - let expectedCount = 4 - var errorCode : UInt8 = 0 - + let expectedCount = 1 + if data.count >= expectedCount { - let processID = Int(data[0]) - let pathNumber = Int(data[1]) - let pathDescriptorAddress = Int(data[2]) * 256 + Int(data[3]) - + let pathNumber = Int(data[0]) + rfmPaths[pathNumber]?.localFile?.closeFile() + rfmPaths.removeValue(forKey: pathNumber) result = expectedCount - - errorCode = 0 - delegate?.dataAvailable(host: self, data: Data([errorCode])) - + delegate?.dataAvailable(host: self, data: Data([0x00])) resetState() + log += "OP_RFM_CLOSE(path#\(pathNumber))\n" + print("OP_RFM_CLOSE(path#\(pathNumber))") } - + return result } @@ -1486,7 +2339,8 @@ extension DriveWireHost { // Send the response code to the guest. delegate?.dataAvailable(host: self, data: Data([UInt8(error)])) - + let msg = "OP_READEX(drive=\(statistics.lastDriveNumber), lsn=0x\(String(statistics.lastLSN, radix: 16))) -> \(error)" + reportActivity(msg, isFrequent: true, isError: error != DriveWireProtocolError.E_NONE.rawValue) // Reset the state machine. resetState() } @@ -1515,9 +2369,13 @@ extension DriveWireHost { // We read 5 bytes into this buffer (OP_READEX, 1 byte drive number, 3 byte LSN) result = 5; - - // Check if the drive number exists in our virtual drive list. - if let virtualDrive = virtualDrives.first(where: { $0.driveNumber == driveNumber }) { + + // Check if this LSN belongs to a synthesized RFM file descriptor. + if let hostPath = rfmLSNToPath[vLSN] { + var tempDesc = RFMPathDescriptor() + tempDesc.attributes = (try? FileManager.default.attributesOfItem(atPath: hostPath)) ?? [:] + sectorBuffer = tempDesc.synthesizeFD(count: 256) + } else if let virtualDrive = virtualDrives.first(where: { $0.driveNumber == driveNumber }) { // It exists! Read sector from disk image. statistics.lastDriveNumber = driveNumber statistics.readCount = statistics.readCount + 1 @@ -1527,8 +2385,6 @@ extension DriveWireHost { // It doesn't exist. Set the error code. error = DriveWireProtocolError.E_UNIT.rawValue } - - // Send the error code delegate?.dataAvailable(host: self, data: Data([UInt8(error)])) // If we have an OK response, we send the sector and checksum. @@ -1596,14 +2452,14 @@ extension Data { extension DriveWireHost { /// A representation of a storage device. public class VirtualDrive : Codable { - + enum CodingKeys: String, CodingKey { + case driveNumber + case imagePath + case bookmarkData + } + func didReceive(changes: String) { - do { - let u = URL(fileURLWithPath: self.imagePath) - self.storageContainer = try Data(contentsOf:u) - } catch { - print(error) - } + reload() } /// The drive number for this drive. @@ -1614,6 +2470,7 @@ extension DriveWireHost { private var bookmarkData = Data() private var storageContainer = Data() + private var isStorageLoaded = false /// Creates a new virtual drive. /// @@ -1630,11 +2487,21 @@ extension DriveWireHost { public func reload() { do { let u = URL(fileURLWithPath: self.imagePath) - self.storageContainer = try Data(contentsOf:u) + self.storageContainer = try Data(contentsOf: u) + self.isStorageLoaded = true } catch { + self.storageContainer = Data() + self.isStorageLoaded = false print(error) } } + + private func ensureStorageLoaded() { + guard !isStorageLoaded, !imagePath.isEmpty else { + return + } + reload() + } /// Reads a 256 byte sector from a virtual disk. /// @@ -1644,6 +2511,8 @@ extension DriveWireHost { /// - Parameters: /// - lsn: The logical sector number to read. public func readSector(lsn : Int) -> (Int, Data) { + ensureStorageLoaded() + // Seek to the offset in the file represented by the URL. let offsetStart = lsn * 256 let offsetEnd = offsetStart + 256 @@ -1668,6 +2537,8 @@ extension DriveWireHost { /// - lsn: The logical sector number to write. /// - sector: The 256-byte sector to write. public func writeSector(lsn : Int, sector : Data) -> Int { + ensureStorageLoaded() + // Seek to the offset in the file represented by the URL. let offsetStart = lsn * 256 let offsetEnd = offsetStart + 256 @@ -1680,8 +2551,25 @@ extension DriveWireHost { storageContainer[range] = sector } + isStorageLoaded = true return DriveWireProtocolError.E_NONE.rawValue } + + public required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.driveNumber = try values.decode(Int.self, forKey: .driveNumber) + self.imagePath = try values.decode(String.self, forKey: .imagePath) + self.bookmarkData = try values.decodeIfPresent(Data.self, forKey: .bookmarkData) ?? Data() + self.storageContainer = Data() + self.isStorageLoaded = false + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(driveNumber, forKey: .driveNumber) + try container.encode(imagePath, forKey: .imagePath) + try container.encode(bookmarkData, forKey: .bookmarkData) + } } } diff --git a/swift/drivewire-cli/main.swift b/swift/drivewire-cli/main.swift index fbe1a73..c545a35 100644 --- a/swift/drivewire-cli/main.swift +++ b/swift/drivewire-cli/main.swift @@ -10,7 +10,10 @@ import ArgumentParser struct DriveWireCmd: ParsableCommand { @Option(name: .shortAndLong, help: "Serial port path (e.g. /dev/cu.usbserial-FTVA079L)") - var port: String + var port: String? + + @Option(name: .long, help: "TCP port to listen on for incoming guest connections (e.g. MAME -bitb socket.127.0.0.1:)") + var tcpPort: UInt16? @Option(name: .shortAndLong, help: "Baud rate for the serial port") var baudRate: Int = 57600 @@ -30,29 +33,36 @@ struct DriveWireCmd: ParsableCommand { @Flag(name: .shortAndLong, help: "Show client activity") var verbose: Bool = false + @Option(name: .long, help: "Root path for RFM file access (default: home directory)") + var rfmRoot: String = NSHomeDirectory() + func run() throws { - let d = DriveWireSerialDriver() - - d.baudRate = baudRate - d.portName = port - d.logging = verbose - - if let disk0Path = disk0 { - try d.host.insertVirtualDisk(driveNumber: 0, imagePath: disk0Path) - } - - if let disk1Path = disk1 { - try d.host.insertVirtualDisk(driveNumber: 1, imagePath: disk1Path) + guard port != nil || tcpPort != nil else { + throw ValidationError("Provide either --port (serial) or --tcp-port (TCP server).") } - - if let disk2Path = disk2 { - try d.host.insertVirtualDisk(driveNumber: 2, imagePath: disk2Path) + + func insertDisks(into host: DriveWireHost) throws { + if let p = disk0 { try host.insertVirtualDisk(driveNumber: 0, imagePath: p) } + if let p = disk1 { try host.insertVirtualDisk(driveNumber: 1, imagePath: p) } + if let p = disk2 { try host.insertVirtualDisk(driveNumber: 2, imagePath: p) } + if let p = disk3 { try host.insertVirtualDisk(driveNumber: 3, imagePath: p) } } - - if let disk3Path = disk3 { - try d.host.insertVirtualDisk(driveNumber: 3, imagePath: disk3Path) + + if let listenPort = tcpPort { + let d = DriveWireTCPServerDriver() + d.logging = verbose + d.host.rfmRootPath = rfmRoot + try insertDisks(into: d.host) + try d.start(port: listenPort) + } else if let serialPort = port { + let d = DriveWireSerialDriver() + d.baudRate = baudRate + d.portName = serialPort + d.logging = verbose + d.host.rfmRootPath = rfmRoot + try insertDisks(into: d.host) } - + while true { RunLoop.current.run(mode: .default, before: Date.distantFuture) }