mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-24 14:47:39 +00:00
446 lines
14 KiB
Swift
446 lines
14 KiB
Swift
import Foundation
|
|
import Observation
|
|
import os
|
|
|
|
/// Errors that can occur during ngrok operations.
|
|
///
|
|
/// Represents various failure modes when working with ngrok tunnels,
|
|
/// from installation issues to runtime configuration problems.
|
|
enum NgrokError: LocalizedError, Equatable {
|
|
case notInstalled
|
|
case authTokenMissing
|
|
case tunnelCreationFailed(String)
|
|
case invalidConfiguration
|
|
case networkError(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notInstalled:
|
|
"ngrok is not installed. Please install it using 'brew install ngrok' or download from ngrok.com"
|
|
case .authTokenMissing:
|
|
"ngrok auth token is missing. Please add it in Settings"
|
|
case .tunnelCreationFailed(let message):
|
|
"Failed to create tunnel: \(message)"
|
|
case .invalidConfiguration:
|
|
"Invalid ngrok configuration"
|
|
case .networkError(let message):
|
|
"Network error: \(message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Represents the status of an ngrok tunnel.
|
|
///
|
|
/// Contains the current state of an active ngrok tunnel including
|
|
/// its public URL, traffic metrics, and creation timestamp.
|
|
struct NgrokTunnelStatus: Codable {
|
|
let publicUrl: String
|
|
let metrics: TunnelMetrics
|
|
let startedAt: Date
|
|
|
|
/// Traffic metrics for the ngrok tunnel.
|
|
///
|
|
/// Tracks connection count and bandwidth usage.
|
|
struct TunnelMetrics: Codable {
|
|
let connectionsCount: Int
|
|
let bytesIn: Int64
|
|
let bytesOut: Int64
|
|
}
|
|
}
|
|
|
|
/// Protocol for ngrok tunnel operations.
|
|
///
|
|
/// Defines the interface for managing ngrok tunnel lifecycle,
|
|
/// including creation, monitoring, and termination.
|
|
protocol NgrokTunnelProtocol {
|
|
func start(port: Int) async throws -> String
|
|
func stop() async throws
|
|
func getStatus() async -> NgrokTunnelStatus?
|
|
func isRunning() async -> Bool
|
|
}
|
|
|
|
/// Manages ngrok tunnel lifecycle and configuration.
|
|
///
|
|
/// `NgrokService` provides a high-level interface for creating and managing ngrok tunnels
|
|
/// to expose local VibeTunnel servers to the internet. It handles authentication,
|
|
/// process management, and status monitoring while integrating with the system keychain
|
|
/// for secure token storage. The service operates as a singleton on the main actor.
|
|
@Observable
|
|
@MainActor
|
|
final class NgrokService: NgrokTunnelProtocol {
|
|
static let shared = NgrokService()
|
|
|
|
/// Current tunnel status
|
|
private(set) var tunnelStatus: NgrokTunnelStatus?
|
|
|
|
/// Indicates if a tunnel is currently active
|
|
private(set) var isActive = false
|
|
|
|
/// The public URL of the active tunnel
|
|
private(set) var publicUrl: String?
|
|
|
|
/// Auth token for ngrok (stored securely in Keychain)
|
|
var authToken: String? {
|
|
get {
|
|
let token = KeychainHelper.getNgrokAuthToken()
|
|
logger.info("Getting auth token from keychain: \(token != nil ? "present" : "nil")")
|
|
return token
|
|
}
|
|
set {
|
|
logger.info("Setting auth token in keychain: \(newValue != nil ? "present" : "nil")")
|
|
if let token = newValue {
|
|
KeychainHelper.setNgrokAuthToken(token)
|
|
} else {
|
|
KeychainHelper.deleteNgrokAuthToken()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if auth token exists without triggering keychain prompt
|
|
var hasAuthToken: Bool {
|
|
KeychainHelper.hasNgrokAuthToken()
|
|
}
|
|
|
|
/// The ngrok process if using CLI mode
|
|
private var ngrokProcess: Process?
|
|
|
|
/// Task for periodic status updates
|
|
private var statusTask: Task<Void, Never>?
|
|
|
|
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "NgrokService")
|
|
|
|
private init() {}
|
|
|
|
/// Starts an ngrok tunnel for the specified port
|
|
func start(port: Int) async throws -> String {
|
|
logger.info("Starting ngrok tunnel on port \(port)")
|
|
|
|
guard let authToken, !authToken.isEmpty else {
|
|
logger.error("Auth token is missing")
|
|
throw NgrokError.authTokenMissing
|
|
}
|
|
|
|
logger.info("Auth token is present, proceeding with CLI start")
|
|
|
|
// For now, we'll use the ngrok CLI approach
|
|
// Later we can switch to the SDK when available
|
|
return try await startWithCLI(port: port)
|
|
}
|
|
|
|
/// Stops the active ngrok tunnel
|
|
func stop() async throws {
|
|
logger.info("Stopping ngrok tunnel")
|
|
|
|
if let process = ngrokProcess {
|
|
process.terminate()
|
|
ngrokProcess = nil
|
|
}
|
|
|
|
statusTask?.cancel()
|
|
statusTask = nil
|
|
|
|
isActive = false
|
|
publicUrl = nil
|
|
tunnelStatus = nil
|
|
}
|
|
|
|
/// Gets the current tunnel status
|
|
func getStatus() async -> NgrokTunnelStatus? {
|
|
tunnelStatus
|
|
}
|
|
|
|
/// Checks if a tunnel is currently running
|
|
func isRunning() async -> Bool {
|
|
isActive && ngrokProcess?.isRunning == true
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
/// Starts ngrok using the CLI
|
|
private func startWithCLI(port: Int) async throws -> String {
|
|
// Check if ngrok is installed
|
|
let checkProcess = Process()
|
|
checkProcess.executableURL = URL(fileURLWithPath: "/usr/bin/which")
|
|
checkProcess.arguments = ["ngrok"]
|
|
|
|
// Add common Homebrew paths to PATH for the check
|
|
var environment = ProcessInfo.processInfo.environment
|
|
let currentPath = environment["PATH"] ?? "/usr/bin:/bin"
|
|
let homebrewPaths = "/opt/homebrew/bin:/usr/local/bin"
|
|
environment["PATH"] = "\(homebrewPaths):\(currentPath)"
|
|
checkProcess.environment = environment
|
|
|
|
let checkPipe = Pipe()
|
|
checkProcess.standardOutput = checkPipe
|
|
checkProcess.standardError = Pipe()
|
|
|
|
do {
|
|
try checkProcess.run()
|
|
checkProcess.waitUntilExit()
|
|
|
|
let data = checkPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let ngrokPath = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
guard let ngrokPath, !ngrokPath.isEmpty else {
|
|
throw NgrokError.notInstalled
|
|
}
|
|
|
|
// Set up ngrok with auth token
|
|
let authProcess = Process()
|
|
authProcess.executableURL = URL(fileURLWithPath: ngrokPath)
|
|
guard let authToken else {
|
|
throw NgrokError.authTokenMissing
|
|
}
|
|
authProcess.arguments = ["config", "add-authtoken", authToken]
|
|
|
|
try authProcess.run()
|
|
authProcess.waitUntilExit()
|
|
|
|
// Start ngrok tunnel
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: ngrokPath)
|
|
process.arguments = ["http", "\(port)", "--log=stdout", "--log-format=json"]
|
|
|
|
let outputPipe = Pipe()
|
|
process.standardOutput = outputPipe
|
|
process.standardError = Pipe()
|
|
|
|
// Monitor output for the public URL
|
|
let outputHandle = outputPipe.fileHandleForReading
|
|
|
|
_ = false // publicUrlFound - removed as unused
|
|
let urlExpectation = Task<String, Error> {
|
|
for try await line in outputHandle.lines {
|
|
if let data = line.data(using: .utf8),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
{
|
|
// Look for tunnel established message
|
|
if let msg = json["msg"] as? String,
|
|
msg.contains("started tunnel"),
|
|
let url = json["url"] as? String
|
|
{
|
|
return url
|
|
}
|
|
|
|
// Alternative: look for public URL in addr field
|
|
if let addr = json["addr"] as? String,
|
|
addr.starts(with: "https://")
|
|
{
|
|
return addr
|
|
}
|
|
}
|
|
}
|
|
throw NgrokError.tunnelCreationFailed("Could not find public URL in ngrok output")
|
|
}
|
|
|
|
try process.run()
|
|
self.ngrokProcess = process
|
|
|
|
// Wait for URL with timeout
|
|
let url = try await withTimeout(seconds: 10) {
|
|
try await urlExpectation.value
|
|
}
|
|
|
|
self.publicUrl = url
|
|
self.isActive = true
|
|
|
|
// Start monitoring tunnel status
|
|
startStatusMonitoring()
|
|
|
|
logger.info("ngrok tunnel started: \(url)")
|
|
return url
|
|
} catch {
|
|
logger.error("Failed to start ngrok: \(error)")
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/// Monitors tunnel status periodically
|
|
private func startStatusMonitoring() {
|
|
statusTask?.cancel()
|
|
|
|
statusTask = Task { @MainActor in
|
|
while !Task.isCancelled {
|
|
await self.updateTunnelStatus()
|
|
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Updates the current tunnel status
|
|
private func updateTunnelStatus() async {
|
|
// In a real implementation, we would query ngrok's API
|
|
// For now, just check if the process is still running
|
|
if let process = ngrokProcess, process.isRunning {
|
|
if tunnelStatus == nil {
|
|
tunnelStatus = NgrokTunnelStatus(
|
|
publicUrl: publicUrl ?? "",
|
|
metrics: .init(connectionsCount: 0, bytesIn: 0, bytesOut: 0),
|
|
startedAt: Date()
|
|
)
|
|
}
|
|
} else {
|
|
isActive = false
|
|
publicUrl = nil
|
|
tunnelStatus = nil
|
|
}
|
|
}
|
|
|
|
/// Executes an async task with a timeout
|
|
private func withTimeout<T: Sendable>(
|
|
seconds: TimeInterval,
|
|
operation: @Sendable @escaping () async throws -> T
|
|
)
|
|
async throws -> T
|
|
{
|
|
try await withThrowingTaskGroup(of: T.self) { group in
|
|
group.addTask {
|
|
try await operation()
|
|
}
|
|
|
|
group.addTask {
|
|
try await Task.sleep(for: .seconds(seconds))
|
|
throw NgrokError.networkError("Operation timed out")
|
|
}
|
|
|
|
guard let result = try await group.next() else {
|
|
throw NgrokError.networkError("No result received")
|
|
}
|
|
group.cancelAll()
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - AsyncSequence Extension for FileHandle
|
|
|
|
extension FileHandle {
|
|
var lines: AsyncLineSequence {
|
|
AsyncLineSequence(fileHandle: self)
|
|
}
|
|
}
|
|
|
|
/// Async sequence for reading lines from a FileHandle.
|
|
///
|
|
/// Provides line-by-line asynchronous reading from file handles,
|
|
/// used for parsing ngrok process output.
|
|
struct AsyncLineSequence: AsyncSequence {
|
|
typealias Element = String
|
|
|
|
let fileHandle: FileHandle
|
|
|
|
struct AsyncIterator: AsyncIteratorProtocol {
|
|
let fileHandle: FileHandle
|
|
var buffer = Data()
|
|
|
|
mutating func next() async -> String? {
|
|
while true {
|
|
let lineBreakData = Data("\n".utf8)
|
|
if let range = buffer.range(of: lineBreakData) {
|
|
let line = String(data: buffer[..<range.lowerBound], encoding: .utf8)
|
|
buffer.removeSubrange(..<range.upperBound)
|
|
return line
|
|
}
|
|
|
|
let newData = fileHandle.availableData
|
|
if newData.isEmpty {
|
|
if !buffer.isEmpty {
|
|
defer { buffer.removeAll() }
|
|
return String(data: buffer, encoding: .utf8)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
buffer.append(newData)
|
|
}
|
|
}
|
|
}
|
|
|
|
func makeAsyncIterator() -> AsyncIterator {
|
|
AsyncIterator(fileHandle: fileHandle)
|
|
}
|
|
}
|
|
|
|
// MARK: - Keychain Helper
|
|
|
|
/// Helper for secure storage of ngrok auth tokens in Keychain.
|
|
///
|
|
/// Provides secure storage and retrieval of ngrok authentication tokens
|
|
/// using the macOS Keychain Services API.
|
|
private enum KeychainHelper {
|
|
private static let service = "sh.vibetunnel.vibetunnel"
|
|
private static let account = "ngrok-auth-token"
|
|
|
|
static func getNgrokAuthToken() -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecReturnData as String: true
|
|
]
|
|
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
|
|
guard status == errSecSuccess,
|
|
let data = result as? Data,
|
|
let token = String(data: data, encoding: .utf8)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return token
|
|
}
|
|
|
|
/// Check if a token exists without retrieving it (won't trigger keychain prompt)
|
|
static func hasNgrokAuthToken() -> Bool {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
kSecReturnAttributes as String: false,
|
|
kSecReturnData as String: false
|
|
]
|
|
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
|
|
return status == errSecSuccess
|
|
}
|
|
|
|
static func setNgrokAuthToken(_ token: String) {
|
|
guard let data = token.data(using: .utf8) else {
|
|
return
|
|
}
|
|
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account
|
|
]
|
|
|
|
// Try to update first
|
|
var updateQuery = query
|
|
updateQuery[kSecValueData as String] = data
|
|
|
|
var status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
|
|
|
if status == errSecItemNotFound {
|
|
// Item doesn't exist, create it
|
|
var addQuery = query
|
|
addQuery[kSecValueData as String] = data
|
|
status = SecItemAdd(addQuery as CFDictionary, nil)
|
|
}
|
|
}
|
|
|
|
static func deleteNgrokAuthToken() {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account
|
|
]
|
|
|
|
SecItemDelete(query as CFDictionary)
|
|
}
|
|
}
|