fix: simplify Tailscale integration using local API (#184)

This commit is contained in:
Billy Irwin 2025-07-01 15:36:59 -07:00 committed by GitHub
parent cf51218d7e
commit 852078d024
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 210 additions and 453 deletions

View file

@ -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")
}
}

View file

@ -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>

View file

@ -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:

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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) ?? "")
)
}
}