Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ log.csv
vendor

workdir
Dockerfile
Dockerfile
macos/
1 change: 1 addition & 0 deletions macos/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.build/
14 changes: 14 additions & 0 deletions macos/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions macos/Package.swift
Original file line number Diff line number Diff line change
@@ -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"]),
]
)
24 changes: 24 additions & 0 deletions macos/README.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions macos/Sources/NylonApp/Central.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
38 changes: 38 additions & 0 deletions macos/Sources/NylonApp/MenuContent.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
25 changes: 25 additions & 0 deletions macos/Sources/NylonApp/NodeStore.swift
Original file line number Diff line number Diff line change
@@ -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 <path>` (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))
}
}
18 changes: 18 additions & 0 deletions macos/Sources/NylonApp/NylonApp.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
35 changes: 35 additions & 0 deletions macos/Tests/NylonAppTests/CentralTests.swift
Original file line number Diff line number Diff line change
@@ -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: [") }
}