From 5d80c59752820e0209160797146d5526d8a44c4c Mon Sep 17 00:00:00 2001 From: Andrew Yang Date: Tue, 29 Sep 2026 20:08:55 -0400 Subject: [PATCH 1/3] feat(macos): list nodes from central.yaml --- .dockerignore | 3 +- macos/.gitignore | 1 + macos/Package.resolved | 14 ++++++++ macos/Package.swift | 12 +++++++ macos/README.md | 18 ++++++++++ macos/Sources/NylonApp/Central.swift | 22 ++++++++++++ macos/Sources/NylonApp/MenuContent.swift | 29 ++++++++++++++++ macos/Sources/NylonApp/NodeStore.swift | 14 ++++++++ macos/Sources/NylonApp/NylonApp.swift | 21 ++++++++++++ macos/Tests/NylonAppTests/CentralTests.swift | 35 ++++++++++++++++++++ 10 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 macos/.gitignore create mode 100644 macos/Package.resolved create mode 100644 macos/Package.swift create mode 100644 macos/README.md create mode 100644 macos/Sources/NylonApp/Central.swift create mode 100644 macos/Sources/NylonApp/MenuContent.swift create mode 100644 macos/Sources/NylonApp/NodeStore.swift create mode 100644 macos/Sources/NylonApp/NylonApp.swift create mode 100644 macos/Tests/NylonAppTests/CentralTests.swift 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..a990812d --- /dev/null +++ b/macos/README.md @@ -0,0 +1,18 @@ +# Nylon for macOS + +A menu bar app that lists the nodes in your nylon network, read from `central.yaml`. Clicking one copies its IP. + +Build and run it. It needs only the Command Line Tools (`xcode-select --install`): + +```bash +swift build -c release --package-path macos +macos/.build/release/NylonApp -central /path/to/central.yaml +``` + +Without `-central` it reads `central.yaml` in the current directory, like `nylon run`. + +Tests (the `-plugin-path` flag is only needed without full Xcode): + +```bash +swift test --package-path macos -Xswiftc -plugin-path -Xswiftc "$(xcode-select -p)/usr/lib/swift/host/plugins/testing" +``` 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..60dbf46f --- /dev/null +++ b/macos/Sources/NylonApp/MenuContent.swift @@ -0,0 +1,29 @@ +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("Quit") { NSApplication.shared.terminate(nil) } + } +} + +// 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..9865be10 --- /dev/null +++ b/macos/Sources/NylonApp/NodeStore.swift @@ -0,0 +1,14 @@ +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 { + let path: String + @Published private(set) var nodes: [Node]? // nil when central.yaml can't be read + + init(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..e6b08bd0 --- /dev/null +++ b/macos/Sources/NylonApp/NylonApp.swift @@ -0,0 +1,21 @@ +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) + // `NylonApp -central ` lands in UserDefaults' argument domain, like `nylon run -c` + let path = UserDefaults.standard.string(forKey: "central") ?? "central.yaml" + _store = StateObject(wrappedValue: NodeStore(path: path)) + } + + 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: [") } +} From c9d837965cb4f06d0163b53707a568ff1c6c3661 Mon Sep 17 00:00:00 2001 From: Andrew Yang Date: Wed, 30 Sep 2026 06:27:47 -0400 Subject: [PATCH 2/3] feat(macos): pick central.yaml from the menu --- macos/README.md | 2 +- macos/Sources/NylonApp/MenuContent.swift | 9 +++++++++ macos/Sources/NylonApp/NodeStore.swift | 15 +++++++++++++-- macos/Sources/NylonApp/NylonApp.swift | 5 +---- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/macos/README.md b/macos/README.md index a990812d..9278019e 100644 --- a/macos/README.md +++ b/macos/README.md @@ -9,7 +9,7 @@ swift build -c release --package-path macos macos/.build/release/NylonApp -central /path/to/central.yaml ``` -Without `-central` it reads `central.yaml` in the current directory, like `nylon run`. +Or start it without `-central` and pick the file with "Choose central.yaml…" in its menu. It remembers the choice. With neither, it reads `central.yaml` in the current directory, like `nylon run`. Tests (the `-plugin-path` flag is only needed without full Xcode): diff --git a/macos/Sources/NylonApp/MenuContent.swift b/macos/Sources/NylonApp/MenuContent.swift index 60dbf46f..9525bfa7 100644 --- a/macos/Sources/NylonApp/MenuContent.swift +++ b/macos/Sources/NylonApp/MenuContent.swift @@ -11,8 +11,17 @@ struct MenuContent: View { 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. diff --git a/macos/Sources/NylonApp/NodeStore.swift b/macos/Sources/NylonApp/NodeStore.swift index 9865be10..d87c52d3 100644 --- a/macos/Sources/NylonApp/NodeStore.swift +++ b/macos/Sources/NylonApp/NodeStore.swift @@ -3,10 +3,21 @@ 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 { - let path: String + @Published private(set) var path = "" @Published private(set) var nodes: [Node]? // nil when central.yaml can't be read - init(path: String) { + // 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 index e6b08bd0..be9ac77a 100644 --- a/macos/Sources/NylonApp/NylonApp.swift +++ b/macos/Sources/NylonApp/NylonApp.swift @@ -3,14 +3,11 @@ import SwiftUI @main struct MenuApp: App { - @StateObject private var store: NodeStore + @StateObject private var store = NodeStore() init() { // menu bar only; without an app bundle it would otherwise start background-only NSApplication.shared.setActivationPolicy(.accessory) - // `NylonApp -central ` lands in UserDefaults' argument domain, like `nylon run -c` - let path = UserDefaults.standard.string(forKey: "central") ?? "central.yaml" - _store = StateObject(wrappedValue: NodeStore(path: path)) } var body: some Scene { From 7d83e36b0af0f96988a2f21e2a27110267f05831 Mon Sep 17 00:00:00 2001 From: Andrew Yang Date: Wed, 30 Sep 2026 10:18:40 -0400 Subject: [PATCH 3/3] doc(macos): lead the readme with the file picker --- macos/README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/macos/README.md b/macos/README.md index 9278019e..da49a34c 100644 --- a/macos/README.md +++ b/macos/README.md @@ -1,18 +1,24 @@ # Nylon for macOS -A menu bar app that lists the nodes in your nylon network, read from `central.yaml`. Clicking one copies its IP. +A menu bar app that lists the nodes in your nylon network with their IPs. Click a node to copy its IP. -Build and run it. It needs only the Command Line Tools (`xcode-select --install`): +## Run it -```bash -swift build -c release --package-path macos -macos/.build/release/NylonApp -central /path/to/central.yaml -``` +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 + ``` -Or start it without `-central` and pick the file with "Choose central.yaml…" in its menu. It remembers the choice. With neither, it reads `central.yaml` in the current directory, like `nylon run`. +3. Click the network icon in the menu bar, then "Choose central.yaml…" and pick your file. The app remembers it for next time. -Tests (the `-plugin-path` flag is only needed without full Xcode): +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.