mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-13 12:35:54 +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 {
|
||||
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
|
||||
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "TailscaleService")
|
||||
|
||||
/// Indicates if Tailscale app is installed on the system
|
||||
private(set) var isInstalled = false
|
||||
|
||||
/// Indicates if Tailscale CLI is available
|
||||
private(set) var isCLIAvailable = false
|
||||
|
||||
/// Indicates if Tailscale is currently running
|
||||
private(set) var isRunning = false
|
||||
|
||||
|
|
@ -35,9 +38,6 @@ final class TailscaleService {
|
|||
/// Error message if status check fails
|
||||
private(set) var statusError: String?
|
||||
|
||||
/// Path to the tailscale executable
|
||||
private var tailscalePath: String?
|
||||
|
||||
private init() {
|
||||
Task {
|
||||
await checkTailscaleStatus()
|
||||
|
|
@ -51,66 +51,49 @@ final class TailscaleService {
|
|||
return isAppInstalled
|
||||
}
|
||||
|
||||
/// Checks if Tailscale CLI is available
|
||||
func checkCLIAvailability() async -> Bool {
|
||||
let checkPaths = [
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
"/usr/local/bin/tailscale",
|
||||
"/opt/homebrew/bin/tailscale"
|
||||
]
|
||||
/// Struct to decode Tailscale API response
|
||||
private struct TailscaleAPIResponse: Codable {
|
||||
let status: String
|
||||
let deviceName: String
|
||||
let tailnetName: String
|
||||
let iPv4: String?
|
||||
|
||||
for path in checkPaths {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
logger.info("Tailscale CLI found at: \(path)")
|
||||
tailscalePath = path
|
||||
return true
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case status = "Status"
|
||||
case deviceName = "DeviceName"
|
||||
case tailnetName = "TailnetName"
|
||||
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 {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
|
||||
process.arguments = ["tailscale"]
|
||||
// Configure URLSession with timeout
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval
|
||||
let session = URLSession(configuration: configuration)
|
||||
|
||||
// Set up PATH to include common installation directories
|
||||
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: ":")
|
||||
let (data, response) = try await session.data(from: url)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200
|
||||
else {
|
||||
logger.warning("Tailscale API returned non-200 status")
|
||||
return nil
|
||||
}
|
||||
process.environment = environment
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
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
|
||||
}
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
return try decoder.decode(TailscaleAPIResponse.self, from: data)
|
||||
} 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
|
||||
|
|
@ -119,7 +102,6 @@ final class TailscaleService {
|
|||
isInstalled = checkAppInstallation()
|
||||
|
||||
guard isInstalled else {
|
||||
isCLIAvailable = false
|
||||
isRunning = false
|
||||
tailscaleHostname = nil
|
||||
tailscaleIP = nil
|
||||
|
|
@ -127,168 +109,40 @@ final class TailscaleService {
|
|||
return
|
||||
}
|
||||
|
||||
// Then check if CLI is available
|
||||
isCLIAvailable = await checkCLIAvailability()
|
||||
// Try to fetch status from API
|
||||
if let apiResponse = await fetchTailscaleStatus() {
|
||||
// Tailscale is running if API responds
|
||||
isRunning = apiResponse.status.lowercased() == "running"
|
||||
|
||||
guard isCLIAvailable else {
|
||||
isRunning = false
|
||||
tailscaleHostname = nil
|
||||
tailscaleIP = nil
|
||||
statusError = nil // No error, just CLI not available
|
||||
return
|
||||
}
|
||||
|
||||
// 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 isRunning {
|
||||
// Extract hostname from device name and tailnet name
|
||||
// Format: devicename.tailnetname (without .ts.net suffix)
|
||||
let deviceName = apiResponse.deviceName.lowercased().replacingOccurrences(of: " ", with: "-")
|
||||
let tailnetName = apiResponse.tailnetName
|
||||
.replacingOccurrences(of: ".ts.net", with: "")
|
||||
.replacingOccurrences(of: ".tailscale.net", with: "")
|
||||
|
||||
// If CLI is available, check status
|
||||
do {
|
||||
let process = Process()
|
||||
tailscaleHostname = "\(deviceName).\(tailnetName).ts.net"
|
||||
tailscaleIP = apiResponse.iPv4
|
||||
statusError = nil
|
||||
|
||||
// Use the discovered tailscale path
|
||||
if let tailscalePath {
|
||||
process.executableURL = URL(fileURLWithPath: tailscalePath)
|
||||
process.arguments = ["status", "--json"]
|
||||
logger
|
||||
.info(
|
||||
"Tailscale status: running=true, hostname=\(self.tailscaleHostname ?? "nil"), IP=\(self.tailscaleIP ?? "nil")"
|
||||
)
|
||||
} else {
|
||||
// Fallback to env if path not found (shouldn't happen if isCLIAvailable is true)
|
||||
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
|
||||
// Tailscale installed but not running properly
|
||||
tailscaleHostname = nil
|
||||
tailscaleIP = nil
|
||||
|
||||
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))"
|
||||
}
|
||||
statusError = "Tailscale is not running"
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to check Tailscale status: \(error)")
|
||||
} else {
|
||||
// API not responding - Tailscale not running
|
||||
isRunning = false
|
||||
tailscaleHostname = 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/>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>vibetunnel.sh</key>
|
||||
<key>0.0.0.0</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<false/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>localhost</key>
|
||||
<key>100.100.100.100</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
|
|
@ -33,11 +31,18 @@
|
|||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>0.0.0.0</key>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>vibetunnel.sh</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<false/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
|
|
@ -57,4 +62,4 @@
|
|||
<key>NSUserNotificationsUsageDescription</key>
|
||||
<string>VibeTunnel will notify you about important events such as new terminal connections, session status changes, and available updates.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ final class CustomMenuWindow: NSPanel {
|
|||
// Activate app and show window
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
makeKeyAndOrderFront(nil)
|
||||
|
||||
|
||||
// Force button state update again after window is shown
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.statusBarButton?.state = .on
|
||||
|
|
@ -336,7 +336,7 @@ struct CustomMenuContainer<Content: View>: View {
|
|||
Color.white.opacity(0.5)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var backgroundMaterial: some ShapeStyle {
|
||||
switch colorScheme {
|
||||
case .dark:
|
||||
|
|
|
|||
|
|
@ -99,10 +99,10 @@ final class StatusBarMenuManager: NSObject {
|
|||
|
||||
// Update menu state to custom window FIRST before any async operations
|
||||
updateMenuState(.customWindow, button: button)
|
||||
|
||||
|
||||
// Ensure button state is set immediately and persistently
|
||||
button.state = .on
|
||||
|
||||
|
||||
// Force another button state update to ensure it sticks
|
||||
DispatchQueue.main.async {
|
||||
button.state = .on
|
||||
|
|
|
|||
|
|
@ -279,42 +279,21 @@ struct ServerInfoHeader: View {
|
|||
ServerAddressRow()
|
||||
|
||||
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "network")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.purple)
|
||||
Text("ngrok:")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
Text(publicURL)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.purple)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
ServerAddressRow(
|
||||
icon: "network",
|
||||
label: "ngrok:",
|
||||
address: publicURL,
|
||||
url: URL(string: publicURL)
|
||||
)
|
||||
}
|
||||
|
||||
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "shield")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.blue)
|
||||
Text("Tailscale:")
|
||||
.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()
|
||||
}
|
||||
ServerAddressRow(
|
||||
icon: "shield",
|
||||
label: "Tailscale:",
|
||||
address: hostname,
|
||||
url: URL(string: "http://\(hostname):\(serverManager.port)")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -323,25 +302,42 @@ struct ServerInfoHeader: View {
|
|||
}
|
||||
|
||||
struct ServerAddressRow: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let address: String
|
||||
let url: URL?
|
||||
|
||||
@Environment(ServerManager.self)
|
||||
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 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "server.rack")
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(Color(red: 0.0, green: 0.7, blue: 0.0))
|
||||
Text("Local:")
|
||||
.foregroundColor(.green)
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
Button(action: {
|
||||
if let url = URL(string: "http://\(serverAddress)") {
|
||||
if let url = url ?? URL(string: "http://\(computedAddress)") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text(serverAddress)
|
||||
Text(computedAddress)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor(.green)
|
||||
.underline()
|
||||
}
|
||||
.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
|
||||
if bindAddress == "127.0.0.1" {
|
||||
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))
|
||||
.overlay(
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
colorScheme == .dark ? Color.accentColor.opacity(0.08) : Color.accentColor.opacity(0.15)
|
||||
}
|
||||
|
||||
|
||||
private var duration: String {
|
||||
// Parse ISO8601 date string with fractional seconds
|
||||
let formatter = ISO8601DateFormatter()
|
||||
|
|
|
|||
|
|
@ -807,199 +807,92 @@ private struct TailscaleIntegrationSection: View {
|
|||
var body: some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if tailscaleService.isInstalled {
|
||||
// Tailscale app is installed
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Tailscale is installed")
|
||||
.font(.callout)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
if tailscaleService.isInstalled {
|
||||
if tailscaleService.isRunning {
|
||||
// Green dot: Tailscale is installed and running
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(.green)
|
||||
.font(.system(size: 10))
|
||||
Text("Tailscale is installed and running")
|
||||
.font(.callout)
|
||||
} else {
|
||||
// CLI available but Tailscale not running/logged in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let error = tailscaleService.statusError {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.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")
|
||||
// Orange dot: Tailscale is installed but not running
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(.orange)
|
||||
.font(.system(size: 10))
|
||||
Text("Tailscale is installed but not running")
|
||||
.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(
|
||||
"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)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
tailscaleService.openAppStore()
|
||||
}) {
|
||||
Text("App Store")
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.controlSize(.small)
|
||||
|
||||
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()
|
||||
}
|
||||
// Show additional content based on state
|
||||
if !tailscaleService.isInstalled {
|
||||
// Show download links when not installed
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
tailscaleService.openAppStore()
|
||||
}) {
|
||||
Text("App Store")
|
||||
}
|
||||
.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()
|
||||
logger
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -74,8 +74,9 @@ struct TerminalLaunchTests {
|
|||
// iTerm2 URL with working directory
|
||||
if let url = Terminal.iTerm2.commandURL(for: command, workingDirectory: workDir) {
|
||||
#expect(url.absoluteString.contains("cd="))
|
||||
#expect(url.absoluteString
|
||||
.contains(workDir.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")
|
||||
#expect(
|
||||
url.absoluteString
|
||||
.contains(workDir.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue