diff --git a/Resources/Info.plist b/Resources/Info.plist index 48d4f800..bf791157 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -108,6 +108,16 @@ NSAllowsArbitraryLoadsInWebContent + NSExceptionDomains + + cmux-loopback.localtest.me + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + SUFeedURL https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 692e3aa5..f8ff3836 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4,6 +4,7 @@ import WebKit import AppKit import Bonsplit import Network +import CFNetwork struct BrowserProxyEndpoint: Equatable { let host: String @@ -2457,6 +2458,13 @@ final class BrowserPanel: Panel, ObservableObject { guard let self, let webView else { return } guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } +#if DEBUG + dlog( + "browser.favicon.begin " + + "panel=\(id.uuidString.prefix(5)) " + + "page=\(pageURL.absoluteString)" + ) +#endif // Try to discover the best icon URL from the document. let js = """ @@ -2484,7 +2492,11 @@ final class BrowserPanel: Panel, ObservableObject { """ var discoveredURL: URL? - if let href = try? await webView.evaluateJavaScript(js) as? String { + if let href = await self.evaluateJavaScriptString( + js, + in: webView, + timeoutNanoseconds: 400_000_000 + ) { let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, let u = URL(string: trimmed) { discoveredURL = u @@ -2496,10 +2508,26 @@ final class BrowserPanel: Panel, ObservableObject { let fallbackURL = URL(string: "/favicon.ico", relativeTo: pageURL) let iconURL = discoveredURL ?? fallbackURL guard let iconURL else { return } +#if DEBUG + dlog( + "browser.favicon.iconURL " + + "panel=\(id.uuidString.prefix(5)) " + + "discovered=\(discoveredURL?.absoluteString ?? "") " + + "fallback=\(fallbackURL?.absoluteString ?? "") " + + "chosen=\(iconURL.absoluteString)" + ) +#endif // Avoid repeated fetches. let iconURLString = iconURL.absoluteString if iconURLString == lastFaviconURLString, faviconPNGData != nil { +#if DEBUG + dlog( + "browser.favicon.skipCached " + + "panel=\(id.uuidString.prefix(5)) " + + "icon=\(iconURLString)" + ) +#endif return } lastFaviconURLString = iconURLString @@ -2508,12 +2536,42 @@ final class BrowserPanel: Panel, ObservableObject { req.timeoutInterval = 2.0 req.cachePolicy = .returnCacheDataElseLoad req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent") + let effectiveRequest = remoteProxyPreparedRequest(from: req, logScope: "faviconRewrite") let data: Data let response: URLResponse do { - (data, response) = try await URLSession.shared.data(for: req) + let remoteSession = remoteProxyURLSession() + defer { remoteSession?.finishTasksAndInvalidate() } + if let remoteSession { +#if DEBUG + dlog( + "browser.favicon.fetch " + + "panel=\(id.uuidString.prefix(5)) " + + "via=proxy " + + "url=\(effectiveRequest.url?.absoluteString ?? "")" + ) +#endif + (data, response) = try await remoteSession.data(for: effectiveRequest) + } else { +#if DEBUG + dlog( + "browser.favicon.fetch " + + "panel=\(id.uuidString.prefix(5)) " + + "via=direct " + + "url=\(effectiveRequest.url?.absoluteString ?? "")" + ) +#endif + (data, response) = try await URLSession.shared.data(for: effectiveRequest) + } } catch { +#if DEBUG + dlog( + "browser.favicon.fetchError " + + "panel=\(id.uuidString.prefix(5)) " + + "error=\(String(describing: error))" + ) +#endif return } guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } @@ -2521,13 +2579,45 @@ final class BrowserPanel: Panel, ObservableObject { guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { +#if DEBUG + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + dlog( + "browser.favicon.badResponse " + + "panel=\(id.uuidString.prefix(5)) " + + "status=\(status)" + ) +#endif return } +#if DEBUG + dlog( + "browser.favicon.response " + + "panel=\(id.uuidString.prefix(5)) " + + "status=\(http.statusCode) " + + "bytes=\(data.count)" + ) +#endif // Use >= 2x the rendered point size so we don't upscale (blurry) on Retina. - guard let png = Self.makeFaviconPNGData(from: data, targetPx: 32) else { return } + guard let png = Self.makeFaviconPNGData(from: data, targetPx: 32) else { +#if DEBUG + dlog( + "browser.favicon.decodeFailed " + + "panel=\(id.uuidString.prefix(5)) " + + "bytes=\(data.count)" + ) +#endif + return + } // Only update if we got a real icon; keep the old one otherwise to avoid flashes. faviconPNGData = png +#if DEBUG + dlog( + "browser.favicon.ready " + + "panel=\(id.uuidString.prefix(5)) " + + "pngBytes=\(png.count)" + ) +#endif } } @@ -2536,6 +2626,35 @@ final class BrowserPanel: Panel, ObservableObject { return generation == faviconRefreshGeneration } + @MainActor + private func evaluateJavaScriptString( + _ script: String, + in webView: WKWebView, + timeoutNanoseconds: UInt64 + ) async -> String? { + await withCheckedContinuation { continuation in + var hasResumed = false + + func resume(_ value: String?) { + guard !hasResumed else { return } + hasResumed = true + continuation.resume(returning: value) + } + + webView.evaluateJavaScript(script) { result, _ in + let value = result as? String + Task { @MainActor in + resume(value) + } + } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + resume(nil) + } + } + } + @MainActor private static func makeFaviconPNGData(from raw: Data, targetPx: Int) -> Data? { guard let image = NSImage(data: raw) else { return nil } @@ -2669,7 +2788,7 @@ final class BrowserPanel: Panel, ObservableObject { if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } - let effectiveRequest = remoteProxyPreparedNavigationRequest(from: request) + let effectiveRequest = remoteProxyPreparedRequest(from: request, logScope: "rewrite") // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true @@ -2680,7 +2799,7 @@ final class BrowserPanel: Panel, ObservableObject { browserLoadRequest(effectiveRequest, in: webView) } - private func remoteProxyPreparedNavigationRequest(from request: URLRequest) -> URLRequest { + private func remoteProxyPreparedRequest(from request: URLRequest, logScope: String) -> URLRequest { guard remoteProxyEndpoint != nil else { return request } guard let url = request.url else { return request } guard let rewrittenURL = Self.remoteProxyLoopbackAliasURL(for: url) else { return request } @@ -2689,7 +2808,7 @@ final class BrowserPanel: Panel, ObservableObject { rewrittenRequest.url = rewrittenURL #if DEBUG dlog( - "browser.remoteProxy.rewrite " + + "browser.remoteProxy.\(logScope) " + "panel=\(id.uuidString.prefix(5)) " + "from=\(url.absoluteString) " + "to=\(rewrittenURL.absoluteString)" @@ -2698,6 +2817,23 @@ final class BrowserPanel: Panel, ObservableObject { return rewrittenRequest } + private func remoteProxyURLSession() -> URLSession? { + guard let endpoint = remoteProxyEndpoint else { return nil } + let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, endpoint.port > 0, endpoint.port <= 65535 else { return nil } + + let configuration = URLSessionConfiguration.ephemeral + configuration.requestCachePolicy = .returnCacheDataElseLoad + configuration.timeoutIntervalForRequest = 2.0 + configuration.timeoutIntervalForResource = 4.0 + configuration.connectionProxyDictionary = [ + kCFNetworkProxiesSOCKSEnable as String: 1, + kCFNetworkProxiesSOCKSProxy as String: host, + kCFNetworkProxiesSOCKSPort as String: endpoint.port, + ] + return URLSession(configuration: configuration) + } + private static func remoteProxyDisplayURL(for url: URL?) -> URL? { guard let url else { return nil } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url }