Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -732,6 +733,7 @@
C3744E020000000000000001 /* Workspace+PanelLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+PanelLifecycle.swift"; sourceTree = "<group>"; };
E30780000000000000000001 /* WorkspaceRemoteConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConfiguration.swift; sourceTree = "<group>"; };
D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackProxyAlias.swift; sourceTree = "<group>"; };
D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackRuntimeBridge.swift; sourceTree = "<group>"; };
E30770000000000000000001 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSSHBatchCommandBuilder.swift; sourceTree = "<group>"; };
E3309A06 /* Workspace+EqualizeSplitsSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+EqualizeSplitsSupport.swift"; sourceTree = "<group>"; };
D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+DetachedWorkspace.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
25 changes: 10 additions & 15 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = [
"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()

Expand Down Expand Up @@ -2695,6 +2683,13 @@ final class BrowserPanel: Panel, ObservableObject {
forMainFrameOnly: true
)
)
configuration.userContentController.addUserScript(
WKUserScript(
source: RemoteLoopbackRuntimeBridge.runtimeBridgeScriptSource,
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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.
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
16 changes: 15 additions & 1 deletion Sources/RemoteLoopbackProxyAlias.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = [
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
Expand Down
141 changes: 141 additions & 0 deletions Sources/RemoteLoopbackRuntimeBridge.swift
Original file line number Diff line number Diff line change
@@ -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)'"
}
}
51 changes: 51 additions & 0 deletions cmuxTests/GhosttyConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<!doctype html><html><body>remote loopback bridge</body></html>",
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")!
Expand All @@ -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)
Expand Down
Loading