192 lines
7.8 KiB
Swift
192 lines
7.8 KiB
Swift
import Foundation
|
|
|
|
public struct RemoteSessionHostResult: Equatable, Sendable, Identifiable {
|
|
public let host: String
|
|
public let sessions: [AgentSession]
|
|
public let error: String?
|
|
|
|
public var id: String {
|
|
self.host
|
|
}
|
|
|
|
public var isReachable: Bool {
|
|
self.error == nil
|
|
}
|
|
|
|
public init(host: String, sessions: [AgentSession], error: String?) {
|
|
self.host = host
|
|
self.sessions = sessions
|
|
self.error = error
|
|
}
|
|
}
|
|
|
|
public enum TailscaleStatusParser {
|
|
public static func hosts(from data: Data, excludingLocalHost localHost: String? = nil) -> [String] {
|
|
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
|
|
let peers: [[String: Any]] = if let dictionary = root["Peer"] as? [String: [String: Any]] {
|
|
Array(dictionary.values)
|
|
} else if let array = root["Peer"] as? [[String: Any]] {
|
|
array
|
|
} else {
|
|
[]
|
|
}
|
|
let selfStatus = root["Self"] as? [String: Any]
|
|
let localLabels = Set([
|
|
localHost,
|
|
selfStatus?["DNSName"] as? String,
|
|
selfStatus?["HostName"] as? String,
|
|
].compactMap(self.firstDNSLabel).map { $0.lowercased() })
|
|
|
|
var seen = Set<String>()
|
|
return peers.compactMap { peer in
|
|
guard peer["Online"] as? Bool == true,
|
|
let operatingSystem = peer["OS"] as? String,
|
|
operatingSystem == "macOS" || operatingSystem == "linux",
|
|
let label = self.firstDNSLabel(peer["DNSName"] as? String)
|
|
else { return nil }
|
|
let normalized = label.lowercased()
|
|
guard !localLabels.contains(normalized), seen.insert(normalized).inserted else { return nil }
|
|
return label
|
|
}.sorted()
|
|
}
|
|
|
|
private static func firstDNSLabel(_ value: String?) -> String? {
|
|
guard let value else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
|
guard let label = trimmed.split(separator: ".").first, !label.isEmpty else { return nil }
|
|
return String(label)
|
|
}
|
|
}
|
|
|
|
public struct RemoteSessionFetcher: Sendable {
|
|
public static let bundledCLIFallback = "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI"
|
|
|
|
public init() {}
|
|
|
|
public func discoveredHosts(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
localHost: String = ProcessInfo.processInfo.hostName) async -> [String]
|
|
{
|
|
guard let tailscale = self.tailscaleBinary(environment: environment),
|
|
let result = try? await SubprocessRunner.run(
|
|
binary: tailscale,
|
|
arguments: ["status", "--json"],
|
|
environment: environment,
|
|
timeout: 5,
|
|
label: "Tailscale session host discovery"),
|
|
let data = result.stdout.data(using: .utf8)
|
|
else { return [] }
|
|
return TailscaleStatusParser.hosts(from: data, excludingLocalHost: localHost)
|
|
}
|
|
|
|
public func fetch(
|
|
hosts: [String],
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) async -> [RemoteSessionHostResult]
|
|
{
|
|
let normalizedHosts = Self.sanitizedHosts(hosts)
|
|
return await withTaskGroup(
|
|
of: RemoteSessionHostResult.self,
|
|
returning: [RemoteSessionHostResult].self)
|
|
{ group in
|
|
for host in normalizedHosts {
|
|
group.addTask {
|
|
await self.fetch(host: host, environment: environment)
|
|
}
|
|
}
|
|
var results: [RemoteSessionHostResult] = []
|
|
for await result in group {
|
|
results.append(result)
|
|
}
|
|
return results
|
|
.sorted { lhs, rhs in lhs.host.localizedCaseInsensitiveCompare(rhs.host) == .orderedAscending }
|
|
}
|
|
}
|
|
|
|
public func focus(
|
|
sessionID: String,
|
|
host: String,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) async
|
|
{
|
|
guard let host = Self.sanitizedHosts([host]).first else { return }
|
|
guard let ssh = self.findExecutable("ssh", environment: environment) ??
|
|
(["/usr/bin/ssh", "/bin/ssh"].first { FileManager.default.isExecutableFile(atPath: $0) })
|
|
else { return }
|
|
let command = "codexbar sessions focus \(Self.shellQuote(sessionID)) || " +
|
|
"\(Self.shellQuote(Self.bundledCLIFallback)) sessions focus \(Self.shellQuote(sessionID))"
|
|
_ = try? await SubprocessRunner.run(
|
|
binary: ssh,
|
|
arguments: ["-o", "BatchMode=yes", "-o", "ConnectTimeout=3", host, "sh", "-lc", Self.shellQuote(command)],
|
|
environment: environment,
|
|
timeout: 5,
|
|
acceptsNonZeroExit: true,
|
|
label: "focus remote agent session")
|
|
}
|
|
|
|
private func fetch(host: String, environment: [String: String]) async -> RemoteSessionHostResult {
|
|
guard let ssh = self.findExecutable("ssh", environment: environment) ??
|
|
(["/usr/bin/ssh", "/bin/ssh"].first { FileManager.default.isExecutableFile(atPath: $0) })
|
|
else {
|
|
return RemoteSessionHostResult(host: host, sessions: [], error: "ssh not found")
|
|
}
|
|
let command = "codexbar sessions --json || " +
|
|
"\(Self.shellQuote(Self.bundledCLIFallback)) sessions --json"
|
|
do {
|
|
let result = try await SubprocessRunner.run(
|
|
binary: ssh,
|
|
arguments: [
|
|
"-o", "BatchMode=yes",
|
|
"-o", "ConnectTimeout=3",
|
|
host,
|
|
"sh", "-lc", Self.shellQuote(command),
|
|
],
|
|
environment: environment,
|
|
timeout: 5,
|
|
label: "fetch remote agent sessions")
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
var sessions = try decoder.decode([AgentSession].self, from: Data(result.stdout.utf8))
|
|
for index in sessions.indices {
|
|
sessions[index].host = host
|
|
}
|
|
return RemoteSessionHostResult(host: host, sessions: sessions, error: nil)
|
|
} catch {
|
|
return RemoteSessionHostResult(host: host, sessions: [], error: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func tailscaleBinary(environment: [String: String]) -> String? {
|
|
self.findExecutable("tailscale", environment: environment) ?? {
|
|
let bundled = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
|
|
return FileManager.default.isExecutableFile(atPath: bundled) ? bundled : nil
|
|
}()
|
|
}
|
|
|
|
private func findExecutable(_ name: String, environment: [String: String]) -> String? {
|
|
let path = environment["PATH"] ?? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
return path.split(separator: ":")
|
|
.map { String($0) + "/" + name }
|
|
.first { FileManager.default.isExecutableFile(atPath: $0) }
|
|
}
|
|
|
|
public static func sanitizedHosts(_ hosts: [String]) -> [String] {
|
|
var seen = Set<String>()
|
|
return hosts.compactMap { rawHost in
|
|
let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let hasUnsafeScalar = host.unicodeScalars.contains { scalar in
|
|
CharacterSet.controlCharacters.contains(scalar) ||
|
|
CharacterSet.whitespacesAndNewlines.contains(scalar)
|
|
}
|
|
guard !host.isEmpty,
|
|
!host.hasPrefix("-"),
|
|
!hasUnsafeScalar,
|
|
seen.insert(host.lowercased()).inserted
|
|
else { return nil }
|
|
return host
|
|
}
|
|
}
|
|
|
|
private static func shellQuote(_ value: String) -> String {
|
|
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
}
|
|
}
|