diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 0afd9de7b88..6e1ea7c1eb0 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -135,6 +135,7 @@ 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; }; 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; }; C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */; }; + C1A2B3C4D5E6F70800000006 /* ServeWebPortStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -334,6 +335,7 @@ 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; }; 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigTests.swift; sourceTree = ""; }; + C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServeWebPortStoreTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -615,6 +617,7 @@ 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */, + C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -899,6 +902,7 @@ 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */, + C1A2B3C4D5E6F70800000006 /* ServeWebPortStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e749793617e..ff92a9ee6bb 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -967,16 +967,16 @@ final class VSCodeServeWebController { let process = Process() process.executableURL = launchConfiguration.executableURL - // TODO(#21): --port 0 means VS Code gets an OS-assigned ephemeral port each restart, so - // the browser must re-navigate to the new URL. To stabilise: either use a fixed loopback - // port, or persist the port assigned by VS Code (from ServeWebOutputCollector) under - // vscodeServerDataDir and reuse it here. The --server-data-dir and persistent - // connection-token already fix Settings Sync / OAuth auth; the URL change is UX-only. + // #21: reuse the port VS Code assigned on a previous run so the embedded browser + // keeps the same URL across restarts. ServeWebPortStore returns the persisted port + // only when it is still bindable, otherwise "0" (OS-assigned) — so a now-occupied + // port falls back gracefully instead of failing the launch. The --server-data-dir + // and persistent connection-token already fix Settings Sync / OAuth auth. process.arguments = launchConfiguration.argumentsPrefix + [ "serve-web", "--accept-server-license-terms", "--host", "127.0.0.1", - "--port", "0", + "--port", ServeWebPortStore.portArgument(persistedIn: Self.vscodeServerDataDir), "--server-data-dir", Self.vscodeServerDataDir?.path ?? NSTemporaryDirectory(), "--connection-token-file", connectionTokenFileURL.path, ] @@ -1075,6 +1075,11 @@ final class VSCodeServeWebController { return nil } + // #21: remember the assigned port so the next launch can request it again. + if let assignedPort = serveWebURL.port { + ServeWebPortStore.persist(port: assignedPort, in: Self.vscodeServerDataDir) + } + return (process, serveWebURL) } @@ -1216,6 +1221,73 @@ final class ServeWebOutputCollector { } } +/// Persists the VS Code serve-web port across restarts (#21) so the embedded browser +/// keeps the same URL. The port `code serve-web` assigns is written under the serve-web +/// data dir and reused on the next launch — but only when it is still bindable, so a +/// now-occupied port falls back to an OS-assigned one instead of failing the launch. +enum ServeWebPortStore { + static let fileName = "serve-web-port" + + /// The `--port` argument for `code serve-web`: the persisted port when it is valid and + /// currently free, otherwise "0" (let the OS assign one). `isPortAvailable` is injectable + /// for testing; it defaults to a real loopback bind probe. + static func portArgument( + persistedIn directory: URL?, + isPortAvailable: (Int) -> Bool = ServeWebPortStore.isPortAvailable + ) -> String { + guard let url = portFileURL(in: directory), + let data = try? Data(contentsOf: url), + let raw = String(data: data, encoding: .utf8), + let port = parsePort(raw), + isPortAvailable(port) else { + return "0" + } + return String(port) + } + + /// Records the port VS Code assigned so the next launch can request it again. No-op for + /// out-of-range ports or when the data dir is unavailable. + static func persist(port: Int, in directory: URL?) { + guard isValidPort(port), let url = portFileURL(in: directory) else { return } + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? Data(String(port).utf8).write(to: url, options: .atomic) + } + + /// Parses a stored port string, returning nil for malformed or out-of-range values. + static func parsePort(_ raw: String) -> Int? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let port = Int(trimmed), isValidPort(port) else { return nil } + return port + } + + /// True when a TCP socket can bind 127.0.0.1:port right now. + static func isPortAvailable(_ port: Int) -> Bool { + guard isValidPort(port) else { return false } + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + var reuse: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(UInt16(port)).bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + return bound == 0 + } + + private static func isValidPort(_ port: Int) -> Bool { (1...65535).contains(port) } + + private static func portFileURL(in directory: URL?) -> URL? { + directory?.appendingPathComponent(fileName, isDirectory: false) + } +} + enum WorkspaceShortcutMapper { /// Maps numbered workspace shortcuts to a zero-based workspace index. /// 1...8 target fixed indices; 9 always targets the last workspace. diff --git a/cmuxTests/ServeWebPortStoreTests.swift b/cmuxTests/ServeWebPortStoreTests.swift new file mode 100644 index 00000000000..b198f100953 --- /dev/null +++ b/cmuxTests/ServeWebPortStoreTests.swift @@ -0,0 +1,114 @@ +import XCTest +import Foundation +import Darwin + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Tests for #21: persisting and reusing the VS Code serve-web port so the embedded +/// browser keeps the same URL across restarts, while falling back to an OS-assigned +/// port when the persisted one is no longer free. +final class ServeWebPortStoreTests: XCTestCase { + private func makeTempDir() -> URL { + let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("serve-web-port-test-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + func testPortArgumentIsZeroWhenNoFile() { + let dir = makeTempDir() + XCTAssertEqual( + ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }), + "0" + ) + } + + func testPersistThenReuseRoundTrips() { + let dir = makeTempDir() + ServeWebPortStore.persist(port: 50_321, in: dir) + XCTAssertEqual( + ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }), + "50321" + ) + } + + func testPersistedPortIgnoredWhenUnavailable() { + let dir = makeTempDir() + ServeWebPortStore.persist(port: 50_321, in: dir) + XCTAssertEqual( + ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in false }), + "0", + "An occupied persisted port must fall back to OS-assigned" + ) + } + + func testNilDirectoryIsZero() { + XCTAssertEqual( + ServeWebPortStore.portArgument(persistedIn: nil, isPortAvailable: { _ in true }), + "0" + ) + } + + func testPersistRejectsOutOfRangePorts() { + let dir = makeTempDir() + ServeWebPortStore.persist(port: 0, in: dir) + ServeWebPortStore.persist(port: 70_000, in: dir) + // Nothing valid was written, so the OS-assigned fallback still applies. + XCTAssertEqual( + ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }), + "0" + ) + } + + func testParsePort() { + XCTAssertEqual(ServeWebPortStore.parsePort(" 8080 \n"), 8080) + XCTAssertEqual(ServeWebPortStore.parsePort("1"), 1) + XCTAssertEqual(ServeWebPortStore.parsePort("65535"), 65535) + XCTAssertNil(ServeWebPortStore.parsePort("")) + XCTAssertNil(ServeWebPortStore.parsePort("notaport")) + XCTAssertNil(ServeWebPortStore.parsePort("0")) + XCTAssertNil(ServeWebPortStore.parsePort("65536")) + XCTAssertNil(ServeWebPortStore.parsePort("-1")) + } + + func testIsPortAvailableDetectsOccupiedPort() throws { + // Bind+listen on an OS-assigned loopback port, then assert the store reports it busy. + let listenFd = socket(AF_INET, SOCK_STREAM, 0) + try XCTSkipIf(listenFd < 0, "could not create probe socket") + defer { close(listenFd) } + + var reuse: Int32 = 1 + _ = setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = 0 // OS-assigned + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.bind(listenFd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + try XCTSkipIf(bound != 0, "could not bind probe socket") + try XCTSkipIf(listen(listenFd, 1) != 0, "could not listen on probe socket") + + var boundAddr = sockaddr_in() + var len = socklen_t(MemoryLayout.size) + let got = withUnsafeMutablePointer(to: &boundAddr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + getsockname(listenFd, sockaddrPointer, &len) + } + } + try XCTSkipIf(got != 0, "could not read probe port") + let port = Int(UInt16(bigEndian: boundAddr.sin_port)) + + XCTAssertFalse( + ServeWebPortStore.isPortAvailable(port), + "A port held by a live listener must report as unavailable" + ) + } +}