Add browser import flow with installed-browser detection

This commit is contained in:
Lawrence Chen 2026-02-22 15:50:28 -08:00
parent 1809b06867
commit 9dd66980ff
4 changed files with 1742 additions and 1 deletions

File diff suppressed because it is too large Load diff

View file

@ -175,6 +175,7 @@ struct BrowserPanelView: View {
@State private var isLoadingRemoteSuggestions: Bool = false
@State private var latestRemoteSuggestionQuery: String = ""
@State private var latestRemoteSuggestions: [String] = []
@State private var emptyStateImportBrowsers: [InstalledBrowserCandidate] = []
@State private var inlineCompletion: OmnibarInlineCompletion?
@State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0)
@State private var omnibarHasMarkedText: Bool = false
@ -304,6 +305,7 @@ struct BrowserPanelView: View {
syncURLFromPanel()
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
autoFocusOmnibarIfBlank()
refreshEmptyStateImportBrowsers()
BrowserHistoryStore.shared.loadIfNeeded()
}
.onChange(of: panel.focusFlashToken) { _ in
@ -320,6 +322,9 @@ struct BrowserPanelView: View {
!isWebViewBlank() {
addressBarFocused = false
}
if isWebViewBlank() {
refreshEmptyStateImportBrowsers()
}
}
.onChange(of: forcedDarkModeEnabled) { _ in
panel.setForcedDarkMode(
@ -644,7 +649,12 @@ struct BrowserPanelView: View {
if addressBarFocused {
addressBarFocused = false
}
}
}
}
}
.overlay {
if isWebViewBlank() {
emptyBrowserStateOverlay
}
}
.zIndex(0)
@ -693,6 +703,56 @@ struct BrowserPanelView: View {
panel.acknowledgeAddressBarFocusRequest(requestId)
}
private var emptyBrowserStateOverlay: some View {
VStack {
Spacer(minLength: 22)
VStack(alignment: .leading, spacing: 10) {
Text("Start browsing")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.primary)
Text("Search the web, enter a URL, or import cookies/history from another browser.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers))
.font(.system(size: 12))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
Button("Focus Address Bar") {
onRequestPanelFocus()
addressBarFocused = true
}
.buttonStyle(.bordered)
Button("Import Browser Data…") {
refreshEmptyStateImportBrowsers()
BrowserDataImportCoordinator.shared.presentImportDialog()
}
.buttonStyle(.borderedProminent)
}
}
.padding(16)
.frame(maxWidth: 460, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.96))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color(nsColor: .separatorColor).opacity(0.6), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.12), radius: 12, y: 4)
Spacer()
}
.padding(.horizontal, 18)
}
/// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes.
private func isWebViewBlank() -> Bool {
guard let url = panel.webView.url else { return true }
@ -710,6 +770,10 @@ struct BrowserPanelView: View {
addressBarFocused = true
}
private func refreshEmptyStateImportBrowsers() {
emptyStateImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers()
}
private func openDevTools() {
#if DEBUG
dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))")

View file

@ -470,6 +470,10 @@ struct cmuxApp: App {
BrowserHistoryStore.shared.clearHistory()
}
Button("Import Browser Data…") {
BrowserDataImportCoordinator.shared.presentImportDialog()
}
Button("Next Workspace") {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextTab()
}
@ -2474,6 +2478,7 @@ struct SettingsView: View {
@State private var showOpenAccessConfirmation = false
@State private var pendingOpenAccessMode: SocketControlMode?
@State private var browserHistoryEntryCount: Int = 0
@State private var detectedImportBrowsers: [InstalledBrowserCandidate] = []
@State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
@State private var socketPasswordDraft = ""
@State private var socketPasswordStatusMessage: String?
@ -2521,6 +2526,10 @@ struct SettingsView: View {
}
}
private var browserImportSubtitle: String {
InstalledBrowserDetector.summaryText(for: detectedImportBrowsers)
}
private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool {
browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist
}
@ -2917,6 +2926,25 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow("Import Browser Data", subtitle: browserImportSubtitle) {
HStack(spacing: 8) {
Button("Import…") {
BrowserDataImportCoordinator.shared.presentImportDialog()
refreshDetectedImportBrowsers()
}
.buttonStyle(.bordered)
.controlSize(.small)
Button("Refresh") {
refreshDetectedImportBrowsers()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
SettingsCardDivider()
SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) {
Button("Clear History…") {
showClearBrowserHistoryConfirmation = true
@ -3042,6 +3070,7 @@ struct SettingsView: View {
browserForcedDarkModeOpacity = BrowserForcedDarkModeSettings.normalizedOpacity(browserForcedDarkModeOpacity)
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
refreshDetectedImportBrowsers()
}
.onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in
// Keep draft in sync with external changes unless the user has local unsaved edits.
@ -3103,6 +3132,7 @@ struct SettingsView: View {
socketPasswordDraft = ""
socketPasswordStatusMessage = nil
socketPasswordStatusIsError = false
refreshDetectedImportBrowsers()
KeyboardShortcutSettings.resetAll()
shortcutResetToken = UUID()
}
@ -3110,6 +3140,10 @@ struct SettingsView: View {
private func saveBrowserInsecureHTTPAllowlist() {
browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft
}
private func refreshDetectedImportBrowsers() {
detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers()
}
}
private struct SettingsTopOffsetPreferenceKey: PreferenceKey {

View file

@ -519,3 +519,87 @@ final class PostHogAnalyticsPropertiesTests: XCTestCase {
XCTAssertNil(dailyProperties["app_build"])
}
}
final class BrowserInstallDetectorTests: XCTestCase {
func testDetectInstalledBrowsersUsesBundleIdAndProfileData() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Google/Chrome/Default/History"),
contents: Data()
)
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Firefox/Profiles/dev.default-release/cookies.sqlite"),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { bundleIdentifier in
if bundleIdentifier == "com.google.Chrome" {
return URL(fileURLWithPath: "/Applications/Google Chrome.app", isDirectory: true)
}
return nil
},
applicationSearchDirectories: []
)
guard let chrome = detected.first(where: { $0.descriptor.id == "google-chrome" }) else {
XCTFail("Expected Chrome to be detected")
return
}
guard let firefox = detected.first(where: { $0.descriptor.id == "firefox" }) else {
XCTFail("Expected Firefox to be detected from profile data")
return
}
XCTAssertNotNil(chrome.appURL)
XCTAssertEqual(firefox.profileURLs.count, 1)
XCTAssertNil(firefox.appURL)
}
func testDetectInstalledBrowsersReturnsEmptyWhenNoSignalsExist() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
XCTAssertTrue(detected.isEmpty)
}
func testUngoogledChromiumRequiresAppSignal() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Chromium/Default/History"),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
XCTAssertTrue(detected.contains(where: { $0.descriptor.id == "chromium" }))
XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" }))
}
private func makeTemporaryHome() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)")
}
private func createFile(at url: URL, contents: Data) throws {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
_ = FileManager.default.createFile(atPath: url.path, contents: contents)
}
}