mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
fix: simplify Tailscale integration using local API (#184)
This commit is contained in:
parent
cf51218d7e
commit
852078d024
7 changed files with 210 additions and 453 deletions
|
|
@ -14,15 +14,18 @@ import os
|
||||||
final class TailscaleService {
|
final class TailscaleService {
|
||||||
static let shared = TailscaleService()
|
static let shared = TailscaleService()
|
||||||
|
|
||||||
|
/// Tailscale local API endpoint
|
||||||
|
private static let tailscaleAPIEndpoint = "http://100.100.100.100/api/data"
|
||||||
|
|
||||||
|
/// API request timeout in seconds
|
||||||
|
private static let apiTimeoutInterval: TimeInterval = 5.0
|
||||||
|
|
||||||
/// Logger instance for debugging
|
/// Logger instance for debugging
|
||||||
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "TailscaleService")
|
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "TailscaleService")
|
||||||
|
|
||||||
/// Indicates if Tailscale app is installed on the system
|
/// Indicates if Tailscale app is installed on the system
|
||||||
private(set) var isInstalled = false
|
private(set) var isInstalled = false
|
||||||
|
|
||||||
/// Indicates if Tailscale CLI is available
|
|
||||||
private(set) var isCLIAvailable = false
|
|
||||||
|
|
||||||
/// Indicates if Tailscale is currently running
|
/// Indicates if Tailscale is currently running
|
||||||
private(set) var isRunning = false
|
private(set) var isRunning = false
|
||||||
|
|
||||||
|
|
@ -35,9 +38,6 @@ final class TailscaleService {
|
||||||
/// Error message if status check fails
|
/// Error message if status check fails
|
||||||
private(set) var statusError: String?
|
private(set) var statusError: String?
|
||||||
|
|
||||||
/// Path to the tailscale executable
|
|
||||||
private var tailscalePath: String?
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
Task {
|
Task {
|
||||||
await checkTailscaleStatus()
|
await checkTailscaleStatus()
|
||||||
|
|
@ -51,66 +51,49 @@ final class TailscaleService {
|
||||||
return isAppInstalled
|
return isAppInstalled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if Tailscale CLI is available
|
/// Struct to decode Tailscale API response
|
||||||
func checkCLIAvailability() async -> Bool {
|
private struct TailscaleAPIResponse: Codable {
|
||||||
let checkPaths = [
|
let status: String
|
||||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
let deviceName: String
|
||||||
"/usr/local/bin/tailscale",
|
let tailnetName: String
|
||||||
"/opt/homebrew/bin/tailscale"
|
let iPv4: String?
|
||||||
]
|
|
||||||
|
|
||||||
for path in checkPaths {
|
private enum CodingKeys: String, CodingKey {
|
||||||
if FileManager.default.fileExists(atPath: path) {
|
case status = "Status"
|
||||||
logger.info("Tailscale CLI found at: \(path)")
|
case deviceName = "DeviceName"
|
||||||
tailscalePath = path
|
case tailnetName = "TailnetName"
|
||||||
return true
|
case iPv4 = "IPv4"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches Tailscale status from the API
|
||||||
|
private func fetchTailscaleStatus() async -> TailscaleAPIResponse? {
|
||||||
|
guard let url = URL(string: Self.tailscaleAPIEndpoint) else {
|
||||||
|
logger.error("Invalid Tailscale API URL")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check if we can run the tailscale command using which
|
|
||||||
do {
|
do {
|
||||||
let process = Process()
|
// Configure URLSession with timeout
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
|
let configuration = URLSessionConfiguration.default
|
||||||
process.arguments = ["tailscale"]
|
configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval
|
||||||
|
let session = URLSession(configuration: configuration)
|
||||||
|
|
||||||
// Set up PATH to include common installation directories
|
let (data, response) = try await session.data(from: url)
|
||||||
var environment = ProcessInfo.processInfo.environment
|
|
||||||
let additionalPaths = [
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
"/usr/local/bin",
|
httpResponse.statusCode == 200
|
||||||
"/opt/homebrew/bin",
|
else {
|
||||||
"/Applications/Tailscale.app/Contents/MacOS"
|
logger.warning("Tailscale API returned non-200 status")
|
||||||
]
|
return nil
|
||||||
if let currentPath = environment["PATH"] {
|
|
||||||
environment["PATH"] = "\(currentPath):\(additionalPaths.joined(separator: ":"))"
|
|
||||||
} else {
|
|
||||||
environment["PATH"] = additionalPaths.joined(separator: ":")
|
|
||||||
}
|
}
|
||||||
process.environment = environment
|
|
||||||
|
|
||||||
let pipe = Pipe()
|
let decoder = JSONDecoder()
|
||||||
process.standardOutput = pipe
|
return try decoder.decode(TailscaleAPIResponse.self, from: data)
|
||||||
process.standardError = pipe
|
|
||||||
|
|
||||||
try process.run()
|
|
||||||
process.waitUntilExit()
|
|
||||||
|
|
||||||
if process.terminationStatus == 0 {
|
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!output.isEmpty
|
|
||||||
{
|
|
||||||
logger.info("Tailscale CLI found at: \(output)")
|
|
||||||
tailscalePath = output
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
logger.debug("Failed to check for tailscale command: \(error)")
|
logger.debug("Failed to fetch Tailscale status: \(error)")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Tailscale CLI not found")
|
|
||||||
tailscalePath = nil
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks the current Tailscale status and updates properties
|
/// Checks the current Tailscale status and updates properties
|
||||||
|
|
@ -119,7 +102,6 @@ final class TailscaleService {
|
||||||
isInstalled = checkAppInstallation()
|
isInstalled = checkAppInstallation()
|
||||||
|
|
||||||
guard isInstalled else {
|
guard isInstalled else {
|
||||||
isCLIAvailable = false
|
|
||||||
isRunning = false
|
isRunning = false
|
||||||
tailscaleHostname = nil
|
tailscaleHostname = nil
|
||||||
tailscaleIP = nil
|
tailscaleIP = nil
|
||||||
|
|
@ -127,168 +109,40 @@ final class TailscaleService {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then check if CLI is available
|
// Try to fetch status from API
|
||||||
isCLIAvailable = await checkCLIAvailability()
|
if let apiResponse = await fetchTailscaleStatus() {
|
||||||
|
// Tailscale is running if API responds
|
||||||
|
isRunning = apiResponse.status.lowercased() == "running"
|
||||||
|
|
||||||
guard isCLIAvailable else {
|
if isRunning {
|
||||||
isRunning = false
|
// Extract hostname from device name and tailnet name
|
||||||
tailscaleHostname = nil
|
// Format: devicename.tailnetname (without .ts.net suffix)
|
||||||
tailscaleIP = nil
|
let deviceName = apiResponse.deviceName.lowercased().replacingOccurrences(of: " ", with: "-")
|
||||||
statusError = nil // No error, just CLI not available
|
let tailnetName = apiResponse.tailnetName
|
||||||
return
|
.replacingOccurrences(of: ".ts.net", with: "")
|
||||||
}
|
.replacingOccurrences(of: ".tailscale.net", with: "")
|
||||||
|
|
||||||
// Check if Tailscale daemon is running by looking for the process
|
|
||||||
let isAppRunning = NSWorkspace.shared.runningApplications.contains { app in
|
|
||||||
app.bundleIdentifier == "io.tailscale.ipn.macsys" ||
|
|
||||||
app.bundleIdentifier == "io.tailscale.ipn.macos"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isAppRunning {
|
|
||||||
isRunning = false
|
|
||||||
tailscaleHostname = nil
|
|
||||||
tailscaleIP = nil
|
|
||||||
statusError = "Tailscale app is not running"
|
|
||||||
logger.info("Tailscale app is not running - skipping status check")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If CLI is available, check status
|
tailscaleHostname = "\(deviceName).\(tailnetName).ts.net"
|
||||||
do {
|
tailscaleIP = apiResponse.iPv4
|
||||||
let process = Process()
|
statusError = nil
|
||||||
|
|
||||||
// Use the discovered tailscale path
|
logger
|
||||||
if let tailscalePath {
|
.info(
|
||||||
process.executableURL = URL(fileURLWithPath: tailscalePath)
|
"Tailscale status: running=true, hostname=\(self.tailscaleHostname ?? "nil"), IP=\(self.tailscaleIP ?? "nil")"
|
||||||
process.arguments = ["status", "--json"]
|
)
|
||||||
} else {
|
} else {
|
||||||
// Fallback to env if path not found (shouldn't happen if isCLIAvailable is true)
|
// Tailscale installed but not running properly
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
|
||||||
process.arguments = ["tailscale", "status", "--json"]
|
|
||||||
|
|
||||||
// Set up PATH environment variable
|
|
||||||
var environment = ProcessInfo.processInfo.environment
|
|
||||||
let additionalPaths = [
|
|
||||||
"/usr/local/bin",
|
|
||||||
"/opt/homebrew/bin",
|
|
||||||
"/Applications/Tailscale.app/Contents/MacOS"
|
|
||||||
]
|
|
||||||
if let currentPath = environment["PATH"] {
|
|
||||||
environment["PATH"] = "\(currentPath):\(additionalPaths.joined(separator: ":"))"
|
|
||||||
} else {
|
|
||||||
environment["PATH"] = additionalPaths.joined(separator: ":")
|
|
||||||
}
|
|
||||||
process.environment = environment
|
|
||||||
}
|
|
||||||
|
|
||||||
let outputPipe = Pipe()
|
|
||||||
let errorPipe = Pipe()
|
|
||||||
process.standardOutput = outputPipe
|
|
||||||
process.standardError = errorPipe
|
|
||||||
|
|
||||||
try process.run()
|
|
||||||
process.waitUntilExit()
|
|
||||||
|
|
||||||
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
|
|
||||||
if process.terminationStatus == 0 {
|
|
||||||
// Check if we have data
|
|
||||||
guard !outputData.isEmpty else {
|
|
||||||
isRunning = false
|
|
||||||
tailscaleHostname = nil
|
|
||||||
tailscaleIP = nil
|
|
||||||
statusError = "Tailscale returned empty response"
|
|
||||||
logger.warning("Tailscale status command returned empty data")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log raw output for debugging
|
|
||||||
let rawOutput = String(data: outputData, encoding: .utf8) ?? "<non-UTF8 data>"
|
|
||||||
logger.debug("Tailscale raw output: \(rawOutput)")
|
|
||||||
|
|
||||||
// Parse JSON output
|
|
||||||
do {
|
|
||||||
let jsonObject = try JSONSerialization.jsonObject(with: outputData)
|
|
||||||
|
|
||||||
// Ensure it's a dictionary
|
|
||||||
guard let json = jsonObject as? [String: Any] else {
|
|
||||||
isRunning = false
|
|
||||||
tailscaleHostname = nil
|
|
||||||
tailscaleIP = nil
|
|
||||||
statusError = "Tailscale returned invalid JSON format (not a dictionary)"
|
|
||||||
logger.warning("Tailscale status returned non-dictionary JSON: \(type(of: jsonObject))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're logged in and connected
|
|
||||||
if let self_ = json["Self"] as? [String: Any],
|
|
||||||
let dnsName = self_["DNSName"] as? String
|
|
||||||
{
|
|
||||||
// Check online status - it might be missing or false
|
|
||||||
let online = self_["Online"] as? Bool ?? false
|
|
||||||
isRunning = online
|
|
||||||
|
|
||||||
// Use the DNSName which is already properly formatted for DNS
|
|
||||||
// Remove trailing dot if present
|
|
||||||
tailscaleHostname = dnsName.hasSuffix(".") ? String(dnsName.dropLast()) : dnsName
|
|
||||||
|
|
||||||
// Get Tailscale IP
|
|
||||||
if let tailscaleIPs = self_["TailscaleIPs"] as? [String],
|
|
||||||
let firstIP = tailscaleIPs.first
|
|
||||||
{
|
|
||||||
tailscaleIP = firstIP
|
|
||||||
}
|
|
||||||
|
|
||||||
statusError = nil
|
|
||||||
logger
|
|
||||||
.info(
|
|
||||||
"Tailscale status: running=\(online), hostname=\(self.tailscaleHostname ?? "nil"), IP=\(self.tailscaleIP ?? "nil")"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
isRunning = false
|
|
||||||
tailscaleHostname = nil
|
|
||||||
tailscaleIP = nil
|
|
||||||
statusError = "Tailscale is not logged in"
|
|
||||||
logger.warning("Tailscale status check failed - missing required fields in JSON")
|
|
||||||
logger.debug("JSON keys: \(json.keys.sorted())")
|
|
||||||
}
|
|
||||||
} catch let parseError {
|
|
||||||
isRunning = false
|
|
||||||
tailscaleHostname = nil
|
|
||||||
tailscaleIP = nil
|
|
||||||
|
|
||||||
// Check if this is the GUI startup error
|
|
||||||
if rawOutput.contains("The Tailscale GUI failed to start") {
|
|
||||||
statusError = "Tailscale app is not running"
|
|
||||||
} else {
|
|
||||||
statusError = "Failed to parse Tailscale status: \(parseError.localizedDescription)"
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error("JSON parsing error: \(parseError)")
|
|
||||||
logger.debug("Failed to parse data: \(rawOutput.prefix(200))...")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Tailscale CLI returned error
|
|
||||||
let errorOutput = String(data: errorData, encoding: .utf8) ?? String(data: outputData, encoding: .utf8) ?? "Unknown error"
|
|
||||||
isRunning = false
|
|
||||||
tailscaleHostname = nil
|
tailscaleHostname = nil
|
||||||
tailscaleIP = nil
|
tailscaleIP = nil
|
||||||
|
statusError = "Tailscale is not running"
|
||||||
if errorOutput.contains("not logged in") {
|
|
||||||
statusError = "Tailscale is not logged in"
|
|
||||||
} else if errorOutput.contains("stopped") {
|
|
||||||
statusError = "Tailscale is stopped"
|
|
||||||
} else {
|
|
||||||
statusError = "Tailscale error: \(errorOutput.trimmingCharacters(in: .whitespacesAndNewlines))"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} else {
|
||||||
logger.error("Failed to check Tailscale status: \(error)")
|
// API not responding - Tailscale not running
|
||||||
isRunning = false
|
isRunning = false
|
||||||
tailscaleHostname = nil
|
tailscaleHostname = nil
|
||||||
tailscaleIP = nil
|
tailscaleIP = nil
|
||||||
statusError = "Failed to check status: \(error.localizedDescription)"
|
statusError = "Please start the Tailscale app"
|
||||||
|
logger.info("Tailscale API not responding - app likely not running")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,12 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSExceptionDomains</key>
|
<key>NSExceptionDomains</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>vibetunnel.sh</key>
|
<key>0.0.0.0</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
<false/>
|
|
||||||
<key>NSIncludesSubdomains</key>
|
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>localhost</key>
|
<key>100.100.100.100</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
@ -33,11 +31,18 @@
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>0.0.0.0</key>
|
<key>localhost</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>vibetunnel.sh</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSIncludesSubdomains</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSSupportsAutomaticTermination</key>
|
<key>NSSupportsAutomaticTermination</key>
|
||||||
|
|
@ -57,4 +62,4 @@
|
||||||
<key>NSUserNotificationsUsageDescription</key>
|
<key>NSUserNotificationsUsageDescription</key>
|
||||||
<string>VibeTunnel will notify you about important events such as new terminal connections, session status changes, and available updates.</string>
|
<string>VibeTunnel will notify you about important events such as new terminal connections, session status changes, and available updates.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ final class CustomMenuWindow: NSPanel {
|
||||||
// Activate app and show window
|
// Activate app and show window
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
makeKeyAndOrderFront(nil)
|
makeKeyAndOrderFront(nil)
|
||||||
|
|
||||||
// Force button state update again after window is shown
|
// Force button state update again after window is shown
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.statusBarButton?.state = .on
|
self?.statusBarButton?.state = .on
|
||||||
|
|
@ -336,7 +336,7 @@ struct CustomMenuContainer<Content: View>: View {
|
||||||
Color.white.opacity(0.5)
|
Color.white.opacity(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var backgroundMaterial: some ShapeStyle {
|
private var backgroundMaterial: some ShapeStyle {
|
||||||
switch colorScheme {
|
switch colorScheme {
|
||||||
case .dark:
|
case .dark:
|
||||||
|
|
|
||||||
|
|
@ -99,10 +99,10 @@ final class StatusBarMenuManager: NSObject {
|
||||||
|
|
||||||
// Update menu state to custom window FIRST before any async operations
|
// Update menu state to custom window FIRST before any async operations
|
||||||
updateMenuState(.customWindow, button: button)
|
updateMenuState(.customWindow, button: button)
|
||||||
|
|
||||||
// Ensure button state is set immediately and persistently
|
// Ensure button state is set immediately and persistently
|
||||||
button.state = .on
|
button.state = .on
|
||||||
|
|
||||||
// Force another button state update to ensure it sticks
|
// Force another button state update to ensure it sticks
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
button.state = .on
|
button.state = .on
|
||||||
|
|
|
||||||
|
|
@ -279,42 +279,21 @@ struct ServerInfoHeader: View {
|
||||||
ServerAddressRow()
|
ServerAddressRow()
|
||||||
|
|
||||||
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
|
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
|
||||||
HStack(spacing: 4) {
|
ServerAddressRow(
|
||||||
Image(systemName: "network")
|
icon: "network",
|
||||||
.font(.system(size: 10))
|
label: "ngrok:",
|
||||||
.foregroundColor(.purple)
|
address: publicURL,
|
||||||
Text("ngrok:")
|
url: URL(string: publicURL)
|
||||||
.font(.system(size: 11))
|
)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text(publicURL)
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(.purple)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.middle)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
|
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
|
||||||
HStack(spacing: 4) {
|
ServerAddressRow(
|
||||||
Image(systemName: "shield")
|
icon: "shield",
|
||||||
.font(.system(size: 10))
|
label: "Tailscale:",
|
||||||
.foregroundColor(.blue)
|
address: hostname,
|
||||||
Text("Tailscale:")
|
url: URL(string: "http://\(hostname):\(serverManager.port)")
|
||||||
.font(.system(size: 11))
|
)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Button(action: {
|
|
||||||
if let url = URL(string: "http://\(hostname)") {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Text(hostname)
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
.underline()
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.pointingHandCursor()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -323,25 +302,42 @@ struct ServerInfoHeader: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ServerAddressRow: View {
|
struct ServerAddressRow: View {
|
||||||
|
let icon: String
|
||||||
|
let label: String
|
||||||
|
let address: String
|
||||||
|
let url: URL?
|
||||||
|
|
||||||
@Environment(ServerManager.self)
|
@Environment(ServerManager.self)
|
||||||
var serverManager
|
var serverManager
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String = "server.rack",
|
||||||
|
label: String = "Local:",
|
||||||
|
address: String? = nil,
|
||||||
|
url: URL? = nil
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.label = label
|
||||||
|
self.address = address ?? ""
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "server.rack")
|
Image(systemName: icon)
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundColor(Color(red: 0.0, green: 0.7, blue: 0.0))
|
.foregroundColor(.green)
|
||||||
Text("Local:")
|
Text(label)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if let url = URL(string: "http://\(serverAddress)") {
|
if let url = url ?? URL(string: "http://\(computedAddress)") {
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Text(serverAddress)
|
Text(computedAddress)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.green)
|
||||||
.underline()
|
.underline()
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
@ -349,7 +345,12 @@ struct ServerAddressRow: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var serverAddress: String {
|
private var computedAddress: String {
|
||||||
|
if !address.isEmpty {
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default behavior for local server
|
||||||
let bindAddress = serverManager.bindAddress
|
let bindAddress = serverManager.bindAddress
|
||||||
if bindAddress == "127.0.0.1" {
|
if bindAddress == "127.0.0.1" {
|
||||||
return "127.0.0.1:\(serverManager.port)"
|
return "127.0.0.1:\(serverManager.port)"
|
||||||
|
|
@ -380,7 +381,10 @@ struct ServerStatusBadge: View {
|
||||||
.fill(isRunning ? Color(red: 0.0, green: 0.7, blue: 0.0).opacity(0.1) : Color.red.opacity(0.1))
|
.fill(isRunning ? Color(red: 0.0, green: 0.7, blue: 0.0).opacity(0.1) : Color.red.opacity(0.1))
|
||||||
.overlay(
|
.overlay(
|
||||||
Capsule()
|
Capsule()
|
||||||
.stroke(isRunning ? Color(red: 0.0, green: 0.7, blue: 0.0).opacity(0.3) : Color.red.opacity(0.3), lineWidth: 0.5)
|
.stroke(
|
||||||
|
isRunning ? Color(red: 0.0, green: 0.7, blue: 0.0).opacity(0.3) : Color.red.opacity(0.3),
|
||||||
|
lineWidth: 0.5
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -700,9 +704,9 @@ struct SessionRow: View {
|
||||||
|
|
||||||
private var activityColor: Color {
|
private var activityColor: Color {
|
||||||
if isActive {
|
if isActive {
|
||||||
Color(red: 1.0, green: 0.5, blue: 0.0) // Brighter, more saturated orange
|
Color(red: 1.0, green: 0.5, blue: 0.0) // Brighter, more saturated orange
|
||||||
} else {
|
} else {
|
||||||
Color(red: 0.0, green: 0.7, blue: 0.0) // Darker, more visible green
|
Color(red: 0.0, green: 0.7, blue: 0.0) // Darker, more visible green
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -714,7 +718,7 @@ struct SessionRow: View {
|
||||||
private var hoverBackgroundColor: Color {
|
private var hoverBackgroundColor: Color {
|
||||||
colorScheme == .dark ? Color.accentColor.opacity(0.08) : Color.accentColor.opacity(0.15)
|
colorScheme == .dark ? Color.accentColor.opacity(0.08) : Color.accentColor.opacity(0.15)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var duration: String {
|
private var duration: String {
|
||||||
// Parse ISO8601 date string with fractional seconds
|
// Parse ISO8601 date string with fractional seconds
|
||||||
let formatter = ISO8601DateFormatter()
|
let formatter = ISO8601DateFormatter()
|
||||||
|
|
|
||||||
|
|
@ -807,199 +807,92 @@ private struct TailscaleIntegrationSection: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
if tailscaleService.isInstalled {
|
HStack {
|
||||||
// Tailscale app is installed
|
if tailscaleService.isInstalled {
|
||||||
HStack {
|
if tailscaleService.isRunning {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
// Green dot: Tailscale is installed and running
|
||||||
.foregroundColor(.green)
|
Image(systemName: "circle.fill")
|
||||||
Text("Tailscale is installed")
|
.foregroundColor(.green)
|
||||||
.font(.callout)
|
.font(.system(size: 10))
|
||||||
|
Text("Tailscale is installed and running")
|
||||||
Spacer()
|
.font(.callout)
|
||||||
}
|
|
||||||
|
|
||||||
if tailscaleService.isCLIAvailable {
|
|
||||||
// CLI is available, show status
|
|
||||||
if tailscaleService.isRunning || tailscaleService.tailscaleHostname != nil {
|
|
||||||
// Show Tailscale hostname and connection info (even if offline, as user might still
|
|
||||||
// connect)
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
if let hostname = tailscaleService.tailscaleHostname {
|
|
||||||
HStack {
|
|
||||||
Text("Tailscale hostname:")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text(hostname)
|
|
||||||
.font(.caption)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let tailscaleIP = tailscaleService.tailscaleIP {
|
|
||||||
HStack {
|
|
||||||
Text("Tailscale IP:")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text(tailscaleIP)
|
|
||||||
.font(.caption)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Access URL
|
|
||||||
if let hostname = tailscaleService.tailscaleHostname {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
if accessMode == .localhost {
|
|
||||||
// Show warning if in localhost-only mode
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
.font(.system(size: 12))
|
|
||||||
Text(
|
|
||||||
"Server is in localhost-only mode. Change to 'Network' mode above to access via Tailscale."
|
|
||||||
)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
} else {
|
|
||||||
// Show the access URL
|
|
||||||
HStack(spacing: 5) {
|
|
||||||
Text("Access VibeTunnel at:")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
let urlString = "http://\(hostname):\(serverPort)"
|
|
||||||
if let url = URL(string: urlString) {
|
|
||||||
Link(urlString, destination: url)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !tailscaleService.isRunning {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "exclamationmark.triangle")
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
.font(.system(size: 10))
|
|
||||||
Text("Tailscale reports as offline but may still be accessible")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// CLI available but Tailscale not running/logged in
|
// Orange dot: Tailscale is installed but not running
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
Image(systemName: "circle.fill")
|
||||||
if let error = tailscaleService.statusError {
|
.foregroundColor(.orange)
|
||||||
HStack {
|
.font(.system(size: 10))
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Text("Tailscale is installed but not running")
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text(error)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Button("Open Tailscale") {
|
|
||||||
tailscaleService.openTailscaleApp()
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.small)
|
|
||||||
|
|
||||||
if let url = URL(string: "https://tailscale.com/kb/1017/install/") {
|
|
||||||
Link("Setup Guide", destination: url)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// App installed but CLI not available
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "info.circle")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
Text("Tailscale CLI not available")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"To see your Tailscale status here, install the Tailscale CLI. You can still use Tailscale - just open the app and connect to your tailnet."
|
|
||||||
)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button("Open Tailscale") {
|
|
||||||
tailscaleService.openTailscaleApp()
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.small)
|
|
||||||
|
|
||||||
if let url = URL(string: "https://tailscale.com/kb/1090/install-tailscale-cli/") {
|
|
||||||
Link("Install CLI", destination: url)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Tailscale is not installed
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "info.circle")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
Text("Tailscale is not installed")
|
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Yellow dot: Tailscale is not installed
|
||||||
|
Image(systemName: "circle.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
Text("Tailscale is not installed")
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Spacer()
|
||||||
"Tailscale creates a secure peer-to-peer VPN for accessing VibeTunnel from any device - your phone, tablet, or another computer."
|
}
|
||||||
)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
// Show additional content based on state
|
||||||
Button(action: {
|
if !tailscaleService.isInstalled {
|
||||||
tailscaleService.openAppStore()
|
// Show download links when not installed
|
||||||
}) {
|
HStack(spacing: 12) {
|
||||||
Text("App Store")
|
Button(action: {
|
||||||
}
|
tailscaleService.openAppStore()
|
||||||
.buttonStyle(.link)
|
}) {
|
||||||
.controlSize(.small)
|
Text("App Store")
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
tailscaleService.openDownloadPage()
|
|
||||||
}) {
|
|
||||||
Text("Direct Download")
|
|
||||||
}
|
|
||||||
.buttonStyle(.link)
|
|
||||||
.controlSize(.small)
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
tailscaleService.openSetupGuide()
|
|
||||||
}) {
|
|
||||||
Text("Setup Guide")
|
|
||||||
}
|
|
||||||
.buttonStyle(.link)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Check Again") {
|
|
||||||
Task {
|
|
||||||
await tailscaleService.checkTailscaleStatus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.link)
|
.buttonStyle(.link)
|
||||||
.font(.caption)
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
tailscaleService.openDownloadPage()
|
||||||
|
}) {
|
||||||
|
Text("Direct Download")
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
tailscaleService.openSetupGuide()
|
||||||
|
}) {
|
||||||
|
Text("Setup Guide")
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
} else if tailscaleService.isRunning {
|
||||||
|
// Show dashboard URL when running
|
||||||
|
if let hostname = tailscaleService.tailscaleHostname {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Text("Access VibeTunnel at:")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
let urlString = "http://\(hostname):\(serverPort)"
|
||||||
|
if let url = URL(string: urlString) {
|
||||||
|
Link(urlString, destination: url)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show warning if in localhost-only mode
|
||||||
|
if accessMode == .localhost {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text(
|
||||||
|
"Server is in localhost-only mode. Change to 'Network' mode above to access via Tailscale."
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1020,7 +913,7 @@ private struct TailscaleIntegrationSection: View {
|
||||||
await tailscaleService.checkTailscaleStatus()
|
await tailscaleService.checkTailscaleStatus()
|
||||||
logger
|
logger
|
||||||
.info(
|
.info(
|
||||||
"TailscaleIntegrationSection: Status check complete - isInstalled: \(tailscaleService.isInstalled), isCLIAvailable: \(tailscaleService.isCLIAvailable), isRunning: \(tailscaleService.isRunning), hostname: \(tailscaleService.tailscaleHostname ?? "nil")"
|
"TailscaleIntegrationSection: Status check complete - isInstalled: \(tailscaleService.isInstalled), isRunning: \(tailscaleService.isRunning), hostname: \(tailscaleService.tailscaleHostname ?? "nil")"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set up timer for automatic updates every 5 seconds
|
// Set up timer for automatic updates every 5 seconds
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,9 @@ struct TerminalLaunchTests {
|
||||||
// iTerm2 URL with working directory
|
// iTerm2 URL with working directory
|
||||||
if let url = Terminal.iTerm2.commandURL(for: command, workingDirectory: workDir) {
|
if let url = Terminal.iTerm2.commandURL(for: command, workingDirectory: workDir) {
|
||||||
#expect(url.absoluteString.contains("cd="))
|
#expect(url.absoluteString.contains("cd="))
|
||||||
#expect(url.absoluteString
|
#expect(
|
||||||
.contains(workDir.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")
|
url.absoluteString
|
||||||
|
.contains(workDir.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue