diff --git a/SecVF.xcodeproj/project.pbxproj b/SecVF.xcodeproj/project.pbxproj index cfd4186..5a36ded 100644 --- a/SecVF.xcodeproj/project.pbxproj +++ b/SecVF.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ CC1111CC11111111CC111CC1 /* VMConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */; }; SPKT002SPKT002SPKT002002 /* SparklineViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */; }; TUIT002TUIT002TUIT002002 /* TacticalUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */; }; + VCCT002VCCT002VCCT002002 /* VMCardCellAndCycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = VCCT001VCCT001VCCT001001 /* VMCardCellAndCycleTests.swift */; }; SBFT002SBFT002SBFT002002 /* SidebarFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */; }; NPT002NPT002NPT002002 /* NetworkPeersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NPT001NPT001NPT001001 /* NetworkPeersTests.swift */; }; PFPT002PFPT002PFPT002002 /* PacketFilterPresetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */; }; @@ -73,6 +74,7 @@ THBT002THBT002THBT002002 /* TacticalHoverButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = THBT001THBT001THBT001001 /* TacticalHoverButton.swift */; }; TTRV002TTRV002TTRV002002 /* TacticalTableRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */; }; TESV002TESV002TESV002002 /* TacticalEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */; }; + VMCV002VMCV002VMCV002002 /* VMCardCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = VMCV001VMCV001VMCV001001 /* VMCardCellView.swift */; }; ICMW002ICMW002ICMW002002 /* ISOCacheManagerWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = ICMW001ICMW001ICMW001001 /* ISOCacheManagerWindow.swift */; }; SSWC002SSWC002SSWC002002 /* SwitchStatisticsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = SSWC001SSWC001SSWC001001 /* SwitchStatisticsWindowController.swift */; }; TSSC002TSSC002TSSC002002 /* TacticalSidebarSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */; }; @@ -129,6 +131,7 @@ DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigurationTests.swift; sourceTree = ""; }; SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparklineViewTests.swift; sourceTree = ""; }; TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalUITests.swift; sourceTree = ""; }; + VCCT001VCCT001VCCT001001 /* VMCardCellAndCycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMCardCellAndCycleTests.swift; sourceTree = ""; }; SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFilterTests.swift; sourceTree = ""; }; NPT001NPT001NPT001001 /* NetworkPeersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPeersTests.swift; sourceTree = ""; }; PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketFilterPresetsTests.swift; sourceTree = ""; }; @@ -169,6 +172,7 @@ THBT001THBT001THBT001001 /* TacticalHoverButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalHoverButton.swift; sourceTree = ""; }; TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalTableRowView.swift; sourceTree = ""; }; TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalEmptyStateView.swift; sourceTree = ""; }; + VMCV001VMCV001VMCV001001 /* VMCardCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMCardCellView.swift; sourceTree = ""; }; ICMW001ICMW001ICMW001001 /* ISOCacheManagerWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ISOCacheManagerWindow.swift; sourceTree = ""; }; SSWC001SSWC001SSWC001001 /* SwitchStatisticsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchStatisticsWindowController.swift; sourceTree = ""; }; TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalSidebarSection.swift; sourceTree = ""; }; @@ -250,6 +254,7 @@ THBT001THBT001THBT001001 /* TacticalHoverButton.swift */, TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */, TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */, + VMCV001VMCV001VMCV001001 /* VMCardCellView.swift */, ICMW001ICMW001ICMW001001 /* ISOCacheManagerWindow.swift */, SSWC001SSWC001SSWC001001 /* SwitchStatisticsWindowController.swift */, TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */, @@ -307,6 +312,7 @@ DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */, SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */, TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */, + VCCT001VCCT001VCCT001001 /* VMCardCellAndCycleTests.swift */, SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */, NPT001NPT001NPT001001 /* NetworkPeersTests.swift */, PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */, @@ -475,6 +481,7 @@ THBT002THBT002THBT002002 /* TacticalHoverButton.swift in Sources */, TTRV002TTRV002TTRV002002 /* TacticalTableRowView.swift in Sources */, TESV002TESV002TESV002002 /* TacticalEmptyStateView.swift in Sources */, + VMCV002VMCV002VMCV002002 /* VMCardCellView.swift in Sources */, ICMW002ICMW002ICMW002002 /* ISOCacheManagerWindow.swift in Sources */, SSWC002SSWC002SSWC002002 /* SwitchStatisticsWindowController.swift in Sources */, TSSC002TSSC002TSSC002002 /* TacticalSidebarSection.swift in Sources */, @@ -511,6 +518,7 @@ CC1111CC11111111CC111CC1 /* VMConfigurationTests.swift in Sources */, SPKT002SPKT002SPKT002002 /* SparklineViewTests.swift in Sources */, TUIT002TUIT002TUIT002002 /* TacticalUITests.swift in Sources */, + VCCT002VCCT002VCCT002002 /* VMCardCellAndCycleTests.swift in Sources */, SBFT002SBFT002SBFT002002 /* SidebarFilterTests.swift in Sources */, NPT002NPT002NPT002002 /* NetworkPeersTests.swift in Sources */, PFPT002PFPT002PFPT002002 /* PacketFilterPresetsTests.swift in Sources */, diff --git a/SecVF/Tests/VMCardCellAndCycleTests.swift b/SecVF/Tests/VMCardCellAndCycleTests.swift new file mode 100644 index 0000000..62ca5e3 --- /dev/null +++ b/SecVF/Tests/VMCardCellAndCycleTests.swift @@ -0,0 +1,164 @@ +// +// VMCardCellAndCycleTests.swift +// SecVFTests +// +// Tests for the multi-line VM card cell and the network-mode cycle. +// Cycle logic is the priority — the cell view tests just verify the +// card's public configuration surface (full UI rendering is covered +// visually). +// + +import XCTest +@testable import SecVF + +@MainActor +final class VMCardCellAndCycleTests: XCTestCase { + + // MARK: - Network-mode cycle (pure logic) + + func testCycleNATGoesToVirtualIsolated() { + let nat = VirtualNetworkConfig(mode: .nat, routerVMId: nil, isRouter: false) + let next = VMManager.nextNetworkConfig(after: nat) + XCTAssertEqual(next.mode, .virtual) + XCTAssertFalse(next.isRouter) + XCTAssertNil(next.routerVMId) + } + + func testCycleVirtualIsolatedGoesToRouter() { + let virt = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: false) + let next = VMManager.nextNetworkConfig(after: virt) + XCTAssertEqual(next.mode, .virtual) + XCTAssertTrue(next.isRouter) + XCTAssertNil(next.routerVMId) + } + + func testCycleRouterGoesToNAT() { + let router = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: true) + let next = VMManager.nextNetworkConfig(after: router) + XCTAssertEqual(next.mode, .nat) + XCTAssertFalse(next.isRouter) + XCTAssertNil(next.routerVMId) + } + + func testCycleVirtualGuestDropsToNATCleanly() { + // A virtual-mode VM with a routerVMId set (guest of someone) + // shouldn't drag its stale guest-of relationship through the + // cycle. The cycle drops it to NAT and clears the routerVMId. + let guest = VirtualNetworkConfig(mode: .virtual, + routerVMId: UUID(), + isRouter: false) + let next = VMManager.nextNetworkConfig(after: guest) + XCTAssertEqual(next.mode, .nat) + XCTAssertNil(next.routerVMId) + XCTAssertFalse(next.isRouter) + } + + func testCycleIsCircular() { + // Four cycles from NAT returns to NAT-equivalent state. Note: + // a guest VM short-circuits to NAT in one step, so this test + // walks the canonical NAT → Virtual → Router → NAT path. + var cfg = VirtualNetworkConfig(mode: .nat, routerVMId: nil, isRouter: false) + let initial = cfg + cfg = VMManager.nextNetworkConfig(after: cfg) // → Virtual + cfg = VMManager.nextNetworkConfig(after: cfg) // → Router + cfg = VMManager.nextNetworkConfig(after: cfg) // → NAT + XCTAssertEqual(cfg.mode, initial.mode) + XCTAssertEqual(cfg.isRouter, initial.isRouter) + XCTAssertEqual(cfg.routerVMId, initial.routerVMId) + } + + // MARK: - Network chip rendering + + func testNetworkChipShowsNATForNATMode() { + let cfg = VirtualNetworkConfig(mode: .nat, routerVMId: nil, isRouter: false) + let (text, _) = VMCardCellView.networkChipSpec(for: cfg) + XCTAssertTrue(text.contains("NAT"), + "NAT chip must mention NAT in its label, got: \(text)") + } + + func testNetworkChipShowsRouterForRouterMode() { + let cfg = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: true) + let (text, _) = VMCardCellView.networkChipSpec(for: cfg) + XCTAssertTrue(text.uppercased().contains("ROUTER"), + "Router chip must mention ROUTER, got: \(text)") + } + + func testNetworkChipShowsGuestForGuestMode() { + let cfg = VirtualNetworkConfig(mode: .virtual, + routerVMId: UUID(), + isRouter: false) + let (text, _) = VMCardCellView.networkChipSpec(for: cfg) + XCTAssertTrue(text.uppercased().contains("GUEST"), + "Guest chip must mention GUEST, got: \(text)") + } + + func testNetworkChipShowsIsolatedForVirtualNoRouter() { + let cfg = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: false) + let (text, _) = VMCardCellView.networkChipSpec(for: cfg) + XCTAssertTrue(text.uppercased().contains("ISOLATED"), + "Isolated chip must mention ISOLATED, got: \(text)") + } + + // MARK: - Status pill spec + + func testStatusPillTextMatchesEachStatus() { + let cases: [(VMStatus, String)] = [ + (.running, "RUNNING"), + (.starting, "STARTING"), + (.stopping, "STOPPING"), + (.stopped, "STOPPED"), + ] + for (status, expected) in cases { + let (_, text, _) = VMCardCellView.statusPillSpec(for: status) + XCTAssertEqual(text, expected, + "Status \(status) should pill-render as \(expected), got \(text)") + } + } + + // MARK: - Formatters + + func testFormatPacketCountUsesGroupingSeparator() { + XCTAssertEqual(VMCardCellView.formatPacketCount(0), "0") + XCTAssertEqual(VMCardCellView.formatPacketCount(42), "42") + XCTAssertEqual(VMCardCellView.formatPacketCount(1_234), "1,234") + XCTAssertEqual(VMCardCellView.formatPacketCount(1_234_567),"1,234,567") + } + + func testFormatBpsCrossesUnitThresholds() { + XCTAssertTrue(VMCardCellView.formatBps(500).contains("B/s"), + "Sub-kB rates show as raw B/s") + XCTAssertTrue(VMCardCellView.formatBps(2_048).contains("kB/s"), + "2 kB ≥ 1024 should render as kB/s") + XCTAssertTrue(VMCardCellView.formatBps(5_000_000).contains("MB/s"), + "5 MB ≥ 1 MB should render as MB/s") + XCTAssertTrue(VMCardCellView.formatBps(3_000_000_000).contains("GB/s"), + "3 GB ≥ 1 GB should render as GB/s") + } + + // MARK: - Cell construction sanity + + func testVMCardCellInitialFrameIsRowHeight() { + let cell = VMCardCellView(frame: NSRect(x: 0, y: 0, width: 400, + height: VMCardCellView.rowHeight)) + XCTAssertEqual(cell.frame.height, VMCardCellView.rowHeight) + XCTAssertEqual(cell.identifier, VMCardCellView.identifier) + } + + func testVMCardCellConfigureSurvivesAllStatuses() { + // Survival-only — fully painting the cell requires a graphics + // context that we don't have in a unit test. Verifying the + // configure() method doesn't crash for any status keeps the + // happy-path bound for table reload integrity. + let cell = VMCardCellView(frame: NSRect(x: 0, y: 0, width: 400, + height: VMCardCellView.rowHeight)) + var vm = VMConfiguration(name: "X", bundlePath: "/tmp/X.bundle/") + for status in [VMStatus.running, .starting, .stopping, .stopped] { + vm.status = status + cell.configure(with: vm, + liveDownBps: 100.0, + liveUpBps: 50.0, + trafficSamples: [1, 2, 3], + packetCount: 42) + } + } +} diff --git a/SecVF/VMCardCellView.swift b/SecVF/VMCardCellView.swift new file mode 100644 index 0000000..3283395 --- /dev/null +++ b/SecVF/VMCardCellView.swift @@ -0,0 +1,359 @@ +// +// VMCardCellView.swift +// SecVF +// +// Multi-line table cell for the VM library. Replaces the previous +// flat per-column NSTextField cells with a single dense card that +// shows three rows of metadata: +// +// ┌──────────────────────────────────────────────────────────┐ +// │ kali-router ● RUNNING │ +// │ Kali Linux 2025.1 · 4 cores · 4 GB · 12 / 32 GB │ +// │ [⇄ VIRTUAL ▾] ↓ 12 kB/s · ↑ 0.3 kB/s 1,284 pkts ▁▄▇ │ +// └──────────────────────────────────────────────────────────┘ +// +// The network-mode chip on the third line is a click target — tapping +// it cycles the VM's mode (NAT → Virtual → Router → NAT) via the +// owning controller. The cycle is blocked when the VM is running, +// per Apple's Virtualization framework's one-shot network-attachment +// semantics; the click handler still fires but the controller +// surfaces a brief alert instead of mutating state. +// +// The cell is mode-aware: AI Sandbox bundles use the same view with +// the third line repurposed for "TEMPLATE" / "SESSION" badging. +// + +import Cocoa + +@MainActor +final class VMCardCellView: NSTableCellView { + + static let identifier = NSUserInterfaceItemIdentifier("VMCardCellView") + static let rowHeight: CGFloat = 62 + + // MARK: - Subviews + + private let nameLabel = NSTextField(labelWithString: "") + private let statusPill = NSTextField(labelWithString: "") + private let metaLabel = NSTextField(labelWithString: "") + private let networkChip = NSButton(title: "—", target: nil, action: nil) + private let rateLabel = NSTextField(labelWithString: "") + private let packetsLabel = NSTextField(labelWithString: "") + let sparkline = SparklineView(frame: NSRect(x: 0, y: 0, width: 60, height: 16)) + + // MARK: - Bound VM + + /// Called when the user clicks the network-mode chip. The owning + /// controller looks up the VM by id and calls VMManager.cycleNetworkMode. + var onCycleNetworkMode: ((UUID) -> Void)? + + /// VM ID currently rendered. Used by the click handler to route + /// the cycle request back to the controller without retaining the + /// full VMConfiguration value. + private var renderedVMID: UUID? + + // MARK: - Init + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + identifier = Self.identifier + wantsLayer = true + buildLayout() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + buildLayout() + } + + // MARK: - Layout + + private func buildLayout() { + let padL: CGFloat = 14 + let padR: CGFloat = 14 + + // Row 1 — Name (left) + Status pill (right) + nameLabel.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .semibold) + nameLabel.textColor = AppColors.textPrimary + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.frame = NSRect(x: padL, y: 42, width: 200, height: 18) + nameLabel.autoresizingMask = [.width, .minYMargin] + addSubview(nameLabel) + + statusPill.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .semibold) + statusPill.alignment = .center + statusPill.wantsLayer = true + statusPill.layer?.cornerRadius = 9 + statusPill.layer?.borderWidth = 1.0 + statusPill.isBordered = false + statusPill.drawsBackground = false + statusPill.lineBreakMode = .byClipping + statusPill.frame = NSRect(x: 0, y: 42, width: 84, height: 18) + statusPill.autoresizingMask = [.minXMargin, .minYMargin] + addSubview(statusPill) + + // Row 2 — Meta line (OS · CPU·RAM · Disk) + metaLabel.font = NSFont.systemFont(ofSize: 11, weight: .regular) + metaLabel.textColor = AppColors.textMuted + metaLabel.lineBreakMode = .byTruncatingTail + metaLabel.frame = NSRect(x: padL, y: 24, width: 400, height: 14) + metaLabel.autoresizingMask = [.width, .minYMargin] + addSubview(metaLabel) + + // Row 3 — Network chip (clickable) + Rate + Packets + Sparkline + networkChip.target = self + networkChip.action = #selector(handleNetworkChipClick) + networkChip.isBordered = false + networkChip.wantsLayer = true + networkChip.layer?.borderWidth = 1.0 + networkChip.layer?.cornerRadius = 8 + networkChip.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .semibold) + networkChip.alignment = .center + networkChip.frame = NSRect(x: padL, y: 4, width: 110, height: 16) + networkChip.autoresizingMask = [.minYMargin] + addSubview(networkChip) + + rateLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular) + rateLabel.textColor = AppColors.textSubtle + rateLabel.frame = NSRect(x: padL + 120, y: 5, width: 180, height: 14) + rateLabel.autoresizingMask = [.minYMargin] + addSubview(rateLabel) + + packetsLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular) + packetsLabel.textColor = AppColors.textSubtle + packetsLabel.alignment = .right + packetsLabel.frame = NSRect(x: 0, y: 5, width: 110, height: 14) + packetsLabel.autoresizingMask = [.minXMargin, .minYMargin] + addSubview(packetsLabel) + + sparkline.frame = NSRect(x: 0, y: 2, width: 60, height: 18) + sparkline.autoresizingMask = [.minXMargin, .minYMargin] + addSubview(sparkline) + + // Re-anchor right-edge subviews on layout pass + anchorRightEdgeSubviews() + } + + override func layout() { + super.layout() + anchorRightEdgeSubviews() + } + + private func anchorRightEdgeSubviews() { + let padR: CGFloat = 14 + let pillW: CGFloat = 84 + let pillH: CGFloat = 18 + statusPill.frame = NSRect(x: bounds.width - padR - pillW, y: 42, + width: pillW, height: pillH) + + let sparkW: CGFloat = 60 + let sparkH: CGFloat = 18 + sparkline.frame = NSRect(x: bounds.width - padR - sparkW, y: 2, + width: sparkW, height: sparkH) + + let packetsW: CGFloat = 110 + packetsLabel.frame = NSRect(x: bounds.width - padR - sparkW - 8 - packetsW, y: 5, + width: packetsW, height: 14) + + // Name + meta line widths track the available space minus the + // right-anchored elements + a small gap. + let rightStop = bounds.width - padR - pillW - 8 + nameLabel.frame.size.width = max(40, rightStop - nameLabel.frame.origin.x) + + let metaRightStop = bounds.width - padR + metaLabel.frame.size.width = max(40, metaRightStop - metaLabel.frame.origin.x) + + let rateRightStop = bounds.width - padR - sparkW - 8 - packetsW - 12 + rateLabel.frame.size.width = max(40, rateRightStop - rateLabel.frame.origin.x) + } + + // MARK: - Configuration + + /// Populate the cell from a standard VM. The owning controller + /// calls this whenever the row needs to render — once on initial + /// reuse and again on any state change that doesn't move rows. + func configure(with vm: VMConfiguration, + liveDownBps: Double?, + liveUpBps: Double?, + trafficSamples: [Double], + packetCount: UInt64?) { + renderedVMID = vm.id + + // Row 1 + nameLabel.stringValue = vm.name + (vm.networkConfig.isRouter ? " ⬡ ROUTER" : "") + + let (pillGlyph, pillText, pillColor) = Self.statusPillSpec(for: vm.status) + let pillAttr = NSMutableAttributedString(string: pillGlyph + " ", attributes: [ + .foregroundColor: pillColor + ]) + pillAttr.append(NSAttributedString(string: pillText, attributes: [ + .foregroundColor: pillColor.withAlphaComponent(0.95) + ])) + statusPill.attributedStringValue = pillAttr + statusPill.layer?.backgroundColor = pillColor.withAlphaComponent(0.12).cgColor + statusPill.layer?.borderColor = pillColor.withAlphaComponent(0.45).cgColor + + // Row 2 — meta + let osStr: String = { + if vm.osType == "Linux", let distro = vm.linuxDistribution { + if let v = vm.linuxVersion, !v.isEmpty { return "\(distro) \(v)" } + return distro + } + return vm.osType + }() + let ramGB = Double(vm.memorySize) / 1_073_741_824.0 + let diskGB = Double(vm.diskSize) / 1_073_741_824.0 + metaLabel.stringValue = String(format: "%@ · %d cores · %.1f GB · %.0f GB disk", + osStr, vm.cpuCount, ramGB, diskGB) + + // Row 3 — network chip + let (chipText, chipColor) = Self.networkChipSpec(for: vm.networkConfig) + let chipAttr = NSAttributedString(string: chipText, attributes: [ + .foregroundColor: chipColor, + .font: NSFont.monospacedSystemFont(ofSize: 9, weight: .semibold) + ]) + networkChip.attributedTitle = chipAttr + networkChip.layer?.backgroundColor = chipColor.withAlphaComponent(0.10).cgColor + networkChip.layer?.borderColor = chipColor.withAlphaComponent(0.45).cgColor + networkChip.toolTip = (vm.status == .stopped) + ? "Click to cycle network mode (NAT → Virtual → Router → NAT)" + : "Stop the VM to change network mode — Apple's Virtualization framework attaches the network device once at boot" + + // Row 3 — rate readout + if vm.status == .running, vm.networkConfig.mode == .virtual, + let down = liveDownBps, let up = liveUpBps, (down + up) > 0 { + let downStr = Self.formatBps(down) + let upStr = Self.formatBps(up) + rateLabel.stringValue = "↓ \(downStr) · ↑ \(upStr)" + } else if vm.status == .running, vm.networkConfig.mode == .nat { + rateLabel.stringValue = "host-routed" + } else { + rateLabel.stringValue = "—" + } + + // Row 3 — packets + if let pkts = packetCount { + packetsLabel.stringValue = Self.formatPacketCount(pkts) + " pkts" + } else { + packetsLabel.stringValue = "—" + } + + // Sparkline + sparkline.samples = trafficSamples + + anchorRightEdgeSubviews() + } + + /// Configuration entry point for an AI Sandbox bundle row. Same + /// three-line layout, but row 3 collapses to a static badge + /// (TEMPLATE / SESSION) instead of a clickable network chip. + func configureForSandbox(bundle: AISandboxBundleRow) { + renderedVMID = nil // network click is disabled for sandbox rows + + let suffix = bundle.isBase ? " (base)" : "" + nameLabel.stringValue = bundle.displayName + suffix + + let (pillGlyph, pillText, pillColor): (String, String, NSColor) = bundle.isBase + ? ("◆", "TEMPLATE", AppColors.accentOrange) + : ("●", "SESSION", AppColors.statusRunning) + let pillAttr = NSMutableAttributedString(string: pillGlyph + " ", attributes: [ + .foregroundColor: pillColor + ]) + pillAttr.append(NSAttributedString(string: pillText, attributes: [ + .foregroundColor: pillColor.withAlphaComponent(0.95) + ])) + statusPill.attributedStringValue = pillAttr + statusPill.layer?.backgroundColor = pillColor.withAlphaComponent(0.12).cgColor + statusPill.layer?.borderColor = pillColor.withAlphaComponent(0.45).cgColor + + let diskStr = ByteCountFormatter.string(fromByteCount: bundle.diskBytes, + countStyle: .binary) + let ramStr = bundle.isBase ? "8 GB" : "—" + metaLabel.stringValue = "macOS · 4 cores · \(ramStr) · \(diskStr)" + + let chipText = bundle.isBase ? "⬡ BASE BUNDLE" : "↳ SESSION" + let chipColor = bundle.isBase ? AppColors.accentOrange : AppColors.statusRunning + let chipAttr = NSAttributedString(string: chipText, attributes: [ + .foregroundColor: chipColor, + .font: NSFont.monospacedSystemFont(ofSize: 9, weight: .semibold) + ]) + networkChip.attributedTitle = chipAttr + networkChip.layer?.backgroundColor = chipColor.withAlphaComponent(0.10).cgColor + networkChip.layer?.borderColor = chipColor.withAlphaComponent(0.45).cgColor + networkChip.toolTip = nil + + rateLabel.stringValue = "—" + if let created = bundle.createdAt { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + packetsLabel.stringValue = f.string(from: created) + } else { + packetsLabel.stringValue = "—" + } + + sparkline.samples = [] + anchorRightEdgeSubviews() + } + + // MARK: - Actions + + @objc private func handleNetworkChipClick() { + guard let id = renderedVMID else { return } + onCycleNetworkMode?(id) + } + + // MARK: - Static helpers + + static func statusPillSpec(for status: VMStatus) -> (glyph: String, text: String, color: NSColor) { + switch status { + case .running: return ("●", "RUNNING", AppColors.statusRunning) + case .starting: return ("◐", "STARTING", AppColors.statusPaused) + case .stopping: return ("◐", "STOPPING", AppColors.statusPaused) + case .stopped: return ("○", "STOPPED", AppColors.statusStopped) + } + } + + static func networkChipSpec(for config: VirtualNetworkConfig) + -> (text: String, color: NSColor) + { + switch config.mode { + case .nat: + return ("🌐 NAT", AppColors.networkNAT) + case .virtual: + if config.isRouter { + return ("⬢ ROUTER", AppColors.accentOrange) + } else if config.routerVMId != nil { + return ("↳ GUEST", AppColors.accentODGlow) + } else { + return ("⊘ ISOLATED", AppColors.accentODGlow) + } + } + } + + private static let packetCountFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.groupingSeparator = "," + return f + }() + + static func formatPacketCount(_ count: UInt64) -> String { + return packetCountFormatter.string(from: NSNumber(value: count)) ?? "\(count)" + } + + static func formatBps(_ bps: Double) -> String { + let units: [(threshold: Double, label: String)] = [ + (1_073_741_824, "GB/s"), + (1_048_576, "MB/s"), + (1_024, "kB/s"), + ] + for (threshold, label) in units { + if bps >= threshold { + return String(format: "%.1f %@", bps / threshold, label) + } + } + return String(format: "%.0f B/s", bps) + } +} diff --git a/SecVF/VMLibraryWindowController.swift b/SecVF/VMLibraryWindowController.swift index bae932f..92bb20a 100644 --- a/SecVF/VMLibraryWindowController.swift +++ b/SecVF/VMLibraryWindowController.swift @@ -312,20 +312,15 @@ class VMLibraryWindowController: NSWindowController, trafficFallOverlay = overlay } - // Programmatically append a Traffic column at the end of whatever - // the XIB defined. Done in code (not the XIB) so the column can - // host a custom NSView (SparklineView) rather than the default - // NSTextField cell. The column is non-resizable and ~80pt wide — - // enough for a glanceable line, not so wide it eats other columns. - if let tableView = tableView, - !tableView.tableColumns.contains(where: { $0.identifier.rawValue == "TrafficColumn" }) { - let trafficCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("TrafficColumn")) - trafficCol.title = "Traffic" - trafficCol.headerCell = TacticalTableHeaderCell(textCell: "Traffic") - trafficCol.width = 80 - trafficCol.minWidth = 60 - trafficCol.maxWidth = 120 - tableView.addTableColumn(trafficCol) + // Convert the XIB's column-based table layout into a single + // full-width "VM" column that hosts a custom multi-line card + // cell. The XIB still defines the per-field columns (Name / + // Status / OS / CPU / Memory / Disk / LastUsed) but we drop + // them at runtime in favor of the new card view — the user + // explicitly asked for 2–3 line rows and a clickable + // network-mode cycle that doesn't fit the column grid. + if let tableView = tableView { + switchTableToCardMode(tableView) } // Apply tactical header styling to every XIB-defined column, too. @@ -4012,6 +4007,107 @@ class VMLibraryWindowController: NSWindowController, // MARK: - NSTableViewDelegate /// Dequeue (or build) the cell for the Traffic column. The cell hosts a + /// Switch the NSTableView into single-column card mode. Drops the + /// XIB-defined per-field columns (Name/Status/OS/CPU/Memory/Disk/ + /// LastUsed) and replaces them with one full-width "VMCardColumn" + /// that hosts the multi-line VMCardCellView. Idempotent — safe to + /// call repeatedly; subsequent calls no-op. + /// + /// Why a runtime swap instead of editing the XIB: the existing + /// columns are referenced by tests (column id strings appear in + /// columnAlignment/columnUsesMonospacedFont helpers) and by the + /// AI Sandbox path. Pruning them at runtime keeps both paths + /// converging on the same cell type without forcing a XIB edit. + private func switchTableToCardMode(_ tableView: NSTableView) { + // Bail if already in card mode. + if tableView.tableColumns.contains(where: { $0.identifier.rawValue == "VMCardColumn" }) { + return + } + + // Drop every existing column (XIB-defined + any programmatic + // additions like the old TrafficColumn). + for column in Array(tableView.tableColumns) { + tableView.removeTableColumn(column) + } + + let cardCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("VMCardColumn")) + cardCol.title = "" + cardCol.headerCell = TacticalTableHeaderCell(textCell: "") + cardCol.minWidth = 360 + cardCol.resizingMask = .autoresizingMask + tableView.addTableColumn(cardCol) + + // Card height drives the row height. Tweak rowHeight on the + // table itself — NSTableView reads this for every row without + // a heightOfRow delegate method. + tableView.rowHeight = VMCardCellView.rowHeight + tableView.intercellSpacing = NSSize(width: 0, height: 4) + tableView.headerView = nil // single-column card has no useful header + } + + /// Dequeue / build the multi-line card cell for the given row. + /// Routes through `standardVM(at:)` for the Standard tab and the + /// AI Sandbox bundle list for the Sandbox tab, configuring the + /// cell with the right state for each. + private func vmCardCellView(tableView: NSTableView, row: Int) -> NSView? { + let id = VMCardCellView.identifier + var cell = tableView.makeView(withIdentifier: id, owner: self) as? VMCardCellView + if cell == nil { + cell = VMCardCellView(frame: NSRect(x: 0, y: 0, + width: tableView.bounds.width, + height: VMCardCellView.rowHeight)) + cell?.identifier = id + } + guard let cell = cell else { return nil } + + // Network-mode click → ask the controller to cycle the mode + // for the VM bound to this row. Configuring the closure once + // per dequeue is cheap and avoids retain cycles since the cell + // captures self weakly through the closure capture list. + cell.onCycleNetworkMode = { [weak self] vmID in + self?.handleCycleNetworkMode(forVMID: vmID) + } + + switch currentLibraryTab { + case .aiSandbox: + guard row < aiSandboxBundles.count else { return nil } + cell.configureForSandbox(bundle: aiSandboxBundles[row]) + case .standard: + guard let vm = standardVM(at: row) else { return nil } + let rate = liveRateBps[vm.name] + cell.configure(with: vm, + liveDownBps: rate?.down, + liveUpBps: rate?.up, + trafficSamples: trafficSamples[vm.name] ?? [], + packetCount: currentPacketCount(forVMName: vm.name)) + } + return cell + } + + /// Network-mode chip tapped on a VM card. Looks the VM up, asks + /// the manager to cycle the mode (NAT → Virtual → Router → NAT), + /// surfaces the standard "stop the VM first" alert on the failure + /// path, and reloads the row to reflect the new state. + private func handleCycleNetworkMode(forVMID vmID: UUID) { + guard let vm = vmManager.virtualMachines.first(where: { $0.id == vmID }) else { return } + do { + _ = try vmManager.cycleNetworkMode(vm) + // Refresh table + sidebar so the new mode + count badges + // reflect immediately. Connection overlay also refreshes + // because router/guest relationships changed. + tableView?.reloadData() + refreshConnectionOverlay() + updateSelectedVMDetailCard() + // Sidebar Network section's count badges (Isolated / + // Virtual / NAT) bucketize by `networkConfig.mode` and + // router relationships — cycling changes both, so the + // counts need to refresh. + refreshSidebarCounts() + } catch { + showAlert(message: error.localizedDescription) + } + } + /// `SparklineView` configured from the per-VM rolling buffer in /// `trafficSamples`. AI Sandbox tab gets an empty sparkline because we /// don't sample those bundles through the virtual switch. @@ -4056,6 +4152,12 @@ class VMLibraryWindowController: NSWindowController, } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + // Card column path: switchTableToCardMode collapsed the per-field + // columns down to a single full-width "VMCardColumn" hosting a + // multi-line VMCardCellView. Every row goes through this branch. + if tableColumn?.identifier.rawValue == "VMCardColumn" { + return vmCardCellView(tableView: tableView, row: row) + } // Traffic column gets a custom SparklineView cell instead of the // shared NSTextField cell used by every other column. We intercept // here before the text-cell construction below. diff --git a/SecVF/VMManager.swift b/SecVF/VMManager.swift index 9f24ebe..83be0e1 100644 --- a/SecVF/VMManager.swift +++ b/SecVF/VMManager.swift @@ -596,6 +596,68 @@ class VMManager { return Self.networkPeers(of: vm, in: virtualMachines) } + // MARK: - Network mode cycle + + /// Advance the VM's network mode by one step in the cycle: + /// + /// NAT → Virtual (isolated) → Router → NAT + /// + /// Persists the change to the VM's metadata.json and updates the + /// in-memory `virtualMachines` array. Throws if the VM isn't found + /// or the metadata write fails. Throws if the VM is currently + /// running — the network mode can't change live (the underlying + /// VZ network attachment is set once at VM-start time and isn't + /// hot-swappable in Apple's Virtualization framework). + /// + /// Pure step logic lives in `nextNetworkConfig(after:)` so it can + /// be tested without touching disk. + @discardableResult + func cycleNetworkMode(_ vmConfig: VMConfiguration) throws -> VMConfiguration { + guard vmConfig.status == .stopped else { + throw VMError.networkModeChangeWhileRunning + } + guard let index = virtualMachines.firstIndex(where: { $0.id == vmConfig.id }) else { + throw VMError.vmNotFound + } + var updated = virtualMachines[index] + updated.networkConfig = Self.nextNetworkConfig(after: updated.networkConfig) + virtualMachines[index] = updated + try saveVMMetadata(updated) + return updated + } + + /// Pure step function for the network-mode cycle. NAT loops back + /// to NAT after Router, and the `routerVMId` / `isRouter` flags + /// reset along the way (a VM that was a guest of someone becomes + /// truly isolated when we cycle past NAT, not a stranded guest + /// pointing at a router relationship from a previous mode). + static func nextNetworkConfig(after current: VirtualNetworkConfig) -> VirtualNetworkConfig { + switch (current.mode, current.isRouter) { + case (.nat, _): + // NAT → Virtual (isolated). Clear any stale router relationships. + return VirtualNetworkConfig(mode: .virtual, + routerVMId: nil, + isRouter: false) + case (.virtual, false) where current.routerVMId == nil: + // Pure isolated → Router + return VirtualNetworkConfig(mode: .virtual, + routerVMId: nil, + isRouter: true) + case (.virtual, true): + // Router → NAT + return VirtualNetworkConfig(mode: .nat, + routerVMId: nil, + isRouter: false) + case (.virtual, false): + // Virtual guest (routerVMId set) → NAT (drops the guest + // relationship; cleaner cycle than dragging it through + // multiple steps). + return VirtualNetworkConfig(mode: .nat, + routerVMId: nil, + isRouter: false) + } + } + // Perf note: refreshConnectionOverlay() in the library window calls // networkPeers(of:) inside a loop over running routers, making the // overlay refresh O(routers × n). Fine at current scale (≤ a dozen @@ -753,6 +815,8 @@ enum VMError: LocalizedError { case bundleExists case diskCreationFailed case importSourceNotFound + case vmNotFound + case networkModeChangeWhileRunning var errorDescription: String? { switch self { @@ -766,6 +830,10 @@ enum VMError: LocalizedError { return "Failed to create disk image" case .importSourceNotFound: return "Import source not found" + case .vmNotFound: + return "VM not found in the library" + case .networkModeChangeWhileRunning: + return "Stop the VM before changing its network mode — Apple's Virtualization framework attaches the network device once at boot and can't hot-swap it." } } }