diff --git a/Resources/Info.plist b/Resources/Info.plist
index da978c67..8e323ec1 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -92,6 +92,11 @@
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoadsInWebContent
+
+
SUFeedURL
https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml
SUPublicEDKey
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index 031887df..bf98723c 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -75,6 +75,164 @@ enum BrowserLinkOpenSettings {
}
}
+enum BrowserInsecureHTTPSettings {
+ static let allowlistKey = "browserInsecureHTTPAllowlist"
+ static let defaultAllowlistPatterns = [
+ "127.0.0.1",
+ "localhost",
+ "*.localtest.me",
+ ]
+ static let defaultAllowlistText = defaultAllowlistPatterns.joined(separator: "\n")
+
+ static func normalizedAllowlistPatterns(defaults: UserDefaults = .standard) -> [String] {
+ normalizedAllowlistPatterns(rawValue: defaults.string(forKey: allowlistKey))
+ }
+
+ static func normalizedAllowlistPatterns(rawValue: String?) -> [String] {
+ let source: String
+ if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ source = rawValue
+ } else {
+ source = defaultAllowlistText
+ }
+ let parsed = parsePatterns(from: source)
+ return parsed.isEmpty ? defaultAllowlistPatterns : parsed
+ }
+
+ static func isHostAllowed(_ host: String, defaults: UserDefaults = .standard) -> Bool {
+ isHostAllowed(host, rawAllowlist: defaults.string(forKey: allowlistKey))
+ }
+
+ static func isHostAllowed(_ host: String, rawAllowlist: String?) -> Bool {
+ guard let normalizedHost = normalizeHost(host) else { return false }
+ return normalizedAllowlistPatterns(rawValue: rawAllowlist).contains { pattern in
+ hostMatchesPattern(normalizedHost, pattern: pattern)
+ }
+ }
+
+ static func addAllowedHost(_ host: String, defaults: UserDefaults = .standard) {
+ guard let normalizedHost = normalizeHost(host) else { return }
+ var patterns = normalizedAllowlistPatterns(defaults: defaults)
+ guard !patterns.contains(normalizedHost) else { return }
+ patterns.append(normalizedHost)
+ defaults.set(patterns.joined(separator: "\n"), forKey: allowlistKey)
+ }
+
+ static func normalizeHost(_ rawHost: String) -> String? {
+ var value = rawHost
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ guard !value.isEmpty else { return nil }
+
+ if let parsed = URL(string: value)?.host {
+ return trimHost(parsed)
+ }
+
+ if let schemeRange = value.range(of: "://") {
+ value = String(value[schemeRange.upperBound...])
+ }
+
+ if let slash = value.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) {
+ value = String(value[.. [String] {
+ let separators = CharacterSet(charactersIn: ",;\n\r\t")
+ var out: [String] = []
+ var seen = Set()
+ for token in rawValue.components(separatedBy: separators) {
+ guard let normalized = normalizePattern(token) else { continue }
+ guard seen.insert(normalized).inserted else { continue }
+ out.append(normalized)
+ }
+ return out
+ }
+
+ private static func normalizePattern(_ rawPattern: String) -> String? {
+ let trimmed = rawPattern
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ guard !trimmed.isEmpty else { return nil }
+
+ if trimmed.hasPrefix("*.") {
+ let suffixRaw = String(trimmed.dropFirst(2))
+ guard let suffix = normalizeHost(suffixRaw) else { return nil }
+ return "*.\(suffix)"
+ }
+
+ return normalizeHost(trimmed)
+ }
+
+ private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool {
+ if pattern.hasPrefix("*.") {
+ let suffix = String(pattern.dropFirst(2))
+ return host == suffix || host.hasSuffix(".\(suffix)")
+ }
+ return host == pattern
+ }
+
+ private static func trimHost(_ raw: String) -> String? {
+ let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: "."))
+ return trimmed.isEmpty ? nil : trimmed
+ }
+}
+
+func browserShouldBlockInsecureHTTPURL(
+ _ url: URL,
+ defaults: UserDefaults = .standard
+) -> Bool {
+ browserShouldBlockInsecureHTTPURL(
+ url,
+ rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey)
+ )
+}
+
+func browserShouldBlockInsecureHTTPURL(
+ _ url: URL,
+ rawAllowlist: String?
+) -> Bool {
+ guard url.scheme?.lowercased() == "http" else { return false }
+ guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true }
+ return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist)
+}
+
+func browserShouldConsumeOneTimeInsecureHTTPBypass(
+ _ url: URL,
+ bypassHostOnce: inout String?
+) -> Bool {
+ guard let bypassHost = bypassHostOnce else { return false }
+ guard url.scheme?.lowercased() == "http",
+ let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
+ return false
+ }
+ guard host == bypassHost else { return false }
+ bypassHostOnce = nil
+ return true
+}
+
+func browserShouldPersistInsecureHTTPAllowlistSelection(
+ response: NSApplication.ModalResponse,
+ suppressionEnabled: Bool
+) -> Bool {
+ guard suppressionEnabled else { return false }
+ return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn
+}
+
enum BrowserUserAgentSettings {
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
@@ -769,6 +927,11 @@ actor BrowserSearchSuggestionService {
/// BrowserPanel provides a WKWebView-based browser panel.
/// All browser panels share a WKProcessPool for cookie sharing.
+private enum BrowserInsecureHTTPNavigationIntent {
+ case currentTab
+ case newTab
+}
+
@MainActor
final class BrowserPanel: Panel, ObservableObject {
/// Shared process pool for cookie sharing across all browser panels
@@ -837,6 +1000,7 @@ final class BrowserPanel: Panel, ObservableObject {
private let minPageZoom: CGFloat = 0.25
private let maxPageZoom: CGFloat = 5.0
private let pageZoomStep: CGFloat = 0.1
+ private var insecureHTTPBypassHostOnce: String?
var displayTitle: String {
if !pageTitle.isEmpty {
@@ -856,9 +1020,10 @@ final class BrowserPanel: Panel, ObservableObject {
false
}
- init(workspaceId: UUID, initialURL: URL? = nil) {
+ init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) {
self.id = UUID()
self.workspaceId = workspaceId
+ self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
// Configure web view
let config = WKWebViewConfiguration()
@@ -907,6 +1072,12 @@ final class BrowserPanel: Panel, ObservableObject {
navDelegate.openInNewTab = { [weak self] url in
self?.openLinkInNewTab(url: url)
}
+ navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in
+ self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false
+ }
+ navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] url, intent in
+ self?.presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false)
+ }
webView.navigationDelegate = navDelegate
self.navigationDelegate = navDelegate
@@ -916,6 +1087,9 @@ final class BrowserPanel: Panel, ObservableObject {
guard let self else { return }
self.openLinkInNewTab(url: url)
}
+ browserUIDelegate.requestNavigation = { [weak self] url, intent in
+ self?.requestNavigation(url, intent: intent)
+ }
webView.uiDelegate = browserUIDelegate
self.uiDelegate = browserUIDelegate
@@ -1208,9 +1382,20 @@ final class BrowserPanel: Panel, ObservableObject {
// MARK: - Navigation
/// Navigate to a URL
- func navigate(to url: URL) {
+ func navigate(to url: URL, recordTypedNavigation: Bool = false) {
+ if shouldBlockInsecureHTTPNavigation(to: url) {
+ presentInsecureHTTPAlert(for: url, intent: .currentTab, recordTypedNavigation: recordTypedNavigation)
+ return
+ }
+ navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation)
+ }
+
+ private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) {
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
+ if recordTypedNavigation {
+ BrowserHistoryStore.shared.recordTypedNavigation(url: url)
+ }
navigationDelegate?.lastAttemptedURL = url
var request = URLRequest(url: url)
// Behave like a normal browser (respect HTTP caching). Reload is handled separately.
@@ -1226,8 +1411,7 @@ final class BrowserPanel: Panel, ObservableObject {
guard !trimmed.isEmpty else { return }
if let url = resolveNavigableURL(from: trimmed) {
- BrowserHistoryStore.shared.recordTypedNavigation(url: url)
- navigate(to: url)
+ navigate(to: url, recordTypedNavigation: true)
return
}
@@ -1240,6 +1424,70 @@ final class BrowserPanel: Panel, ObservableObject {
resolveBrowserNavigableURL(input)
}
+ private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool {
+ if browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) {
+ return false
+ }
+ return browserShouldBlockInsecureHTTPURL(url)
+ }
+
+ private func requestNavigation(_ url: URL, intent: BrowserInsecureHTTPNavigationIntent) {
+ if shouldBlockInsecureHTTPNavigation(to: url) {
+ presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false)
+ return
+ }
+ switch intent {
+ case .currentTab:
+ navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: false)
+ case .newTab:
+ openLinkInNewTab(url: url)
+ }
+ }
+
+ private func presentInsecureHTTPAlert(
+ for url: URL,
+ intent: BrowserInsecureHTTPNavigationIntent,
+ recordTypedNavigation: Bool
+ ) {
+ guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return }
+
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = "Connection isn't secure"
+ alert.informativeText = """
+ \(host) uses plain HTTP, so traffic can be read or modified on the network.
+
+ Open this URL in your default browser, or proceed in cmux.
+ """
+ alert.addButton(withTitle: "Open in Default Browser")
+ alert.addButton(withTitle: "Proceed in cmux")
+ alert.addButton(withTitle: "Cancel")
+ alert.showsSuppressionButton = true
+ alert.suppressionButton?.title = "Always allow this host in cmux"
+
+ let response = alert.runModal()
+ if browserShouldPersistInsecureHTTPAllowlistSelection(
+ response: response,
+ suppressionEnabled: alert.suppressionButton?.state == .on
+ ) {
+ BrowserInsecureHTTPSettings.addAllowedHost(host)
+ }
+ switch response {
+ case .alertFirstButtonReturn:
+ NSWorkspace.shared.open(url)
+ case .alertSecondButtonReturn:
+ switch intent {
+ case .currentTab:
+ insecureHTTPBypassHostOnce = host
+ navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation)
+ case .newTab:
+ openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host)
+ }
+ default:
+ return
+ }
+ }
+
deinit {
webViewObservers.removeAll()
}
@@ -1290,11 +1538,16 @@ extension BrowserPanel {
}
/// Open a link in a new browser surface in the same pane
- func openLinkInNewTab(url: URL) {
+ func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) {
guard let tabManager = AppDelegate.shared?.tabManager,
let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }),
let paneId = workspace.paneId(forPanelId: id) else { return }
- workspace.newBrowserSurface(inPane: paneId, url: url, focus: true)
+ workspace.newBrowserSurface(
+ inPane: paneId,
+ url: url,
+ focus: true,
+ bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
+ )
}
/// Reload the current page
@@ -1445,6 +1698,8 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
var didFinish: ((WKWebView) -> Void)?
var didFailNavigation: ((WKWebView, String) -> Void)?
var openInNewTab: ((URL) -> Void)?
+ var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)?
+ var handleBlockedInsecureHTTPNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)?
/// The URL of the last navigation that was attempted. Used to preserve the omnibar URL
/// when a provisional navigation fails (e.g. connection refused on localhost:3000).
var lastAttemptedURL: URL?
@@ -1564,6 +1819,21 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
+ if let url = navigationAction.request.url,
+ navigationAction.targetFrame?.isMainFrame != false,
+ shouldBlockInsecureHTTPNavigation?(url) == true {
+ let intent: BrowserInsecureHTTPNavigationIntent
+ if navigationAction.navigationType == .linkActivated,
+ navigationAction.modifierFlags.contains(.command) {
+ intent = .newTab
+ } else {
+ intent = .currentTab
+ }
+ handleBlockedInsecureHTTPNavigation?(url, intent)
+ decisionHandler(.cancel)
+ return
+ }
+
// target=_blank or window.open() — navigate in the current webview
if navigationAction.targetFrame == nil,
let url = navigationAction.request.url {
@@ -1589,6 +1859,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
private class BrowserUIDelegate: NSObject, WKUIDelegate {
var openInNewTab: ((URL) -> Void)?
+ var requestNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)?
/// Returning nil tells WebKit not to open a new window.
/// Cmd+click opens in a new tab; regular target=_blank navigates in-place.
@@ -1599,7 +1870,11 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
windowFeatures: WKWindowFeatures
) -> WKWebView? {
if let url = navigationAction.request.url {
- if navigationAction.modifierFlags.contains(.command) {
+ if let requestNavigation {
+ let intent: BrowserInsecureHTTPNavigationIntent =
+ navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab
+ requestNavigation(url, intent)
+ } else if navigationAction.modifierFlags.contains(.command) {
openInNewTab?(url)
} else {
webView.load(URLRequest(url: url))
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index aa8a4b0f..9e397788 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -1071,7 +1071,19 @@ func buildOmnibarSuggestions(
)
order += 1
if let existing = bestByCompletion[key] {
- if ranked.score > existing.score {
+ let shouldReplaceExisting: Bool = {
+ // For identical completions, keep "go to URL" over "switch to tab" so
+ // pressing Enter performs navigation unless the user explicitly picks a tab row.
+ switch (existing.suggestion.kind, ranked.suggestion.kind) {
+ case (.navigate, .switchToTab):
+ return false
+ case (.switchToTab, .navigate):
+ return true
+ default:
+ return ranked.score > existing.score
+ }
+ }()
+ if shouldReplaceExisting {
bestByCompletion[key] = ranked
}
} else {
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index 71d28ded..29348c39 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -587,11 +587,16 @@ final class Workspace: Identifiable, ObservableObject {
inPane paneId: PaneID,
url: URL? = nil,
focus: Bool? = nil,
- insertAtEnd: Bool = false
+ insertAtEnd: Bool = false,
+ bypassInsecureHTTPHostOnce: String? = nil
) -> BrowserPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
- let browserPanel = BrowserPanel(workspaceId: id, initialURL: url)
+ let browserPanel = BrowserPanel(
+ workspaceId: id,
+ initialURL: url,
+ bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
+ )
panels[browserPanel.id] = browserPanel
guard let newTabId = bonsplitController.createTab(
diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift
index daf9ecd6..fd7d23b0 100644
--- a/Sources/cmuxApp.swift
+++ b/Sources/cmuxApp.swift
@@ -2306,6 +2306,7 @@ struct SettingsView: View {
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
+ @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@State private var shortcutResetToken = UUID()
@@ -2314,6 +2315,7 @@ struct SettingsView: View {
@State private var settingsTitleLeadingInset: CGFloat = 92
@State private var showClearBrowserHistoryConfirmation = false
@State private var browserHistoryEntryCount: Int = 0
+ @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
private var selectedWorkspacePlacement: NewWorkspacePlacement {
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
@@ -2334,6 +2336,10 @@ struct SettingsView: View {
}
}
+ private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool {
+ browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist
+ }
+
private func blurOpacity(forContentOffset offset: CGFloat) -> Double {
guard let baseline = topBlurBaselineOffset else { return 0 }
let reveal = (baseline - offset) / 24
@@ -2482,6 +2488,69 @@ struct SettingsView: View {
SettingsCardDivider()
+ VStack(alignment: .leading, spacing: 8) {
+ Text("HTTP Host Allowlist")
+ .font(.system(size: 13, weight: .semibold))
+
+ Text("HTTP loads outside this list show a warning prompt with options to open externally or proceed.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ TextEditor(text: $browserInsecureHTTPAllowlistDraft)
+ .font(.system(size: 12, weight: .regular, design: .monospaced))
+ .frame(minHeight: 86)
+ .padding(6)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(Color(nsColor: .textBackgroundColor))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField")
+
+ ViewThatFits(in: .horizontal) {
+ HStack(alignment: .center, spacing: 10) {
+ Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Spacer(minLength: 0)
+
+ Button("Save") {
+ saveBrowserInsecureHTTPAllowlist()
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
+ .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ HStack {
+ Spacer(minLength: 0)
+ Button("Save") {
+ saveBrowserInsecureHTTPAllowlist()
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
+ .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+
+ SettingsCardDivider()
+
SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) {
Button("Clear History…") {
showClearBrowserHistoryConfirmation = true
@@ -2605,6 +2674,13 @@ struct SettingsView: View {
.onAppear {
BrowserHistoryStore.shared.loadIfNeeded()
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
+ browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
+ }
+ .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in
+ // Keep draft in sync with external changes unless the user has local unsaved edits.
+ if browserInsecureHTTPAllowlistDraft == oldValue {
+ browserInsecureHTTPAllowlistDraft = newValue
+ }
}
.onReceive(BrowserHistoryStore.shared.$entries) { entries in
browserHistoryEntryCount = entries.count
@@ -2630,11 +2706,17 @@ struct SettingsView: View {
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
+ browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
+ browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
KeyboardShortcutSettings.resetAll()
shortcutResetToken = UUID()
}
+
+ private func saveBrowserInsecureHTTPAllowlist() {
+ browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft
+ }
}
private struct SettingsTopOffsetPreferenceKey: PreferenceKey {
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 07d399c6..d17a5f97 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -1425,6 +1425,39 @@ final class OmnibarSuggestionRankingTests: XCTestCase {
XCTAssertEqual(results.first?.completion, "https://gmail.com/")
}
+ func testNavigateSuggestionRanksAheadOfSwitchToTabForSameResolvedURL() throws {
+ let targetURL = try XCTUnwrap(URL(string: "http://http.badssl.com/"))
+
+ let results = buildOmnibarSuggestions(
+ query: targetURL.absoluteString,
+ engineName: "Google",
+ historyEntries: [],
+ openTabMatches: [
+ .init(
+ tabId: UUID(),
+ panelId: UUID(),
+ url: targetURL.absoluteString,
+ title: "http.badssl.com",
+ isKnownOpenTab: true
+ ),
+ ],
+ remoteQueries: [],
+ resolvedURL: targetURL,
+ limit: 8,
+ now: fixedNow
+ )
+
+ guard let first = results.first else {
+ XCTFail("Expected at least one suggestion")
+ return
+ }
+ guard case .navigate(let navigateURL) = first.kind else {
+ XCTFail("Expected first suggestion to be navigate, got \(first.kind)")
+ return
+ }
+ XCTAssertEqual(navigateURL, targetURL.absoluteString)
+ }
+
func testSuggestionSelectionPrefersAutocompletionCandidateAfterSuggestionsUpdate() {
let entries: [BrowserHistoryStore.Entry] = [
.init(
diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift
index 4efc8c2b..2dc252fd 100644
--- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift
+++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift
@@ -1,5 +1,7 @@
import XCTest
import Foundation
+import AppKit
+@testable import cmux_DEV
/// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths.
/// This prevents accidentally hiding the update UI in Release builds.
@@ -64,3 +66,133 @@ final class UpdatePillReleaseVisibilityTests: XCTestCase {
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
}
}
+
+/// Regression test: ensure WKWebView can load HTTP development URLs (e.g. *.localtest.me).
+final class AppTransportSecurityTests: XCTestCase {
+ func testInfoPlistAllowsArbitraryLoadsInWebContent() throws {
+ let projectRoot = findProjectRoot()
+ let infoPlistURL = projectRoot.appendingPathComponent("Resources/Info.plist")
+ let data = try Data(contentsOf: infoPlistURL)
+ var format = PropertyListSerialization.PropertyListFormat.xml
+ let plist = try XCTUnwrap(
+ PropertyListSerialization.propertyList(from: data, options: [], format: &format) as? [String: Any]
+ )
+ let ats = try XCTUnwrap(plist["NSAppTransportSecurity"] as? [String: Any])
+ XCTAssertEqual(
+ ats["NSAllowsArbitraryLoadsInWebContent"] as? Bool,
+ true,
+ "Resources/Info.plist must allow HTTP loads in WKWebView for local dev hostnames."
+ )
+ }
+
+ private func findProjectRoot() -> URL {
+ var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent()
+ for _ in 0..<10 {
+ let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj")
+ if FileManager.default.fileExists(atPath: marker.path) {
+ return dir
+ }
+ dir = dir.deletingLastPathComponent()
+ }
+ return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
+ }
+}
+
+final class BrowserInsecureHTTPSettingsTests: XCTestCase {
+ func testDefaultAllowlistPatternsArePresent() {
+ XCTAssertEqual(
+ BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: nil),
+ ["127.0.0.1", "localhost", "*.localtest.me"]
+ )
+ }
+
+ func testWildcardAndExactHostMatching() {
+ XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("localhost", rawAllowlist: nil))
+ XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("api.localtest.me", rawAllowlist: nil))
+ XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("neverssl.com", rawAllowlist: nil))
+ }
+
+ func testCustomAllowlistNormalizesAndDeduplicatesEntries() {
+ let raw = """
+ localhost
+ *.example.com
+ 127.0.0.1
+ https://dev.internal:8080/path
+ *.example.com
+ """
+
+ XCTAssertEqual(
+ BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: raw),
+ ["localhost", "*.example.com", "127.0.0.1", "dev.internal"]
+ )
+ XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("foo.example.com", rawAllowlist: raw))
+ XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("dev.internal", rawAllowlist: raw))
+ XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("example.net", rawAllowlist: raw))
+ }
+
+ func testBlockDecisionUsesAllowlistAndSchemeRules() throws {
+ let localURL = try XCTUnwrap(URL(string: "http://foo.localtest.me:3000"))
+ XCTAssertFalse(browserShouldBlockInsecureHTTPURL(localURL, rawAllowlist: nil))
+
+ let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com"))
+ XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil))
+
+ let httpsURL = try XCTUnwrap(URL(string: "https://neverssl.com"))
+ XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil))
+ }
+
+ func testOneTimeBypassIsConsumedAfterFirstNavigation() throws {
+ let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com"))
+ var bypassHostOnce: String? = "neverssl.com"
+
+ XCTAssertTrue(browserShouldConsumeOneTimeInsecureHTTPBypass(
+ insecureURL,
+ bypassHostOnce: &bypassHostOnce
+ ))
+ XCTAssertNil(bypassHostOnce)
+
+ // Subsequent visits should prompt again unless host was saved.
+ XCTAssertFalse(browserShouldConsumeOneTimeInsecureHTTPBypass(
+ insecureURL,
+ bypassHostOnce: &bypassHostOnce
+ ))
+ XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil))
+ }
+
+ func testAddAllowedHostPersistsToDefaultsAndUnblocksHTTP() throws {
+ let suiteName = "BrowserInsecureHTTPSettingsTests.Persist.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ XCTFail("Failed to create isolated UserDefaults suite")
+ return
+ }
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let url = try XCTUnwrap(URL(string: "http://persist-me.test"))
+ XCTAssertTrue(browserShouldBlockInsecureHTTPURL(url, defaults: defaults))
+
+ BrowserInsecureHTTPSettings.addAllowedHost("persist-me.test", defaults: defaults)
+ let persisted = defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey)
+ XCTAssertNotNil(persisted)
+ XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("persist-me.test", defaults: defaults))
+ XCTAssertFalse(browserShouldBlockInsecureHTTPURL(url, defaults: defaults))
+ }
+
+ func testAllowlistSelectionPersistsForProceedAndOpenExternal() {
+ XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection(
+ response: .alertFirstButtonReturn,
+ suppressionEnabled: true
+ ))
+ XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection(
+ response: .alertSecondButtonReturn,
+ suppressionEnabled: true
+ ))
+ XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection(
+ response: .alertThirdButtonReturn,
+ suppressionEnabled: true
+ ))
+ XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection(
+ response: .alertSecondButtonReturn,
+ suppressionEnabled: false
+ ))
+ }
+}