Add browser import flow with installed-browser detection
This commit is contained in:
parent
1809b06867
commit
9dd66980ff
4 changed files with 1742 additions and 1 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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))")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue