diff --git a/.dockerignore b/.dockerignore index f626ebaa..797912e7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -38,4 +38,5 @@ log.csv vendor workdir -Dockerfile \ No newline at end of file +Dockerfile +macos/ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 00000000..30bcfa4e --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/macos/Package.resolved b/macos/Package.resolved new file mode 100644 index 00000000..556ef1ac --- /dev/null +++ b/macos/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" + } + } + ], + "version" : 2 +} diff --git a/macos/Package.swift b/macos/Package.swift new file mode 100644 index 00000000..24028aa0 --- /dev/null +++ b/macos/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "Nylon", + platforms: [.macOS(.v14)], + dependencies: [.package(url: "https://github.com/jpsim/Yams.git", from: "6.2.2")], + targets: [ + .executableTarget(name: "NylonApp", dependencies: ["Yams"]), + .testTarget(name: "NylonAppTests", dependencies: ["NylonApp"]), + ] +) diff --git a/macos/README.md b/macos/README.md new file mode 100644 index 00000000..da49a34c --- /dev/null +++ b/macos/README.md @@ -0,0 +1,24 @@ +# Nylon for macOS + +A menu bar app that lists the nodes in your nylon network with their IPs. Click a node to copy its IP. + +## Run it + +1. Install the Command Line Tools if you don't have them: `xcode-select --install` +2. From the repo root, build and start it: + + ```bash + swift build -c release --package-path macos && macos/.build/release/NylonApp + ``` + +3. Click the network icon in the menu bar, then "Choose central.yaml…" and pick your file. The app remembers it for next time. + +To skip the picker, pass the file: `macos/.build/release/NylonApp -central /path/to/central.yaml`. With no file chosen, it reads `central.yaml` in the current directory, like `nylon run`. + +## Test + +```bash +swift test --package-path macos -Xswiftc -plugin-path -Xswiftc "$(xcode-select -p)/usr/lib/swift/host/plugins/testing" +``` + +The `-plugin-path` flag is only needed without full Xcode. diff --git a/macos/Sources/NylonApp/Central.swift b/macos/Sources/NylonApp/Central.swift new file mode 100644 index 00000000..74b5d45b --- /dev/null +++ b/macos/Sources/NylonApp/Central.swift @@ -0,0 +1,22 @@ +import Foundation +import Yams + +struct Node: Equatable { + let name: String + let address: String? // first mesh IP; nil for a node that only advertises prefixes +} + +// central.yaml, just the parts shown here. Every router and client is a node. +private struct Central: Decodable { + struct Entry: Decodable { + let id: String + let addresses: [String]? + } + let routers: [Entry]? + let clients: [Entry]? +} + +func readNodes(_ yaml: String) throws -> [Node] { + let c = try YAMLDecoder().decode(Central.self, from: yaml) + return ((c.routers ?? []) + (c.clients ?? [])).map { Node(name: $0.id, address: $0.addresses?.first) } +} diff --git a/macos/Sources/NylonApp/MenuContent.swift b/macos/Sources/NylonApp/MenuContent.swift new file mode 100644 index 00000000..9525bfa7 --- /dev/null +++ b/macos/Sources/NylonApp/MenuContent.swift @@ -0,0 +1,38 @@ +import AppKit +import SwiftUI + +struct MenuContent: View { + @ObservedObject var store: NodeStore + + var body: some View { + if let nodes = store.nodes { + ForEach(nodes, id: \.name) { NodeRow(node: $0) } + } else { + Text("Can't read \(store.path)") + } + Divider() + Button("Choose central.yaml…") { chooseCentral() } + Button("Quit") { NSApplication.shared.terminate(nil) } + } + + private func chooseCentral() { + NSApp.activate() // a menu bar app runs in the background, so bring the picker to the front + let panel = NSOpenPanel() + if panel.runModal() == .OK, let url = panel.url { + store.choose(url.path) + } + } +} + +// A node's name and IP. Clicking it copies the IP. +struct NodeRow: View { + let node: Node + + var body: some View { + Button("\(node.name) \(node.address ?? "no address")") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(node.address ?? "", forType: .string) + } + .disabled(node.address == nil) + } +} diff --git a/macos/Sources/NylonApp/NodeStore.swift b/macos/Sources/NylonApp/NodeStore.swift new file mode 100644 index 00000000..d87c52d3 --- /dev/null +++ b/macos/Sources/NylonApp/NodeStore.swift @@ -0,0 +1,25 @@ +import Foundation + +// What the menu shows. Views read it; where the data comes from stays in here. +// ObservableObject, since @State is a macro the Command Line Tools can't expand (no SwiftUI macro plugin). +final class NodeStore: ObservableObject { + @Published private(set) var path = "" + @Published private(set) var nodes: [Node]? // nil when central.yaml can't be read + + // A path picked earlier or passed as `-central ` (both land in UserDefaults), else ./central.yaml like `nylon run` + init() { + load(UserDefaults.standard.string(forKey: "central") ?? "central.yaml") + } + + // Picked from the menu, so remember it for the next launch. + func choose(_ path: String) { + UserDefaults.standard.set(path, forKey: "central") + load(path) + } + + private func load(_ path: String) { + // absolute and with ~ expanded, so "Can't read" says where it looked + self.path = URL(fileURLWithPath: NSString(string: path).expandingTildeInPath).path + nodes = try? readNodes(String(contentsOfFile: self.path, encoding: .utf8)) + } +} diff --git a/macos/Sources/NylonApp/NylonApp.swift b/macos/Sources/NylonApp/NylonApp.swift new file mode 100644 index 00000000..be9ac77a --- /dev/null +++ b/macos/Sources/NylonApp/NylonApp.swift @@ -0,0 +1,18 @@ +import AppKit +import SwiftUI + +@main +struct MenuApp: App { + @StateObject private var store = NodeStore() + + init() { + // menu bar only; without an app bundle it would otherwise start background-only + NSApplication.shared.setActivationPolicy(.accessory) + } + + var body: some Scene { + MenuBarExtra("Nylon", systemImage: "network") { + MenuContent(store: store) + } + } +} diff --git a/macos/Tests/NylonAppTests/CentralTests.swift b/macos/Tests/NylonAppTests/CentralTests.swift new file mode 100644 index 00000000..36d33930 --- /dev/null +++ b/macos/Tests/NylonAppTests/CentralTests.swift @@ -0,0 +1,35 @@ +import Testing +@testable import NylonApp + +// Block and flow lists, prefix objects the app ignores, and a router with no addresses. +@Test func readsNodes() throws { + let yaml = """ + routers: + - id: alice + pubkey: k + endpoints: ["alice.example.com:57175"] + addresses: + - 10.0.0.1 + - fd00::1 + prefixes: + - type: static + prefix: 192.168.0.0/24 + - id: relay + pubkey: k + clients: + - id: phone + pubkey: k + addresses: [10.0.0.20] + graph: + - alice, relay, phone + """ + #expect(try readNodes(yaml) == [ + Node(name: "alice", address: "10.0.0.1"), + Node(name: "relay", address: nil), + Node(name: "phone", address: "10.0.0.20"), + ]) +} + +@Test func rejectsMalformedYAML() { + #expect(throws: (any Error).self) { try readNodes("routers: [") } +}