feat: add browser profile mapping import flow

This commit is contained in:
Lawrence Chen 2026-03-16 21:22:39 -07:00
parent 1d540d0806
commit 92cb42262c
No known key found for this signature in database
12 changed files with 4609 additions and 258 deletions

View file

@ -83,12 +83,14 @@
B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; };
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; };
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; };
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
@ -228,12 +230,14 @@
B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = "<group>"; };
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = "<group>"; };
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; };
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
@ -454,6 +458,7 @@
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */,
);
@ -468,6 +473,7 @@
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */,
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
@ -693,6 +699,7 @@
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */,
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */,
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */,
);
@ -707,6 +714,7 @@
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */,
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,

File diff suppressed because it is too large Load diff

View file

@ -2313,7 +2313,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
stopSocketListenerHealthMonitor()
TerminalController.shared.stop()
VSCodeServeWebController.shared.stop()
BrowserHistoryStore.shared.flushPendingSaves()
BrowserProfileStore.shared.flushPendingSaves()
if TelemetrySettings.enabledForCurrentLaunch {
PostHogAnalytics.shared.flush()
}
@ -8673,7 +8673,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
@discardableResult
func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else {
let preferredProfileID =
tabManager?.focusedBrowserPanel?.profileID
?? tabManager?.selectedWorkspace?.preferredBrowserProfileID
guard let panelId = tabManager?.openBrowser(
url: url,
preferredProfileID: preferredProfileID,
insertAtEnd: insertAtEnd
) else {
#if DEBUG
dlog(
"browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " +

File diff suppressed because it is too large Load diff

View file

@ -206,6 +206,7 @@ func resolvedBrowserOmnibarPillBackgroundColor(
/// View for rendering a browser panel with address bar
struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel
@ObservedObject private var browserProfileStore = BrowserProfileStore.shared
let paneId: PaneID
let isFocused: Bool
let isVisibleInUI: Bool
@ -236,6 +237,7 @@ struct BrowserPanelView: View {
@State private var lastHandledAddressBarFocusRequestId: UUID?
@State private var pendingAddressBarFocusRetryRequestId: UUID?
@State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0
@State private var isBrowserProfileMenuPresented = false
@State private var isBrowserThemeMenuPresented = false
@State private var ghosttyBackgroundGeneration: Int = 0
// Keep this below half of the compact omnibar height so it reads as a squircle,
@ -433,7 +435,7 @@ struct BrowserPanelView: View {
autoFocusOmnibarIfBlank()
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
refreshEmptyStateImportBrowsers()
BrowserHistoryStore.shared.loadIfNeeded()
panel.historyStore.loadIfNeeded()
#if DEBUG
logBrowserFocusState(event: "view.onAppear")
#endif
@ -469,6 +471,12 @@ struct BrowserPanelView: View {
.onChange(of: panel.pendingAddressBarFocusRequestId) { _ in
applyPendingAddressBarFocusRequestIfNeeded()
}
.onChange(of: panel.profileID) { _ in
panel.historyStore.loadIfNeeded()
if addressBarFocused {
refreshSuggestions()
}
}
.onChange(of: isFocused) { focused in
#if DEBUG
logBrowserFocusState(
@ -541,7 +549,7 @@ struct BrowserPanelView: View {
applyOmnibarEffects(effects)
refreshInlineCompletion()
}
.onReceive(BrowserHistoryStore.shared.$entries) { _ in
.onReceive(panel.historyStore.$entries) { _ in
guard addressBarFocused else { return }
refreshSuggestions()
}
@ -569,11 +577,10 @@ struct BrowserPanelView: View {
.accessibilityIdentifier("BrowserOmnibarPill")
.accessibilityLabel("Browser omnibar")
if !panel.isShowingNewTabPage {
browserProfileButton
browserThemeModeButton
developerToolsButton
}
}
.padding(.horizontal, 8)
.padding(.vertical, addressBarVerticalPadding)
.background(browserChromeBackground)
@ -677,6 +684,34 @@ struct BrowserPanelView: View {
.accessibilityIdentifier("BrowserToggleDevToolsButton")
}
private var browserProfileButton: some View {
Button(action: {
isBrowserProfileMenuPresented.toggle()
}) {
Image(systemName: "person.crop.circle")
.symbolRenderingMode(.monochrome)
.cmuxFlatSymbolColorRendering()
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(devToolsColorOption.color)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.popover(isPresented: $isBrowserProfileMenuPresented, arrowEdge: .bottom) {
browserProfilePopover
}
.safeHelp(
String(
format: String(
localized: "browser.profile.buttonHelp",
defaultValue: "Browser Profile: %@"
),
panel.profileDisplayName
)
)
.accessibilityIdentifier("BrowserProfileButton")
}
private var browserThemeModeButton: some View {
Button(action: {
isBrowserThemeMenuPresented.toggle()
@ -693,10 +728,76 @@ struct BrowserPanelView: View {
.popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) {
browserThemeModePopover
}
.safeHelp("Browser Theme: \(browserThemeMode.displayName)")
.safeHelp(
String(
format: String(
localized: "browser.theme.buttonHelp",
defaultValue: "Browser Theme: %@"
),
browserThemeMode.displayName
)
)
.accessibilityIdentifier("BrowserThemeModeButton")
}
private var browserProfilePopover: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
ForEach(browserProfileStore.profiles) { profile in
Button {
applyBrowserProfileSelection(profile.id)
} label: {
HStack(spacing: 8) {
Image(systemName: profile.id == panel.profileID ? "checkmark" : "circle")
.font(.system(size: 10, weight: .semibold))
.opacity(profile.id == panel.profileID ? 1.0 : 0.0)
.frame(width: 12, alignment: .center)
Text(profile.displayName)
.font(.system(size: 12))
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(height: 24)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(profile.id == panel.profileID ? Color.primary.opacity(0.12) : Color.clear)
)
}
.buttonStyle(.plain)
}
}
Divider()
Button {
isBrowserProfileMenuPresented = false
presentCreateBrowserProfilePrompt()
} label: {
Text(String(localized: "browser.profile.new", defaultValue: "New Profile..."))
.font(.system(size: 12))
}
.buttonStyle(.plain)
if browserProfileStore.canRenameProfile(id: panel.profileID) {
Button {
isBrowserProfileMenuPresented = false
presentRenameBrowserProfilePrompt()
} label: {
Text(String(localized: "browser.profile.rename", defaultValue: "Rename Current Profile..."))
.font(.system(size: 12))
}
.buttonStyle(.plain)
}
}
.padding(8)
.frame(minWidth: 208)
}
private var browserThemeModePopover: some View {
VStack(alignment: .leading, spacing: 2) {
ForEach(BrowserThemeMode.allCases) { mode in
@ -1145,7 +1246,9 @@ struct BrowserPanelView: View {
Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) {
refreshEmptyStateImportBrowsers()
BrowserDataImportCoordinator.shared.presentImportDialog()
BrowserDataImportCoordinator.shared.presentImportDialog(
defaultDestinationProfileID: panel.profileID
)
}
.buttonStyle(.bordered)
.controlSize(.small)
@ -1327,10 +1430,73 @@ struct BrowserPanelView: View {
let target = omnibarState.suggestions[idx]
guard case .history(let url, _) = target.kind else { return }
guard BrowserHistoryStore.shared.removeHistoryEntry(urlString: url) else { return }
guard panel.historyStore.removeHistoryEntry(urlString: url) else { return }
refreshSuggestions()
}
private func applyBrowserProfileSelection(_ profileID: UUID) {
isBrowserProfileMenuPresented = false
owningWorkspace?.setPreferredBrowserProfileID(profileID)
_ = panel.switchToProfile(profileID)
}
private func presentCreateBrowserProfilePrompt() {
let alert = NSAlert()
alert.messageText = String(localized: "browser.profile.new.title", defaultValue: "New Browser Profile")
alert.informativeText = String(localized: "browser.profile.new.message", defaultValue: "Create a separate browser profile for cookies, history, and local storage.")
let input = NSTextField(string: "")
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: String(localized: "common.create", defaultValue: "Create"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
guard alert.runModal() == .alertFirstButtonReturn,
let profile = browserProfileStore.createProfile(named: input.stringValue) else {
return
}
applyBrowserProfileSelection(profile.id)
}
private func presentRenameBrowserProfilePrompt() {
guard let profile = browserProfileStore.profileDefinition(id: panel.profileID),
browserProfileStore.canRenameProfile(id: profile.id) else {
return
}
let alert = NSAlert()
alert.messageText = String(localized: "browser.profile.rename.title", defaultValue: "Rename Browser Profile")
alert.informativeText = String(localized: "browser.profile.rename.message", defaultValue: "Choose a new name for this browser profile.")
let input = NSTextField(string: profile.displayName)
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
guard alert.runModal() == .alertFirstButtonReturn else { return }
_ = browserProfileStore.renameProfile(id: profile.id, to: input.stringValue)
}
private func refreshInlineCompletion() {
inlineCompletion = omnibarInlineCompletionForDisplay(
typedText: omnibarState.buffer,
@ -1366,9 +1532,9 @@ struct BrowserPanelView: View {
let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
let historyEntries: [BrowserHistoryStore.Entry] = {
if query.isEmpty {
return BrowserHistoryStore.shared.recentSuggestions(limit: 12)
return panel.historyStore.recentSuggestions(limit: 12)
}
return BrowserHistoryStore.shared.suggestions(for: query, limit: 12)
return panel.historyStore.suggestions(for: query, limit: 12)
}()
let openTabMatches = query.isEmpty ? [] : matchingOpenTabSuggestions(for: query, limit: 12)
let isSingleCharacterQuery = omnibarSingleCharacterQuery(for: query) != nil
@ -1432,7 +1598,7 @@ struct BrowserPanelView: View {
let merged = buildOmnibarSuggestions(
query: query,
engineName: searchEngine.displayName,
historyEntries: BrowserHistoryStore.shared.suggestions(for: query, limit: 12),
historyEntries: panel.historyStore.suggestions(for: query, limit: 12),
openTabMatches: matchingOpenTabSuggestions(for: query, limit: 12),
remoteQueries: remote,
resolvedURL: panel.resolveNavigableURL(from: query),

View file

@ -228,6 +228,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable {
struct SessionBrowserPanelSnapshot: Codable, Sendable {
var urlString: String?
var profileID: UUID?
var shouldRenderWebView: Bool
var pageZoom: Double
var developerToolsVisible: Bool

View file

@ -2450,6 +2450,7 @@ class TabManager: ObservableObject {
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil,
preferredProfileID: UUID? = nil,
focus: Bool = true
) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
@ -2458,14 +2459,24 @@ class TabManager: ObservableObject {
orientation: orientation,
insertFirst: insertFirst,
url: url,
preferredProfileID: preferredProfileID,
focus: focus
)?.id
}
/// Create a new browser surface in a pane
func newBrowserSurface(tabId: UUID, inPane paneId: PaneID, url: URL? = nil) -> UUID? {
func newBrowserSurface(
tabId: UUID,
inPane paneId: PaneID,
url: URL? = nil,
preferredProfileID: UUID? = nil
) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newBrowserSurface(inPane: paneId, url: url)?.id
return tab.newBrowserSurface(
inPane: paneId,
url: url,
preferredProfileID: preferredProfileID
)?.id
}
/// Get a browser panel by ID
@ -2480,6 +2491,7 @@ class TabManager: ObservableObject {
inWorkspace tabId: UUID,
url: URL? = nil,
preferSplitRight: Bool = false,
preferredProfileID: UUID? = nil,
insertAtEnd: Bool = false
) -> UUID? {
guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil }
@ -2493,7 +2505,8 @@ class TabManager: ObservableObject {
inPane: targetPaneId,
url: url,
focus: true,
insertAtEnd: insertAtEnd
insertAtEnd: insertAtEnd,
preferredProfileID: preferredProfileID
) {
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
return browserPanel.id
@ -2519,6 +2532,7 @@ class TabManager: ObservableObject {
from: splitSourcePanelId,
orientation: .horizontal,
url: url,
preferredProfileID: preferredProfileID,
focus: true
) {
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
@ -2531,7 +2545,8 @@ class TabManager: ObservableObject {
inPane: paneId,
url: url,
focus: true,
insertAtEnd: insertAtEnd
insertAtEnd: insertAtEnd,
preferredProfileID: preferredProfileID
) else {
return nil
}
@ -2541,12 +2556,17 @@ class TabManager: ObservableObject {
/// Open a browser in the currently focused pane (as a new surface)
@discardableResult
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
func openBrowser(
url: URL? = nil,
preferredProfileID: UUID? = nil,
insertAtEnd: Bool = false
) -> UUID? {
guard let tabId = selectedTabId else { return nil }
return openBrowser(
inWorkspace: tabId,
url: url,
preferSplitRight: false,
preferredProfileID: preferredProfileID,
insertAtEnd: insertAtEnd
)
}
@ -2642,7 +2662,12 @@ class TabManager: ObservableObject {
in workspace: Workspace
) -> UUID? {
if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }),
let browserPanel = workspace.newBrowserSurface(inPane: originalPane, url: snapshot.url, focus: true) {
let browserPanel = workspace.newBrowserSurface(
inPane: originalPane,
url: snapshot.url,
focus: true,
preferredProfileID: snapshot.profileID
) {
let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count
let maxIndex = max(0, tabCount - 1)
let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex)
@ -2659,7 +2684,8 @@ class TabManager: ObservableObject {
from: anchorPanelId,
orientation: orientation,
insertFirst: snapshot.fallbackSplitInsertFirst,
url: snapshot.url
url: snapshot.url,
preferredProfileID: snapshot.profileID
)?.id {
return browserPanelId
}
@ -2667,7 +2693,12 @@ class TabManager: ObservableObject {
guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else {
return nil
}
return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true)?.id
return workspace.newBrowserSurface(
inPane: focusedPane,
url: snapshot.url,
focus: true,
preferredProfileID: snapshot.profileID
)?.id
}
/// Flash the currently focused panel so the user can visually confirm focus.

View file

@ -335,6 +335,7 @@ extension Workspace {
let historySnapshot = browserPanel.sessionNavigationHistorySnapshot()
browserSnapshot = SessionBrowserPanelSnapshot(
urlString: browserPanel.preferredURLStringForOmnibar(),
profileID: browserPanel.profileID,
shouldRenderWebView: browserPanel.shouldRenderWebView,
pageZoom: Double(browserPanel.webView.pageZoom),
developerToolsVisible: browserPanel.isDeveloperToolsVisible(),
@ -516,7 +517,8 @@ extension Workspace {
guard let browserPanel = newBrowserSurface(
inPane: paneId,
url: initialURL,
focus: false
focus: false,
preferredProfileID: snapshot.browser?.profileID
) else {
return nil
}
@ -901,6 +903,7 @@ enum SidebarBranchOrdering {
struct ClosedBrowserPanelRestoreSnapshot {
let workspaceId: UUID
let url: URL?
let profileID: UUID?
let originalPaneId: UUID
let originalTabIndex: Int
let fallbackSplitOrientation: SplitOrientation?
@ -918,6 +921,7 @@ final class Workspace: Identifiable, ObservableObject {
@Published var isPinned: Bool = false
@Published var customColor: String? // hex string, e.g. "#C0392B"
@Published var currentDirectory: String
private(set) var preferredBrowserProfileID: UUID?
/// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session)
var portOrdinal: Int = 0
@ -1330,6 +1334,35 @@ final class Workspace: Identifiable, ObservableObject {
)
}
panelSubscriptions[browserPanel.id] = subscription
setPreferredBrowserProfileID(browserPanel.profileID)
}
func setPreferredBrowserProfileID(_ profileID: UUID?) {
guard let profileID else {
preferredBrowserProfileID = nil
return
}
guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return }
preferredBrowserProfileID = profileID
}
private func resolvedNewBrowserProfileID(
preferredProfileID: UUID? = nil,
sourcePanelId: UUID? = nil
) -> UUID {
if let preferredProfileID,
BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil {
return preferredProfileID
}
if let sourcePanelId,
let sourceBrowserPanel = browserPanel(for: sourcePanelId) {
return sourceBrowserPanel.profileID
}
if let preferredBrowserProfileID,
BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil {
return preferredBrowserProfileID
}
return BrowserProfileStore.shared.effectiveLastUsedProfileID
}
private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) {
@ -2197,6 +2230,7 @@ final class Workspace: Identifiable, ObservableObject {
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil,
preferredProfileID: UUID? = nil,
focus: Bool = true
) -> BrowserPanel? {
// Find the pane containing the source panel
@ -2213,9 +2247,17 @@ final class Workspace: Identifiable, ObservableObject {
guard let paneId = sourcePaneId else { return nil }
// Create browser panel
let browserPanel = BrowserPanel(workspaceId: id, initialURL: url)
let browserPanel = BrowserPanel(
workspaceId: id,
profileID: resolvedNewBrowserProfileID(
preferredProfileID: preferredProfileID,
sourcePanelId: panelId
),
initialURL: url
)
panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle
setPreferredBrowserProfileID(browserPanel.profileID)
// Pre-generate the bonsplit tab ID so the mapping exists before the split lands.
let newTab = Bonsplit.Tab(
@ -2271,17 +2313,20 @@ final class Workspace: Identifiable, ObservableObject {
url: URL? = nil,
focus: Bool? = nil,
insertAtEnd: Bool = false,
preferredProfileID: UUID? = nil,
bypassInsecureHTTPHostOnce: String? = nil
) -> BrowserPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
let browserPanel = BrowserPanel(
workspaceId: id,
profileID: resolvedNewBrowserProfileID(preferredProfileID: preferredProfileID),
initialURL: url,
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
)
panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle
setPreferredBrowserProfileID(browserPanel.profileID)
guard let newTabId = bonsplitController.createTab(
title: browserPanel.displayTitle,
@ -2754,6 +2799,7 @@ final class Workspace: Identifiable, ObservableObject {
pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot(
workspaceId: id,
url: resolvedURL,
profileID: browserPanel.profileID,
originalPaneId: pane.id,
originalTabIndex: tabIndex,
fallbackSplitOrientation: fallbackPlan?.orientation,
@ -3933,14 +3979,27 @@ final class Workspace: Identifiable, ObservableObject {
private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) {
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
guard let newPanel = newBrowserSurface(inPane: paneId, url: url, focus: true) else { return }
let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID }
guard let newPanel = newBrowserSurface(
inPane: paneId,
url: url,
focus: true,
preferredProfileID: preferredProfileID
) else { return }
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
}
private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) {
guard let panelId = panelIdFromSurfaceId(anchorTabId),
let browser = browserPanel(for: panelId) else { return }
createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL)
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
guard let newPanel = newBrowserSurface(
inPane: paneId,
url: browser.currentURL,
focus: true,
preferredProfileID: browser.profileID
) else { return }
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
}
private func promptRenamePanel(tabId: TabID) {

View file

@ -0,0 +1,232 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class BrowserImportMappingTests: XCTestCase {
@MainActor
func testDefaultExecutionPlanUsesSeparateModeForMultipleSourceProfiles() {
let defaultProfile = BrowserProfileDefinition(
id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!,
displayName: "Default",
createdAt: .distantPast,
isBuiltInDefault: true
)
let sourceProfiles = [
makeSourceProfile(displayName: "You", path: "/tmp/browser-import-you", isDefault: true),
makeSourceProfile(displayName: "austin", path: "/tmp/browser-import-austin", isDefault: false),
]
let plan = BrowserImportPlanResolver.defaultPlan(
selectedSourceProfiles: sourceProfiles,
destinationProfiles: [defaultProfile],
preferredSingleDestinationProfileID: defaultProfile.id
)
XCTAssertEqual(plan.mode, .separateProfiles)
XCTAssertEqual(plan.entries.count, 2)
XCTAssertEqual(plan.entries.map { $0.sourceProfiles.map(\.displayName) }, [["You"], ["austin"]])
}
@MainActor
func testDefaultExecutionPlanUsesSingleDestinationForSingleSourceProfile() {
let defaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!
let sourceProfile = makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-single",
isDefault: true
)
let plan = BrowserImportPlanResolver.defaultPlan(
selectedSourceProfiles: [sourceProfile],
destinationProfiles: [],
preferredSingleDestinationProfileID: defaultProfileID
)
XCTAssertEqual(plan.mode, .singleDestination)
XCTAssertEqual(plan.entries.count, 1)
XCTAssertEqual(plan.entries[0].sourceProfiles.map(\.displayName), ["You"])
}
@MainActor
func testSeparatePlanReusesExistingSameNamedDestinationProfiles() {
let workID = UUID()
let destinationProfiles = [
BrowserProfileDefinition(
id: workID,
displayName: "You",
createdAt: .distantPast,
isBuiltInDefault: false
)
]
let sourceProfiles = [
makeSourceProfile(displayName: " you ", path: "/tmp/browser-import-match", isDefault: true)
]
let plan = BrowserImportPlanResolver.separateProfilesPlan(
selectedSourceProfiles: sourceProfiles,
destinationProfiles: destinationProfiles
)
XCTAssertEqual(plan.entries.count, 1)
XCTAssertEqual(plan.entries[0].destination, .existing(workID))
}
@MainActor
func testSeparatePlanUsesStableCreateNamesWhenTwoSourceProfilesShareDisplayName() {
let sourceProfiles = [
makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-1", isDefault: true),
makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-2", isDefault: false),
]
let plan = BrowserImportPlanResolver.separateProfilesPlan(
selectedSourceProfiles: sourceProfiles,
destinationProfiles: []
)
XCTAssertEqual(plan.entries.count, 2)
XCTAssertEqual(plan.entries[0].destination, .createNamed("Work"))
XCTAssertEqual(plan.entries[1].destination, .createNamed("Work (2)"))
}
func testStep3PresentationShowsPerProfileRowsWhenPlanUsesSeparateMode() {
let presentation = BrowserImportStep3Presentation(
plan: BrowserImportExecutionPlan(
mode: .separateProfiles,
entries: [
BrowserImportExecutionEntry(
sourceProfiles: [
makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-presentation-separate",
isDefault: true
)
],
destination: .createNamed("You")
)
]
)
)
XCTAssertTrue(presentation.showsSeparateRows)
XCTAssertFalse(presentation.showsSingleDestinationPicker)
}
func testStep3PresentationShowsSingleDestinationPickerWhenPlanUsesMergeMode() {
let presentation = BrowserImportStep3Presentation(
plan: BrowserImportExecutionPlan(
mode: .mergeIntoOne,
entries: []
)
)
XCTAssertFalse(presentation.showsSeparateRows)
XCTAssertTrue(presentation.showsSingleDestinationPicker)
}
@MainActor
func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws {
let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = BrowserProfileStore(defaults: defaults)
let plan = BrowserImportExecutionPlan(
mode: .separateProfiles,
entries: [
BrowserImportExecutionEntry(
sourceProfiles: [
makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-realize-create",
isDefault: true
)
],
destination: .createNamed("You")
)
]
)
let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store)
XCTAssertEqual(realized.createdProfiles.map(\.displayName), ["You"])
XCTAssertEqual(store.profiles.map(\.displayName), ["Default", "You"])
}
@MainActor
func testRealizePlanReusesExistingProfileInsteadOfCreatingDuplicate() throws {
let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = BrowserProfileStore(defaults: defaults)
let existing = try XCTUnwrap(store.createProfile(named: "You"))
let plan = BrowserImportExecutionPlan(
mode: .separateProfiles,
entries: [
BrowserImportExecutionEntry(
sourceProfiles: [
makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-realize-existing",
isDefault: true
)
],
destination: .existing(existing.id)
)
]
)
let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store)
XCTAssertTrue(realized.createdProfiles.isEmpty)
XCTAssertEqual(realized.entries[0].destinationProfileID, existing.id)
}
func testAggregateOutcomeIncludesOneMappingLinePerDestination() {
let outcome = BrowserImportOutcome(
browserName: "Helium",
scope: .cookiesAndHistory,
domainFilters: [],
createdDestinationProfileNames: ["You", "austin"],
entries: [
BrowserImportOutcomeEntry(
sourceProfileNames: ["You"],
destinationProfileName: "You",
importedCookies: 10,
skippedCookies: 0,
importedHistoryEntries: 20,
warnings: []
),
BrowserImportOutcomeEntry(
sourceProfileNames: ["austin"],
destinationProfileName: "austin",
importedCookies: 5,
skippedCookies: 1,
importedHistoryEntries: 9,
warnings: []
),
],
warnings: []
)
let lines = BrowserImportOutcomeFormatter.lines(for: outcome)
XCTAssertTrue(lines.contains("You -> You"))
XCTAssertTrue(lines.contains("austin -> austin"))
XCTAssertTrue(lines.contains("Created cmux profiles: You, austin"))
}
private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile {
InstalledBrowserProfile(
displayName: displayName,
rootURL: URL(fileURLWithPath: path, isDirectory: true),
isDefault: isDefault
)
}
}

View file

@ -930,6 +930,7 @@ final class RecentlyClosedBrowserStackTests: XCTestCase {
ClosedBrowserPanelRestoreSnapshot(
workspaceId: UUID(),
url: URL(string: "https://example.com/\(index)"),
profileID: nil,
originalPaneId: UUID(),
originalTabIndex: index,
fallbackSplitOrientation: .horizontal,
@ -1614,6 +1615,104 @@ final class BrowserInstallDetectorTests: XCTestCase {
XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" }))
}
func testDetectInstalledBrowsersDiscoversHeliumProfilesFromChromiumLayout() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
let heliumRoot = home.appendingPathComponent("Library/Application Support/net.imput.helium", isDirectory: true)
try createFile(
at: heliumRoot.appendingPathComponent("Default/History"),
contents: Data()
)
try createFile(
at: heliumRoot.appendingPathComponent("Profile 1/Cookies"),
contents: Data()
)
try createFile(
at: heliumRoot.appendingPathComponent("Local State"),
contents: Data(
"""
{
"profile": {
"info_cache": {
"Default": {
"name": "Personal"
},
"Profile 1": {
"name": "Work"
}
}
}
}
""".utf8
)
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
guard let helium = detected.first(where: { $0.descriptor.id == "helium" }) else {
XCTFail("Expected Helium to be detected")
return
}
XCTAssertEqual(helium.family, .chromium)
XCTAssertEqual(helium.profiles.map(\.displayName), ["Personal", "Work"])
XCTAssertEqual(
helium.profiles.map(\.rootURL.lastPathComponent),
["Default", "Profile 1"]
)
}
func testDetectInstalledBrowsersDiscoversSafariProfiles() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home.appendingPathComponent("Library/Safari/History.db"),
contents: Data()
)
try createFile(
at: home.appendingPathComponent(
"Library/Safari/Profiles/Work/History.db"
),
contents: Data()
)
try createFile(
at: home.appendingPathComponent(
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel/History.db"
),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
guard let safari = detected.first(where: { $0.descriptor.id == "safari" }) else {
XCTFail("Expected Safari to be detected")
return
}
XCTAssertEqual(safari.profiles.map(\.displayName), ["Default", "Work", "Travel"])
XCTAssertEqual(
safari.profiles.map { $0.rootURL.path(percentEncoded: false) }.sorted(),
[
home.appendingPathComponent("Library/Safari", isDirectory: true).path(percentEncoded: false),
home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true).path(percentEncoded: false),
home.appendingPathComponent(
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel",
isDirectory: true
).path(percentEncoded: false),
].sorted()
)
}
private func makeTemporaryHome() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)")
}

View file

@ -150,8 +150,10 @@ final class SessionPersistenceTests: XCTestCase {
}
func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws {
let profileID = UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64")
let source = SessionBrowserPanelSnapshot(
urlString: "https://example.com/current",
profileID: profileID,
shouldRenderWebView: true,
pageZoom: 1.2,
developerToolsVisible: true,
@ -167,6 +169,7 @@ final class SessionPersistenceTests: XCTestCase {
let data = try JSONEncoder().encode(source)
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data)
XCTAssertEqual(decoded.urlString, source.urlString)
XCTAssertEqual(decoded.profileID, source.profileID)
XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings)
XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings)
}
@ -183,6 +186,7 @@ final class SessionPersistenceTests: XCTestCase {
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json)
XCTAssertEqual(decoded.urlString, "https://example.com/current")
XCTAssertNil(decoded.profileID)
XCTAssertNil(decoded.backHistoryURLStrings)
XCTAssertNil(decoded.forwardHistoryURLStrings)
}

View file

@ -0,0 +1,131 @@
import XCTest
import Foundation
final class BrowserImportProfilesUITests: XCTestCase {
private var capturePath = ""
override func setUp() {
super.setUp()
continueAfterFailure = false
capturePath = "/tmp/cmux-ui-test-browser-import-\(UUID().uuidString).json"
try? FileManager.default.removeItem(atPath: capturePath)
}
func testMultipleSourceProfilesDefaultToSeparateDestinations() throws {
let app = launchApp()
openImportWizard(app)
app.buttons["Next"].click()
app.buttons["Next"].click()
XCTAssertTrue(
app.radioButtons["Keep profiles separate"].waitForExistence(timeout: 5.0),
"Expected Step 3 to show the separate-profiles default"
)
XCTAssertTrue(app.radioButtons["Merge all into one cmux profile"].exists)
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-you"].exists)
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-austin"].exists)
app.buttons["Start Import"].click()
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
XCTAssertEqual(capture["mode"] as? String, "separateProfiles")
XCTAssertEqual(capture["scope"] as? String, "cookiesAndHistory")
let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]])
XCTAssertEqual(entries.count, 2)
XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You"])
XCTAssertEqual(entries[0]["destinationKind"] as? String, "create")
XCTAssertEqual(entries[0]["destinationName"] as? String, "You")
XCTAssertEqual(entries[1]["sourceProfiles"] as? [String], ["austin"])
XCTAssertEqual(entries[1]["destinationKind"] as? String, "create")
XCTAssertEqual(entries[1]["destinationName"] as? String, "austin")
}
func testMergeModeCapturesSingleMergedDestination() throws {
let app = launchApp()
openImportWizard(app)
app.buttons["Next"].click()
app.buttons["Next"].click()
let mergeRadio = app.radioButtons["Merge all into one cmux profile"]
XCTAssertTrue(mergeRadio.waitForExistence(timeout: 5.0))
mergeRadio.click()
XCTAssertTrue(
app.popUpButtons["BrowserImportDestinationPopup-merge"].waitForExistence(timeout: 5.0),
"Expected merge mode to show the single destination popup"
)
app.buttons["Start Import"].click()
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
XCTAssertEqual(capture["mode"] as? String, "mergeIntoOne")
let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]])
XCTAssertEqual(entries.count, 1)
XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You", "austin"])
XCTAssertEqual(entries[0]["destinationKind"] as? String, "existing")
XCTAssertEqual(entries[0]["destinationName"] as? String, "Default")
}
private func launchApp() -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"#
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"#
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath
app.launch()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch in the foreground for browser import UI tests"
)
return app
}
private func openImportWizard(_ app: XCUIApplication) {
let viewMenu = app.menuBars.menuBarItems["View"].firstMatch
XCTAssertTrue(viewMenu.waitForExistence(timeout: 5.0), "Expected View menu to exist")
viewMenu.click()
let importItem = app.menuItems["Import From Browser…"].firstMatch
XCTAssertTrue(importItem.waitForExistence(timeout: 5.0), "Expected Import From Browser menu item to exist")
importItem.click()
XCTAssertTrue(
app.staticTexts["Import Browser Data"].waitForExistence(timeout: 5.0),
"Expected the import wizard to open"
)
}
private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? {
let deadline = Date().addingTimeInterval(timeout)
let url = URL(fileURLWithPath: capturePath)
while Date() < deadline {
if let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return object
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
guard let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return object
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
if app.state == .runningBackground {
app.activate()
return app.wait(for: .runningForeground, timeout: 6.0)
}
return false
}
}