diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 01adbee5b809..282bf7abb5ea 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -243,6 +243,7 @@ C3744E010000000000000001 /* Workspace+PanelLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3744E020000000000000001 /* Workspace+PanelLifecycle.swift */; }; E30780000000000000000002 /* WorkspaceRemoteConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30780000000000000000001 /* WorkspaceRemoteConfiguration.swift */; }; D0C0D0C0D0C0D0C0D0C00001 /* RemoteLoopbackProxyAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */; }; + D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */; }; E30770000000000000000002 /* WorkspaceRemoteSSHBatchCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30770000000000000000001 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */; }; C0DE32470000000000000003 /* WorkspaceSurfaceIdentifierClipboardText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000004 /* WorkspaceSurfaceIdentifierClipboardText.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; @@ -732,6 +733,7 @@ C3744E020000000000000001 /* Workspace+PanelLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+PanelLifecycle.swift"; sourceTree = ""; }; E30780000000000000000001 /* WorkspaceRemoteConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConfiguration.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackProxyAlias.swift; sourceTree = ""; }; + D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackRuntimeBridge.swift; sourceTree = ""; }; E30770000000000000000001 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSSHBatchCommandBuilder.swift; sourceTree = ""; }; E3309A06 /* Workspace+EqualizeSplitsSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+EqualizeSplitsSupport.swift"; sourceTree = ""; }; D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+DetachedWorkspace.swift"; sourceTree = ""; }; @@ -1143,6 +1145,7 @@ C3744E020000000000000001 /* Workspace+PanelLifecycle.swift */, E30780000000000000000001 /* WorkspaceRemoteConfiguration.swift */, D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */, + D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */, E30770000000000000000001 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */, E3309A06 /* Workspace+EqualizeSplitsSupport.swift */, D7AB00000000000000000016 /* Workspace+DetachedSurfaceTransfer.swift */, @@ -1767,6 +1770,7 @@ C3744E010000000000000001 /* Workspace+PanelLifecycle.swift in Sources */, E30780000000000000000002 /* WorkspaceRemoteConfiguration.swift in Sources */, D0C0D0C0D0C0D0C0D0C00001 /* RemoteLoopbackProxyAlias.swift in Sources */, + D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */, E30770000000000000000002 /* WorkspaceRemoteSSHBatchCommandBuilder.swift in Sources */, E3309A05 /* Workspace+EqualizeSplitsSupport.swift in Sources */, D7AB00000000000000000015 /* Workspace+DetachedSurfaceTransfer.swift in Sources */, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9f9caaff3794..c5bbdf494d1e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1868,18 +1868,6 @@ final class BrowserPortalAnchorView: NSView { @MainActor final class BrowserPanel: Panel, ObservableObject { - private static let remoteLoopbackProxyAliasHost = RemoteLoopbackProxyAlias.aliasHost - private static let remoteLoopbackHosts: Set = [ - "localhost", - "127.0.0.1", - "::1", - "0.0.0.0", - ] - - private static func isRemoteLoopbackHost(_ host: String) -> Bool { - remoteLoopbackHosts.contains(host) || host.hasSuffix(".localhost") - } - /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() @@ -2695,6 +2683,13 @@ final class BrowserPanel: Panel, ObservableObject { forMainFrameOnly: true ) ) + configuration.userContentController.addUserScript( + WKUserScript( + source: RemoteLoopbackRuntimeBridge.runtimeBridgeScriptSource, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ) // Track the last editable focused element continuously so omnibar exit can // restore page input focus even if capture runs after first-responder handoff. // Main frame only — same CAPTCHA interference concern as telemetry hooks. @@ -4035,7 +4030,7 @@ final class BrowserPanel: Panel, ObservableObject { guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url } guard let displayHost = RemoteLoopbackProxyAlias.localhostFamilyHost( forAliasHost: host, - aliasHost: remoteLoopbackProxyAliasHost + aliasHost: RemoteLoopbackProxyAlias.aliasHost ) else { return url } var components = URLComponents(url: url, resolvingAgainstBaseURL: false) @@ -4046,12 +4041,12 @@ final class BrowserPanel: Panel, ObservableObject { private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { guard let scheme = url.scheme?.lowercased(), scheme == "http" else { return nil } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil } - guard Self.isRemoteLoopbackHost(host) else { return nil } + guard RemoteLoopbackProxyAlias.isLoopbackHost(host) else { return nil } var components = URLComponents(url: url, resolvingAgainstBaseURL: false) components?.host = RemoteLoopbackProxyAlias.browserAliasHost( forLoopbackHost: host, - aliasHost: remoteLoopbackProxyAliasHost + aliasHost: RemoteLoopbackProxyAlias.aliasHost ) return components?.url } diff --git a/Sources/RemoteLoopbackProxyAlias.swift b/Sources/RemoteLoopbackProxyAlias.swift index e1a1872312dc..198779926c2e 100644 --- a/Sources/RemoteLoopbackProxyAlias.swift +++ b/Sources/RemoteLoopbackProxyAlias.swift @@ -3,7 +3,21 @@ import Foundation enum RemoteLoopbackProxyAlias { static let aliasHost = "cmux-loopback.localtest.me" - private static let canonicalLoopbackHost = "localhost" + static let canonicalLoopbackHost = "localhost" + static let exactLoopbackHosts: Set = [ + canonicalLoopbackHost, + "127.0.0.1", + "::1", + "0.0.0.0", + ] + + static func isLoopbackHost(_ host: String) -> Bool { + guard let normalizedHost = BrowserInsecureHTTPSettings.normalizeHost(host) else { + return false + } + return exactLoopbackHosts.contains(normalizedHost) + || normalizedHost.hasSuffix(".\(canonicalLoopbackHost)") + } static func browserAliasHost(forLoopbackHost host: String, aliasHost: String) -> String { localhostFamilyAliasHost(forLoopbackHost: host, aliasHost: aliasHost) ?? aliasHost diff --git a/Sources/RemoteLoopbackRuntimeBridge.swift b/Sources/RemoteLoopbackRuntimeBridge.swift new file mode 100644 index 000000000000..c9e7dfa74ef2 --- /dev/null +++ b/Sources/RemoteLoopbackRuntimeBridge.swift @@ -0,0 +1,141 @@ +import Foundation + +enum RemoteLoopbackRuntimeBridge { + static let runtimeBridgeScriptSource: String = { + let exactLoopbackHostLiterals = RemoteLoopbackProxyAlias.exactLoopbackHosts + .sorted() + .map(javaScriptStringLiteral) + .joined(separator: ", ") + return """ + (() => { + const aliasHost = \(javaScriptStringLiteral(RemoteLoopbackProxyAlias.aliasHost)); + const canonicalLoopbackHost = \(javaScriptStringLiteral(RemoteLoopbackProxyAlias.canonicalLoopbackHost)); + const exactLoopbackHosts = new Set([\(exactLoopbackHostLiterals)]); + const normalizeHost = (host) => { + let value = String(host || '').trim().toLowerCase(); + if (!value) return ''; + if (value.endsWith('.')) value = value.slice(0, -1); + if (value.startsWith('[') && value.endsWith(']')) { + value = value.slice(1, -1); + } + return value; + }; + const normalizedAliasHost = normalizeHost(aliasHost); + const currentHost = normalizeHost(window.location.hostname); + let effectiveHost = currentHost; + if (!effectiveHost && window.location.protocol === 'about:') { + try { + effectiveHost = normalizeHost(new URL(document.baseURI).hostname); + } catch (_) {} + } + if (effectiveHost !== normalizedAliasHost && !effectiveHost.endsWith(`.${normalizedAliasHost}`)) { + return true; + } + if (window.__cmuxRemoteLoopbackRuntimeBridgeInstalled) return true; + window.__cmuxRemoteLoopbackRuntimeBridgeInstalled = true; + + const loopbackAliasHost = (host) => { + const normalizedHost = normalizeHost(host); + if (exactLoopbackHosts.has(normalizedHost)) { + return aliasHost; + } + const suffix = `.${canonicalLoopbackHost}`; + if (normalizedHost.endsWith(suffix) && normalizedHost.length > suffix.length) { + return `${normalizedHost.slice(0, -suffix.length)}.${aliasHost}`; + } + return null; + }; + + const rewriteLoopbackURL = (input) => { + if (typeof input !== 'string' && !(input instanceof URL)) { + return input; + } + const original = input instanceof URL ? input.href : input; + let parsed; + try { + parsed = new URL(original, document.baseURI); + } catch { + return input; + } + // Only rewrite cleartext HTTP/WebSocket requests. TLS-bearing `https:` and + // `wss:` validate certificates against the URL hostname, so aliasing them + // would change SNI/certificate expectations for localhost dev servers. + if (parsed.protocol !== 'http:' && parsed.protocol !== 'ws:') { + return input; + } + const rewrittenHost = loopbackAliasHost(parsed.hostname); + if (!rewrittenHost) { + return input; + } + parsed.hostname = rewrittenHost; + return parsed.href; + }; + + Object.defineProperty(window, '__cmuxRewriteRemoteLoopbackURL', { + value: rewriteLoopbackURL, + configurable: true, + }); + + const nativeFetch = window.fetch ? window.fetch.bind(window) : null; + if (nativeFetch) { + window.fetch = (input, init) => { + if (typeof Request !== 'undefined' && input instanceof Request) { + const rewrittenURL = rewriteLoopbackURL(input.url); + if (rewrittenURL !== input.url) { + return nativeFetch(new Request(rewrittenURL, input), init); + } + return nativeFetch(input, init); + } + return nativeFetch(rewriteLoopbackURL(input), init); + }; + } + + const nativeXHROpen = window.XMLHttpRequest && window.XMLHttpRequest.prototype.open; + if (nativeXHROpen) { + window.XMLHttpRequest.prototype.open = function(method, url, ...rest) { + return nativeXHROpen.call(this, method, rewriteLoopbackURL(url), ...rest); + }; + } + + const NativeWebSocket = window.WebSocket; + if (typeof NativeWebSocket === 'function') { + const CmuxWebSocket = function(url, protocols) { + const rewrittenURL = rewriteLoopbackURL(url); + if (protocols === undefined) { + return new NativeWebSocket(rewrittenURL); + } + return new NativeWebSocket(rewrittenURL, protocols); + }; + CmuxWebSocket.prototype = NativeWebSocket.prototype; + Object.setPrototypeOf(CmuxWebSocket, NativeWebSocket); + window.WebSocket = CmuxWebSocket; + } + + const NativeEventSource = window.EventSource; + if (typeof NativeEventSource === 'function') { + const CmuxEventSource = function(url, eventSourceInitDict) { + const rewrittenURL = rewriteLoopbackURL(url); + if (eventSourceInitDict === undefined) { + return new NativeEventSource(rewrittenURL); + } + return new NativeEventSource(rewrittenURL, eventSourceInitDict); + }; + CmuxEventSource.prototype = NativeEventSource.prototype; + Object.setPrototypeOf(CmuxEventSource, NativeEventSource); + window.EventSource = CmuxEventSource; + } + + return true; + })(); + """ + }() + + private static func javaScriptStringLiteral(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + return "'\(escaped)'" + } +} diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index a3f4a0efaeab..253cfc3e6e72 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1275,6 +1275,46 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { XCTAssertEqual(panel.webView.url?.host, "api.cmux-loopback.localtest.me") } + func testRemoteWorkspaceRuntimeBridgeAliasesMultipleLoopbackPortsFromSamePage() async throws { + let remoteWorkspaceId = UUID() + let panel = BrowserPanel( + workspaceId: remoteWorkspaceId, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: remoteWorkspaceId + ) + let baseURL = try XCTUnwrap(URL(string: "http://cmux-loopback.localtest.me:3000/")) + + panel.webView.loadHTMLString( + "remote loopback bridge", + baseURL: baseURL + ) + try await waitForBrowserWebViewLoad(panel.webView) + + let result = try await panel.evaluateJavaScript( + """ + (() => { + const rewrite = window.__cmuxRewriteRemoteLoopbackURL; + if (typeof rewrite !== 'function') { + return 'missing bridge'; + } + return JSON.stringify([ + rewrite('http://localhost:3000/frontend'), + rewrite('http://localhost:8000/api'), + rewrite('http://api.localhost:8000/v1'), + rewrite('ws://localhost:5173/hmr'), + rewrite('wss://localhost:5173/hmr'), + rewrite('https://localhost:9443/secure') + ]); + })() + """ + ) as? String + + XCTAssertEqual( + result, + #"["http://cmux-loopback.localtest.me:3000/frontend","http://cmux-loopback.localtest.me:8000/api","http://api.cmux-loopback.localtest.me:8000/v1","ws://cmux-loopback.localtest.me:5173/hmr","wss://localhost:5173/hmr","https://localhost:9443/secure"]"# + ) + } + func testRemoteWorkspaceKeepsHTTPSLoopbackUnaliased() { let remoteWorkspaceId = UUID() let url = URL(string: "https://localhost:3443/demo")! @@ -1297,6 +1337,17 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { XCTAssertEqual(panel.webView.url?.host, "localhost") } + private func waitForBrowserWebViewLoad(_ webView: WKWebView, timeout: TimeInterval = 2.0) async throws { + let deadline = Date().addingTimeInterval(timeout) + while webView.isLoading { + if Date() >= deadline { + XCTFail("Timed out waiting for browser web view to finish loading") + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + } + func testBrowserMoveIntoRemoteWorkspaceRebuildsWebsiteDataStoreScope() throws { let source = Workspace() let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first)